diff --git a/cli/src/commands/git/clone.rs b/cli/src/commands/git/clone.rs index f9e14888246..6401da2f300 100644 --- a/cli/src/commands/git/clone.rs +++ b/cli/src/commands/git/clone.rs @@ -19,6 +19,7 @@ use std::num::NonZeroU32; use std::path::Path; use std::path::PathBuf; +use clap_complete::ArgValueCompleter; use jj_lib::git; use jj_lib::git::GitFetchError; use jj_lib::git::GitFetchStats; @@ -33,6 +34,7 @@ use crate::command_error::user_error; use crate::command_error::user_error_with_message; use crate::command_error::CommandError; use crate::commands::git::maybe_add_gitignore; +use crate::complete; use crate::config::write_config_value_to_file; use crate::config::ConfigNamePathBuf; use crate::git_util::get_git_repo; @@ -47,7 +49,7 @@ use crate::ui::Ui; #[derive(clap::Args, Clone, Debug)] pub struct GitCloneArgs { /// URL or path of the Git repo to clone - #[arg(value_hint = clap::ValueHint::DirPath)] + #[arg(add = ArgValueCompleter::new(complete::new_git_remote_url))] source: String, /// Specifies the target directory for the Jujutsu repository clone. /// If not provided, defaults to a directory named after the last component diff --git a/cli/src/commands/git/remote/add.rs b/cli/src/commands/git/remote/add.rs index a66a7faac89..747a251922a 100644 --- a/cli/src/commands/git/remote/add.rs +++ b/cli/src/commands/git/remote/add.rs @@ -12,11 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +use clap_complete::ArgValueCompleter; use jj_lib::git; use jj_lib::repo::Repo; use crate::cli_util::CommandHelper; use crate::command_error::CommandError; +use crate::complete; use crate::git_util::get_git_repo; use crate::ui::Ui; @@ -26,6 +28,7 @@ pub struct GitRemoteAddArgs { /// The remote's name remote: String, /// The remote's URL + #[arg(add = ArgValueCompleter::new(complete::new_git_remote_url))] url: String, } diff --git a/cli/src/commands/git/remote/set_url.rs b/cli/src/commands/git/remote/set_url.rs index 770efa7f505..380497d362e 100644 --- a/cli/src/commands/git/remote/set_url.rs +++ b/cli/src/commands/git/remote/set_url.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use clap_complete::ArgValueCandidates; +use clap_complete::{ArgValueCandidates, ArgValueCompleter}; use jj_lib::git; use jj_lib::repo::Repo; @@ -29,6 +29,7 @@ pub struct GitRemoteSetUrlArgs { #[arg(add = ArgValueCandidates::new(complete::git_remotes))] remote: String, /// The desired url for `remote` + #[arg(add = ArgValueCompleter::new(complete::new_git_remote_url))] url: String, } diff --git a/cli/src/complete.rs b/cli/src/complete.rs index 997c8908583..b1cac5e5ad2 100644 --- a/cli/src/complete.rs +++ b/cli/src/complete.rs @@ -193,6 +193,62 @@ pub fn git_remotes() -> Vec { }) } +pub fn new_git_remote_url(current: &std::ffi::OsStr) -> Vec { + let Some(current) = current.to_str() else { + return Vec::new(); + }; + + let known_schemas = ["git@", "https://", "http://"]; + + let schema_candiates = known_schemas + .iter() + .filter(|&&schema| schema.starts_with(current) && !current.starts_with(schema)) + .map(CompletionCandidate::new) + .collect_vec(); + if !schema_candiates.is_empty() { + return schema_candiates; + } + + let Some(&schema) = known_schemas.iter().find(|&s| current.starts_with(s)) else { + // We don't know the schema the user is attempting to use. + return Vec::new(); + }; + let current = current.strip_prefix(schema).unwrap(); + + let domain_terminator = match schema { + "git@" => ":", + _ => "/", // http, probably + }; + + if current.contains(domain_terminator) { + // Completing the path after the domain as well would be cool, but it's + // more difficult. One approach could be to cache the URLs that were + // cloned previously. Users probably clone repositories from the same + // user / organisations frequently. + return Vec::new(); + } + + let Some(known_hosts) = dirs::home_dir() + .and_then(|home| std::fs::read_to_string(home.join(".ssh/known_hosts")).ok()) + else { + return Vec::new(); // cannot determine domain candidates + }; + + let mut candidates = Vec::new(); + for line in known_hosts.lines() { + let domain = line.split_whitespace().next().unwrap_or(line); + if !domain.starts_with(current) { + continue; + } + let display_order = if domain.contains("git") { 0 } else { 1 }; + candidates.push( + CompletionCandidate::new(format!("{schema}{domain}{domain_terminator}")) + .display_order(Some(display_order)), + ); + } + candidates +} + pub fn aliases() -> Vec { with_jj(|_, config| { Ok(config diff --git a/cli/tests/test_completion.rs b/cli/tests/test_completion.rs index c3bea0ac68d..09b5e922789 100644 --- a/cli/tests/test_completion.rs +++ b/cli/tests/test_completion.rs @@ -400,3 +400,80 @@ fn test_operations() { let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "op", "undo", "5b"]); insta::assert_snapshot!(stdout, @"5bbb4ca536a8 (2001-02-03 08:05:12) describe commit 968261075dddabf4b0e333c1cc9a49ce26a3f710"); } + +#[test] +fn test_new_git_remote() { + let test_env = TestEnvironment::default(); + + let ssh_dir = test_env.home_dir().join(".ssh"); + std::fs::create_dir(&ssh_dir).unwrap(); + std::fs::write( + ssh_dir.join("known_hosts"), + "\ +random-server.com long-fingerprint +self-hosted-git-server.com long-fingerprint +company-git-server.com long-fingerprint +", + ) + .unwrap(); + + let mut test_env = test_env; + test_env.add_env_var("COMPLETE", "fish"); + let test_env = test_env; + + let stdout = test_env.jj_cmd_success(test_env.env_root(), &["--", "jj", "git", "clone", ""]); + insta::assert_snapshot!(stdout, @r" + git@ + https:// + http:// + --remote Name of the newly created remote + --colocate Whether or not to colocate the Jujutsu repo with the git repo + --depth Create a shallow clone of the given depth + --help Print help (see more with '--help') + --repository Path to repository to operate on + --ignore-working-copy Don't snapshot the working copy, and don't update it + --ignore-immutable Allow rewriting immutable commits + --at-operation Operation to load the repo at + --debug Enable debug logging + --color When to colorize output (always, never, debug, auto) + --quiet Silence non-primary command output + --no-pager Disable the pager + --config-toml Additional configuration options (can be repeated) + "); + + let stdout = test_env.jj_cmd_success(test_env.env_root(), &["--", "jj", "git", "clone", "gi"]); + insta::assert_snapshot!(stdout, @"git@"); + let stdout = test_env.jj_cmd_success(test_env.env_root(), &["--", "jj", "git", "clone", "ht"]); + insta::assert_snapshot!(stdout, @r" + https:// + http:// + "); + + let stdout = + test_env.jj_cmd_success(test_env.env_root(), &["--", "jj", "git", "clone", "git@"]); + insta::assert_snapshot!(stdout, @r" + git@self-hosted-git-server.com: + git@company-git-server.com: + git@random-server.com: + "); + let stdout = test_env.jj_cmd_success( + test_env.env_root(), + &["--", "jj", "git", "clone", "https://"], + ); + insta::assert_snapshot!(stdout, @r" + https://self-hosted-git-server.com/ + https://company-git-server.com/ + https://random-server.com/ + "); + + let stdout = test_env.jj_cmd_success( + test_env.env_root(), + &["--", "jj", "git", "clone", "git@sel"], + ); + insta::assert_snapshot!(stdout, @"git@self-hosted-git-server.com:"); + let stdout = test_env.jj_cmd_success( + test_env.env_root(), + &["--", "jj", "git", "clone", "https://com"], + ); + insta::assert_snapshot!(stdout, @"https://company-git-server.com/"); +}