Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ enum Commands {
Revert(command::revert::RevertArgs),
#[command(subcommand, about = "Manage set of tracked repositories")]
Remote(command::remote::RemoteCmds),
#[command(about = "Open the repository in the browser")]
Open(command::open::OpenArgs),
#[command(about = "Manage repository configurations")]
Config(command::config::ConfigArgs),
#[command(about = "Manage the log of reference changes (e.g., HEAD, branches)")]
Expand Down Expand Up @@ -209,6 +211,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> Result<(), GitError> {
Commands::Blame(args) => command::blame::execute(args).await,
Commands::Revert(args) => command::revert::execute(args).await,
Commands::Remote(cmd) => command::remote::execute(cmd).await,
Commands::Open(args) => command::open::execute(args).await,
Commands::Pull(args) => command::pull::execute(args).await,
Commands::Config(args) => command::config::execute(args).await,
Commands::Checkout(args) => command::checkout::execute(args).await,
Expand Down
1 change: 1 addition & 0 deletions src/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod init;
pub mod lfs;
pub mod log;
pub mod merge;
pub mod open;
pub mod pull;
pub mod push;
pub mod rebase;
Expand Down
162 changes: 162 additions & 0 deletions src/command/open.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
use std::process::Command;
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing module-level documentation. All other command modules in this codebase include a //! doc comment at the top describing the module's purpose (see add.rs, blame.rs, branch.rs, etc.). Add a module-level doc comment following the established convention, e.g., "//! Opens the remote repository URL in the default browser by transforming Git URLs to HTTPS format."

Copilot uses AI. Check for mistakes.

use clap::Parser;
use lazy_static::lazy_static;
use regex::Regex;

use crate::internal::config::Config;

#[derive(Parser, Debug)]
pub struct OpenArgs {
/// The remote to open
pub remote: Option<String>,
}

lazy_static! {
static ref SCP_RE: Regex = Regex::new(r"^git@([^:]+):(.+?)(\.git)?$").expect("Invalid Regex");
static ref SSH_RE: Regex =
Regex::new(r"^ssh://(?:[^@]+@)?([^:/]+)(?::\d+)?/(.+?)(\.git)?$").expect("Invalid Regex");
}
Comment on lines +15 to +19
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern uses expect("Invalid Regex") which violates the coding guideline that library code should avoid expect(). While this is a static regex that should never fail, if it does fail, it will panic. Consider using once_cell::sync::Lazy instead of lazy_static, which is more modern, or handle the potential error more gracefully. However, since this is in the command layer and the regex is statically validated, this may be acceptable. Note that lazy_static is also considered somewhat legacy compared to once_cell or std::sync::LazyLock (stable in Rust 1.80+).

Copilot uses AI. Check for mistakes.

pub async fn execute(args: OpenArgs) {
let in_repo = crate::utils::util::check_repo_exist();

let remote_url = if let Some(input) = args.remote {
if in_repo {
let remotes = Config::all_remote_configs().await;
if remotes.iter().any(|r| r.name == input) {
Config::get_remote_url(&input).await
} else {
// If not found in remotes, treat input as the URL directly
input
}
} else {
// Not in repo, treat input as URL directly
input
}
} else {
if !in_repo {
eprintln!("fatal: not a libra repository (or any of the parent directories): .libra");
return;
}
match Config::get_current_remote_url().await {
Some(url) => url,
Comment on lines +42 to +43

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid panic on detached HEAD in open default path

libra open calls Config::get_current_remote_url() here when no remote is supplied; that helper unwrap()s and panics if HEAD is detached. In that scenario, the command crashes before reaching the fallback to origin/first remote, so running open in a detached HEAD state will unexpectedly abort. Consider handling the detached-HEAD error and continuing to the fallback branch instead of unwrapping.

Useful? React with 👍 / 👎.

None => {
Comment on lines +42 to +44

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard against detached HEAD before resolving default remote

When no remote is provided, this path calls Config::get_current_remote_url().await directly. That helper unwraps the result of get_current_remote_with_conn (see src/internal/config.rs around the unwrap() at lines 152-154), which panics on detached HEAD. Running libra open in a detached HEAD repo will therefore crash instead of emitting a user-friendly error. Consider checking get_current_remote_with_conn for Err (or providing a safe wrapper) before calling here so the command can report the detached HEAD condition gracefully.

Useful? React with 👍 / 👎.

// Fallback, try origin
let remotes = Config::all_remote_configs().await;
if let Some(origin) = remotes.iter().find(|r| r.name == "origin") {
origin.url.clone()
} else if let Some(first) = remotes.first() {
first.url.clone() // Fallback to first available remote
} else {
eprintln!("fatal: No remote configured");
return;
}
}
}
Comment on lines +42 to +56
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback logic for finding a remote has potential issues. When Config::get_current_remote_url() returns None, the code falls back to "origin", then to the first remote. However, this behavior is not clearly communicated to the user. Consider adding informational messages like "Using remote 'origin'" or "Using remote 'X' (first available)" so users understand which remote is being opened. This is especially important when multiple remotes exist.

Copilot uses AI. Check for mistakes.
};

let url = transform_url(&remote_url);

if !is_safe_url(&url) {
eprintln!(
"fatal: calculated URL '{}' is unsafe or invalid. Only http/https are supported.",
url
);
return;
}

println!("Opening {}", url);

if let Err(e) = open_browser(&url) {
eprintln!("error: failed to open browser: {}", e);
}
}

fn open_browser(url: &str) -> std::io::Result<()> {
#[cfg(target_os = "windows")]
{
Command::new("cmd").args(["/C", "start", "", url]).spawn()?;
}
#[cfg(target_os = "macos")]
{
Command::new("open").arg(url).spawn()?;
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
Command::new("xdg-open").arg(url).spawn()?;
}
Ok(())
}

fn is_safe_url(url: &str) -> bool {
// Validates that the URL uses http or https scheme.
// This blocks local file access, javascript:, or other potential injection vectors
match url::Url::parse(url) {
Ok(parsed) => parsed.scheme() == "http" || parsed.scheme() == "https",
Err(_) => false,
}
}
Comment on lines 78 to 99
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling: Silent failures

All three platform-specific commands use .ok() which silently ignores errors. Users won't know if:

  • The command doesn't exist on their system
  • The URL format is invalid
  • The browser launch failed

Recommendation:

let result = Command::new("open").arg(&url).spawn();
match result {
    Ok(_) => {},
    Err(e) => eprintln!("warning: Failed to open browser: {}", e),
}


fn transform_url(remote: &str) -> String {
if remote.starts_with("http://") || remote.starts_with("https://") {
return remote.trim_end_matches(".git").to_string();
}

// Handle SCP-like syntax: git@github.com:user/repo.git
if let Some(caps) = SCP_RE.captures(remote) {
let host = &caps[1];
let path = &caps[2];
return format!("https://{}/{}", host, path);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code clarity: Direct string formatting

Minor: The format! macro can be simplified when concatenating with /:

return format!("https://{host}/{path}");

This is more idiomatic Rust.

}

// Handle ssh:// syntax
// ssh://[user@]host.xz[:port]/path/to/repo.git/
if let Some(caps) = SSH_RE.captures(remote) {
let host = &caps[1];
let path = &caps[2];
return format!("https://{}/{}", host, path);
}

// Fallback: return as is, maybe it is already workable or user has weird config
remote.to_string()
}

// Unit test
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_transform_url() {
assert_eq!(
transform_url("git@github.com:rust-lang/rust.git"),
"https://github.com/rust-lang/rust"
);
assert_eq!(
transform_url("git@gitlab.com:group/project.git"),
"https://gitlab.com/group/project"
);
assert_eq!(
transform_url("https://github.com/rust-lang/rust.git"),
"https://github.com/rust-lang/rust"
);
assert_eq!(
transform_url("ssh://git@github.com/rust-lang/rust.git"),
"https://github.com/rust-lang/rust"
);
assert_eq!(
transform_url("ssh://user@host.com:2222/repo.git"),
"https://host.com/repo"
);
}
Comment on lines +131 to +152
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unit tests don't cover several important edge cases:

  1. URLs with usernames other than 'git' in SCP format (e.g., 'myuser@github.com:repo/path.git')
  2. HTTPS URLs with trailing slashes
  3. URLs without .git suffix
  4. Malformed URLs that should fail gracefully
  5. File:// URLs (should be rejected by is_safe_url)
  6. URLs with query parameters or fragments

Consider adding test cases for these scenarios to ensure robust URL handling.

Copilot uses AI. Check for mistakes.

#[test]
fn test_is_safe_url() {
assert!(is_safe_url("https://github.com/rust-lang/rust"));
assert!(is_safe_url("http://github.com/rust-lang/rust"));
assert!(!is_safe_url("file:///etc/passwd"));
assert!(!is_safe_url("javascript:alert(1)"));
assert!(!is_safe_url("ftp://github.com/rust-lang/rust"));
}
}
1 change: 1 addition & 0 deletions tests/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mod init_test;
mod lfs_test;
mod log_test;
mod merge_test;
mod open_test;
mod pull_test;
mod push_test;
mod rebase_test;
Expand Down
51 changes: 51 additions & 0 deletions tests/command/open_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Tests open command integration to ensure it finds remote correctly.
use libra::{
command::{
open,
remote::{self, RemoteCmds},
},
utils::test,
};
use serial_test::serial;
use tempfile::tempdir;

#[tokio::test]
#[serial]
async fn test_open_remote_origin() {
let repo_dir = tempdir().unwrap();
test::setup_with_new_libra_in(repo_dir.path()).await;
let _guard = test::ChangeDirGuard::new(repo_dir.path());

// Add origin remote
remote::execute(RemoteCmds::Add {
name: "origin".into(),
url: "git@github.com:user/repo.git".into(),
})
.await;

// Test explicit remote
open::execute(open::OpenArgs {
remote: Some("origin".to_string()),
})
.await;

// Test default remote should find origin
open::execute(open::OpenArgs { remote: None }).await;

// Test non-existent remote
open::execute(open::OpenArgs {
remote: Some("nonexistent".to_string()),
})
.await;
}
Comment on lines 14 to 40
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This integration test lacks assertions to verify the behavior. The test calls open::open() but doesn't check:

  1. Whether the function succeeds or fails appropriately
  2. What error messages are printed for the non-existent remote case (line 35-39)
  3. Any state changes or side effects

Without assertions, this test will always pass even if the functionality is broken. Consider capturing stdout/stderr or having the open function return a Result that can be asserted, or at minimum verify that appropriate error messages are output for error cases.

Copilot uses AI. Check for mistakes.
Comment on lines 14 to 40
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These integration tests will attempt to actually open a browser during test execution, which is problematic for several reasons:

  1. CI/CD environments typically don't have browsers or display servers configured
  2. Opening browsers during automated tests is disruptive to developers
  3. The tests cannot verify whether the browser opened successfully

Consider refactoring the open function to accept a dependency injection parameter for browser opening (or use a trait), so tests can mock the browser-opening behavior without actually spawning processes. Alternatively, add a feature flag or environment variable to skip browser opening during tests.

Copilot uses AI. Check for mistakes.

#[tokio::test]
#[serial]
async fn test_open_no_remote() {
let repo_dir = tempdir().unwrap();
test::setup_with_new_libra_in(repo_dir.path()).await;
let _guard = test::ChangeDirGuard::new(repo_dir.path());

// Should handle no remote configured
open::execute(open::OpenArgs { remote: None }).await;
}
Comment on lines 12 to 51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: Limited assertions

The test doesn't verify:

  1. That the correct URL was opened (no way to capture what was sent to the browser)
  2. That the URL transformation worked correctly
  3. Different remote URL formats (HTTPS, SSH, SCP)
  4. Error cases (no remote, invalid remote name)

Recommendation:
Consider adding unit tests for the transform_url function (which you already have ✓) and integration tests for error scenarios:

#[tokio::test]
#[serial]
async fn test_open_nonexistent_remote() {
    let repo_dir = tempdir().unwrap();
    test::setup_with_new_libra_in(repo_dir.path()).await;
    let _guard = test::ChangeDirGuard::new(repo_dir.path());
    
    // Should print error message and not panic
    open::open(open::OpenArgs {
        remote: Some("nonexistent".to_string()),
    })
    .await;
}

#[tokio::test]
#[serial]
async fn test_open_no_remotes_configured() {
    let repo_dir = tempdir().unwrap();
    test::setup_with_new_libra_in(repo_dir.path()).await;
    let _guard = test::ChangeDirGuard::new(repo_dir.path());
    
    // Should print error message and not panic
    open::open(open::OpenArgs { remote: None }).await;
}

Comment on lines 44 to 51
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test lacks assertions. It calls open::open() with no remote configured, but doesn't verify:

  1. That an appropriate error message is printed
  2. That the function handles the error gracefully

Without assertions, this test will pass even if the function panics or behaves incorrectly. Add assertions to verify expected error output or return values.

Copilot uses AI. Check for mistakes.