feat(#1399): hive-forge clone verb + pr-create --agit for no-fork PRs
This commit is contained in:
parent
e7a68472fd
commit
c103ae5f10
7 changed files with 347 additions and 11 deletions
123
hive-forge/src/verbs/clone.rs
Normal file
123
hive-forge/src/verbs/clone.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
//! `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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue