//! `clone [] [--branch ] [--depth ]` — clone a forge repo //! with credentials auto-injected, so agents don't hand-assemble //! token-bearing URLs. The repo is the standard `-r/--repo` (default //! `HIVE_FORGE_REPO`). Pairs with `pr-create --agit`: clone, edit + //! commit normally, then open a PR via the `AGit` ref (closes #1399). use std::process::Command; use anyhow::{Context, Result, bail}; use clap::Args as ClapArgs; use crate::client::Client; #[derive(ClapArgs)] pub struct Args { /// Destination directory. Defaults to the repo's basename /// (e.g. `internal/knowledge` → `knowledge`). dest: Option, /// Branch to check out after cloning. #[arg(long)] branch: Option, /// Shallow-clone depth (omit for a full clone). #[arg(long)] depth: Option, } /// # Errors /// /// Returns an error if the `git clone` shellout fails (bad repo, auth /// rejected, network) or the destination can't be derived. pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); let dest = match args.dest { Some(d) => d, None => repo .rsplit('/') .next() .filter(|s| !s.is_empty()) .map(str::to_owned) .with_context(|| format!("clone: cannot derive a destination dir from repo {repo}"))?, }; let url = client.authed_git_url(repo); let mut git_args = vec!["clone".to_owned()]; if let Some(depth) = args.depth { git_args.push(format!("--depth={depth}")); } if let Some(branch) = &args.branch { git_args.push("--branch".to_owned()); git_args.push(branch.clone()); } git_args.push(url); git_args.push(dest.clone()); let arg_refs: Vec<&str> = git_args.iter().map(String::as_str).collect(); let out = Command::new("git") .args(&arg_refs) .output() .context("spawn `git clone`")?; if !out.status.success() { // Scrub the token from any URL echoed back in git's error. let stderr = scrub_credentials(&String::from_utf8_lossy(&out.stderr)); bail!("clone: git clone {repo} failed:\n{stderr}"); } // Print the destination so callers can `cd` into it. println!("{dest}"); Ok(()) } /// Redact the `user:token@` userinfo from any URL in `s` so a token /// never lands in an error message / log. Replaces the credential span /// with `***` while keeping the rest of the URL legible. fn scrub_credentials(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut rest = s; while let Some(scheme_at) = rest.find("://") { let after_scheme = scheme_at + 3; // Userinfo runs from after `://` up to the next `@`, but only if // that `@` comes before the next `/` (i.e. it's in the authority). let authority = &rest[after_scheme..]; let at = authority.find('@'); let slash = authority.find('/'); match (at, slash) { (Some(a), maybe_slash) if maybe_slash.is_none_or(|sl| a < sl) => { out.push_str(&rest[..after_scheme]); out.push_str("***"); out.push('@'); rest = &authority[a + 1..]; } _ => { // No credentials in this URL; emit up to here and move on. out.push_str(&rest[..after_scheme]); rest = authority; } } } out.push_str(rest); out } #[cfg(test)] mod tests { use super::*; #[test] fn scrubs_token_userinfo() { let s = "fatal: unable to access 'http://damocles:deadbeef@localhost:3000/internal/knowledge.git/'"; let scrubbed = scrub_credentials(s); assert!(!scrubbed.contains("deadbeef")); assert!(scrubbed.contains("http://***@localhost:3000/internal/knowledge.git")); } #[test] fn leaves_credential_free_urls_intact() { let s = "Cloning into 'http://localhost:3000/x/y.git'..."; assert_eq!(scrub_credentials(s), s); } #[test] fn handles_no_url() { assert_eq!(scrub_credentials("plain message"), "plain message"); } }