hyperhive/hive-forge/src/verbs/clone.rs

123 lines
4.1 KiB
Rust

//! `clone [<dest>] [--branch <b>] [--depth <n>]` — 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<String>,
/// Branch to check out after cloning.
#[arg(long)]
branch: Option<String>,
/// Shallow-clone depth (omit for a full clone).
#[arg(long)]
depth: Option<u32>,
}
/// # 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");
}
}