Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/src/client.rs

//! The client half: everything behind `--features client`.
//!
//! **This half assumes a normal operating system and uses it** — the no-OS
//! rule binds the server, never the client. Real
//! working trees, `~/.ssh`, `$SSH_AUTH_SOCK`, checkouts onto disk. None of it
//! is abstracted, because abstracting it buys nothing: the no-OS rule is the
//! *server's* constraint and only the server's.
//!
//! Every verb here is a thin shape adapter over `gunnar-client`. Nothing in
//! this file implements git.

use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use gunnar_client::{porcelain, MirrorRegistry, PushOptions, RefSpec, SshOptions};

use crate::cli::{
    BranchArgs, CloneArgs, CommitArgs, FetchArgs, LogArgs, LsRemoteArgs, MirrorCommand, PushArgs,
    RepoArgs, TagArgs,
};

fn open(path: &Path) -> Result<gix::Repository> {
    gix::open(path).with_context(|| format!("opening the repository at {}", path.display()))
}

/// How every client verb here authenticates.
///
/// **With `-i`: exactly that key.** No `ssh-agent`, no `~/.ssh` ladder, and a
/// key the server refuses is a refusal. **Without it:** the agent first, then
/// the ladder. The whole rule is
/// [`gunnar_client::ssh::resolve_identity`](gunnar_client::ssh::resolve_identity),
/// which is where it is written down and why it cannot differ between verbs.
///
/// # The two asymmetries this shape has had to fix
///
/// **2026-08-05, the agent.** `fetch` and `mirror sync` used to resolve a key
/// *path* and fail when no file matched, on a comment saying `nornir-git`'s
/// upload-pack connector could not reach `ssh-agent`. It has been able to since
/// `nornir-git 0.1.6`, but nothing here called the functions that take it, so on
/// a machine whose only identity lives in the agent `gunnar push` worked and
/// `gunnar fetch` answered *"no SSH private key found. Pass -i <path>; looked
/// at:"* with the list empty. MEASURED against a real `sshd`: push ok, fetch and
/// `mirror sync` both exit 1.
///
/// **2026-08-06, the identity.** Having got the agent in, `-i` became the *head
/// of a ladder* rather than the whole of it, and `gunnar ls-remote -i <a key the
/// server has never seen>` exited 0 by falling through to `~/.ssh/id_ed25519`.
/// See `crates/gunnar-cli/tests/named_identity.rs`, which is that finding turned
/// into a permanent guard.
fn ssh_options(explicit: Option<PathBuf>) -> SshOptions {
    match explicit {
        Some(path) => SshOptions::new().with_key(path),
        None => SshOptions::new(),
    }
}

/// Where `mirror` keeps its registry and its bare repositories.
fn mirror_root(explicit: Option<PathBuf>) -> PathBuf {
    explicit.unwrap_or_else(|| match std::env::var_os("HOME") {
        Some(home) if !home.is_empty() => PathBuf::from(home).join(".local/share/gunnar/mirrors"),
        _ => PathBuf::from("gunnar-mirrors"),
    })
}

/// `gunnar clone`.
///
/// # Two paths, and the line between them
///
/// With no shallow flag this is `nornir-git`'s `clone_or_fetch`, unchanged and
/// byte for byte what it always was. With one, it is
/// [`gunnar_client::clone_ssh_with`], because `clone_or_fetch`'s signature is
/// `(url, dest, ssh_key)` — there is no depth in it, it is a published crate,
/// and gunnar does not `[patch]` a published dependency onto a sibling
/// checkout to add one. See `gunnar_client::clone` for what the second path
/// reuses rather than rewrites.
pub fn clone(args: CloneArgs) -> Result<()> {
    let depth = args.depth_args();
    let dest = match args.dest {
        Some(dest) => dest,
        None => PathBuf::from(default_dest(&args.url)?),
    };
    let filter = parse_filter(args.filter.as_deref())?;
    if depth.is_active() || filter.is_some() {
        let shallow = depth.to_shallow()?;
        let opts = ssh_options(args.identity);
        let mut fetch_options = gunnar_client::FetchOptions::new().with_shallow(shallow);
        if let Some(filter) = filter.clone() {
            fetch_options = fetch_options.with_filter(filter);
        }
        let report = gunnar_client::clone_ssh_with(&args.url, &dest, &opts, &fetch_options)
            .with_context(|| format!("cloning {} into {}", args.url, dest.display()))?;
        let head = report
            .head
            .map(|id| id.to_string())
            .unwrap_or_else(|| "no commits".into());
        println!(
            "cloned {} into {} at {head} on {} ({} objects)",
            args.url,
            dest.display(),
            report.branch,
            report.objects_received
        );
        // Applied output: where history is actually cut, read back from
        // `.git/shallow`. A shallow clone that quietly fetched everything prints
        // nothing here, which is the only visible difference.
        report_boundary(&report.shallow_boundary);
        if let Some(filter) = &filter {
            println!("partial clone: filter {}", filter.to_spec());
        }
        return Ok(());
    }
    let head = gunnar_client::clone_or_fetch(&args.url, &dest, args.identity.as_deref())
        .with_context(|| format!("cloning {} into {}", args.url, dest.display()))?;
    println!("cloned {} into {} at {head}", args.url, dest.display());
    Ok(())
}

/// Parse a `--filter` value, or `None` when the caller gave none.
///
/// One parser for both verbs (one implementation), and it refuses before any
/// connection is opened: the spec grammar is `gunnar-wire`'s, so a string this
/// accepts is one the server accepts, and a string it refuses never costs a
/// handshake.
fn parse_filter(spec: Option<&str>) -> Result<Option<gunnar_client::FilterSpec>> {
    match spec {
        Some(spec) => Ok(Some(gunnar_client::FilterSpec::parse(spec)?)),
        None => Ok(None),
    }
}

/// Print the shallow boundary, or say plainly that there is none.
///
/// One writer for both verbs (one implementation), and it prints the *absence*
/// too: "0 cut points" after a `--depth 1` is the whole symptom of a shallow
/// request that did not take, and a line that only appears on success cannot
/// show it.
fn report_boundary(boundary: &[gix::ObjectId]) {
    if boundary.is_empty() {
        println!("history is complete (.git/shallow holds no cut points)");
        return;
    }
    println!("{} shallow cut point(s):", boundary.len());
    for id in boundary {
        println!("shallow    {id}");
    }
}

/// The last path component of an SSH URL, without `.git`.
fn default_dest(url: &str) -> Result<String> {
    let tail = url.rsplit(['/', ':']).next().unwrap_or_default();
    let name = tail.strip_suffix(".git").unwrap_or(tail);
    if name.is_empty() {
        bail!("cannot work out a directory name from {url}; give one explicitly");
    }
    Ok(name.to_owned())
}

/// What `gunnar fetch` fetches when the caller names no refspec.
///
/// # This used to be `refs/heads/*`, and that fetched into nowhere
///
/// A refspec with no destination is a legal thing to write and means "resolve
/// these, update no local reference" — `git fetch <url> refs/heads/*` behaves
/// the same and leans on `FETCH_HEAD`, which gix does not write. So `gunnar
/// fetch` downloaded the pack, printed *"4 refs advertised, 1 objects
/// received"*, printed the remote's new oids, exited 0 — and left the
/// repository byte-identical apart from an unreferenced pack that the next
/// `gc` deletes. MEASURED against a real `sshd` and a real `git upload-pack` on
/// 2026-08-05: the new commit was in the object database, no ref moved, and no
/// `FETCH_HEAD` existed.
///
/// The destinations are the ones `gunnar clone` already writes, so a fetch into
/// a clone updates the tracking refs that clone created.
const DEFAULT_FETCH_REFSPECS: &[&str] = &[
    "+refs/heads/*:refs/remotes/origin/*",
    "+refs/tags/*:refs/tags/*",
];

/// `gunnar fetch`.
pub fn fetch(args: FetchArgs) -> Result<()> {
    let specs: Vec<&str> = if args.refspec.is_empty() {
        DEFAULT_FETCH_REFSPECS.to_vec()
    } else {
        args.refspec.iter().map(String::as_str).collect()
    };
    // First, and with no `with_context` around it, so a refspec git would refuse
    // reads as `gunnar: invalid refspec 'refs/heads/*'` — git's own sentence and
    // nothing else. Not a duplicate of the library's guard but the *earliest* call
    // to it: this refuses before the repository is even opened, and
    // `gunnar_client::fetch_ssh_with` refuses again before it dials.
    gunnar_client::check_fetch_refspecs(&specs)?;
    // The shallow refusals join it here, and for the same reason: git rejects
    // `--depth 0` and `--depth … --unshallow` before it opens a connection, so a
    // bad command line costs no repository and no handshake.
    let depth = args.depth_args();
    let mut fetch_options = gunnar_client::FetchOptions::new().with_shallow(depth.to_shallow()?);
    if let Some(filter) = parse_filter(args.filter.as_deref())? {
        fetch_options = fetch_options.with_filter(filter);
    }
    let repo = open(&args.repo)?;
    let opts = ssh_options(args.identity);
    let outcome =
        gunnar_client::fetch_ssh_with_opts(&repo, &args.url, &opts, &specs, &fetch_options)
            .with_context(|| format!("fetching {}", args.url))?;
    println!(
        "{} refs advertised, {} objects received, {} local refs updated",
        outcome.refs.len(),
        outcome.objects_received,
        outcome.updated_refs.len()
    );
    for advertised in &outcome.refs {
        println!("advertised {}\t{}", advertised.oid, advertised.name);
    }
    // The applied output, kept visually distinct from the advertisement: these
    // are the only lines that say the local repository changed.
    for update in &outcome.updated_refs {
        match update.new {
            Some(oid) => println!("updated    {oid}\t{}", update.name),
            None => println!("deleted    \t{}", update.name),
        }
    }
    if depth.is_active() {
        report_boundary(&outcome.shallow_boundary);
    }
    if outcome.received_pack && outcome.updated_refs.is_empty() {
        eprintln!(
            "gunnar: a pack arrived but no local reference was written, so nothing points at \
             it and the next `gc` will discard it. Name a refspec with a destination, e.g. \
             --refspec '+refs/heads/*:refs/remotes/origin/*'"
        );
    }
    Ok(())
}

/// `gunnar ls-remote`.
///
/// # Why a verb at all when the library had the function
///
/// `gunnar_client::ls_remote` and `ls_remote_with` have existed since the client
/// did, and nothing put them on the command line. It is the cheapest question a
/// user has — *is this remote reachable, does my key work, does it hold what I
/// think* — and the first one they reach for, and until now the only way to ask
/// it of a gunnar server was to clone.
///
/// # The output is git's, byte for byte
///
/// ```text
/// 10c9553660635b30d79ef3781a991190f5efac43<TAB>HEAD
/// 10c9553660635b30d79ef3781a991190f5efac43<TAB>refs/heads/main
/// d320a6055e5756a7f77dc23b481400669ff47235<TAB>refs/tags/v1
/// 10c9553660635b30d79ef3781a991190f5efac43<TAB>refs/tags/v1^{}
/// ```
///
/// (`<TAB>` is one literal `\t`; written out because a tab in a doc comment is
/// re-indented by the renderer and this shape is measured to the byte.)
///
/// One tab, `HEAD` first, then the remote's order, and an annotated tag's peeled
/// commit on its own `^{}` line immediately after it. Scripts parse this, so the
/// shape is not decoration: it is the contract. Rendered by
/// `AdvertisedRef::ls_remote_lines`, the same function
/// `crates/gunnar-client/tests/ls_remote_matches_git.rs` and
/// `crates/gunnar-cli/tests/ls_remote_verb.rs` diff against real
/// `git ls-remote` output.
///
/// **`stdout().write_all`, not `println!`** — the whole advertisement is written
/// as one buffer so a reader on the far side of a pipe never sees a half-written
/// line, and a `SIGPIPE` from `| head` ends the verb rather than panicking a
/// `println!` mid-loop.
pub fn ls_remote(args: LsRemoteArgs) -> Result<()> {
    use std::io::Write;

    let opts = ssh_options(args.identity);
    let refs = gunnar_client::ls_remote_with(&args.url, &opts)
        .with_context(|| format!("listing the references of {}", args.url))?;
    let out = gunnar_client::ls_remote_output(&refs);
    std::io::stdout()
        .write_all(out.as_bytes())
        .context("writing the advertisement")?;
    Ok(())
}

/// `gunnar push`.
pub fn push(args: PushArgs) -> Result<()> {
    let repo = open(&args.repo)?;
    let specs = if args.refspecs.is_empty() {
        let head = repo
            .head_name()
            .context("reading HEAD")?
            .ok_or_else(|| anyhow::anyhow!("HEAD is detached; name a refspec explicitly"))?;
        let name = head.as_bstr().to_string();
        RefSpec::parse_all(&[format!("{name}:{name}")])?
    } else {
        RefSpec::parse_all(&args.refspecs)?
    };

    // A sha256 repository's first push: its remote is created in sha256 first
    // (a push-made repository is sha1 — the server speaks before the client).
    if repo.object_hash() == gix::hash::Kind::Sha256 && !args.dry_run {
        crate::repo_settings::ensure_remote_sha256(&args.url, args.identity.as_deref())?;
    }
    let mut options = PushOptions::new();
    options.force = args.force;
    options.dry_run = args.dry_run;
    options.ssh = ssh_options(args.identity);

    let outcome = gunnar_client::push(&repo, &args.url, &specs, &options)
        .with_context(|| format!("pushing to {}", args.url))?;
    for name in &outcome.up_to_date {
        println!("= {name} (up to date)");
    }
    match &outcome.report {
        Some(report) => {
            // The remote's own words first, exactly as `git push` orders them:
            // a `pre-receive` hook's refusal arrives on band 2 and is the only
            // sentence that says *why*. This used to print `remote_errors`
            // (band 3) alone, which is empty for every hook rejection there is.
            for line in report.remote_lines() {
                eprintln!("remote: {line}");
            }
            println!("unpack {}", report.unpack);
            for status in &report.refs {
                if status.accepted {
                    println!("ok {}", status.name);
                } else {
                    println!(
                        "!! {}: {}",
                        status.name,
                        status
                            .reason
                            .as_deref()
                            .unwrap_or("rejected, no reason given")
                    );
                }
            }
            // A reference the remote never mentioned is a silent no-op, and
            // `is_ok()` already fails the push for it. Naming it is the other
            // half: without this the user is told the push was refused and not
            // which reference nobody answered for.
            for name in &report.unreported {
                println!("?? {name}: the remote never reported on it");
            }
        }
        None => println!("the remote sent no status report"),
    }
    println!(
        "{} objects, {} bytes{}",
        outcome.objects_sent,
        outcome.pack_bytes,
        if outcome.dry_run { " (dry run)" } else { "" }
    );
    // A push whose refs were refused must not exit 0. `git push` does not, and
    // a CI step that only checks the exit code would otherwise treat a rejected
    // force-push as a success.
    if !outcome.is_ok() {
        bail!("the remote refused at least one reference");
    }
    Ok(())
}

/// `gunnar mirror …` — pull-mirroring.
pub fn mirror(command: MirrorCommand) -> Result<()> {
    match command {
        MirrorCommand::Add { name, url, root } => {
            let root = mirror_root(root);
            std::fs::create_dir_all(&root)?;
            let mut registry = MirrorRegistry::open(&root)?;
            let added = registry.add(&name, &url)?.clone();
            registry.save()?;
            println!("registered mirror {} -> {}", added.name, added.url);
        }
        MirrorCommand::Sync {
            name,
            root,
            identity: key,
        } => {
            let root = mirror_root(root);
            let opts = ssh_options(key);
            let mut registry = MirrorRegistry::open(&root)?;
            let report = registry.sync_with(&name, &opts)?;
            registry.save()?;
            if report.unchanged {
                println!("{}: unchanged", report.name);
            } else {
                println!(
                    "{}: {} created, {} updated, {} deleted, {} objects",
                    report.name,
                    report.created.len(),
                    report.updated.len(),
                    report.deleted.len(),
                    report.objects_received
                );
            }
        }
        MirrorCommand::Status { root } => {
            let root = mirror_root(root);
            let registry = MirrorRegistry::open(&root)?;
            let statuses = registry.status();
            if statuses.is_empty() {
                println!("no mirrors registered under {}", root.display());
            }
            for status in statuses {
                println!(
                    "{}\t{}\t{}\t{} refs\t{}",
                    status.mirror.name,
                    status.mirror.url,
                    if status.present { "present" } else { "absent" },
                    status.refs,
                    status.path.display()
                );
            }
        }
    }
    Ok(())
}

/// `gunnar log`.
pub fn log(args: LogArgs) -> Result<()> {
    let repo = open(&args.repo)?;
    let commits = porcelain::log(&repo, args.start.as_deref(), Some(args.max))?;
    for commit in commits {
        println!("{} {}", commit.id, commit.summary);
    }
    Ok(())
}

/// `gunnar status`.
pub fn status(args: RepoArgs) -> Result<()> {
    let repo = open(&args.repo)?;
    let entries = porcelain::status(&repo)?;
    if entries.is_empty() {
        println!("clean");
    }
    for entry in entries {
        println!("{:?}\t{}", entry.state, entry.path);
    }
    Ok(())
}

/// `gunnar commit`.
pub fn commit(args: CommitArgs) -> Result<()> {
    let id = porcelain::commit_all(&args.repo, &args.message)?;
    println!("{id}");
    Ok(())
}

/// `gunnar branch`.
pub fn branch(args: BranchArgs) -> Result<()> {
    let repo = open(&args.repo)?;
    match (args.name, args.delete) {
        (Some(name), true) => {
            porcelain::delete_branch(&repo, &name)?;
            println!("deleted {name}");
        }
        (Some(name), false) => {
            let at = porcelain::create_branch(&repo, &name, None)?;
            println!("created {name} at {at}");
        }
        (None, _) => {
            for branch in porcelain::branches(&repo)? {
                println!(
                    "{} {}\t{}",
                    if branch.is_head { "*" } else { " " },
                    branch.name,
                    branch.target
                );
            }
        }
    }
    Ok(())
}

/// `gunnar tag`.
pub fn tag(args: TagArgs) -> Result<()> {
    let repo = open(&args.repo)?;
    match args.name {
        None => {
            for tag in porcelain::tags(&repo)? {
                println!(
                    "{}\t{}{}",
                    tag.name,
                    tag.target,
                    if tag.annotated { "\tannotated" } else { "" }
                );
            }
        }
        Some(name) => {
            let head = repo.head_id().context("resolving HEAD to tag it")?.detach();
            match args.message {
                Some(message) => {
                    let id = porcelain::tag_annotated(&repo, &name, head, &message, None)?;
                    println!("created annotated tag {name} ({id})");
                }
                None => {
                    porcelain::tag_lightweight(&args.repo, &name, head)?;
                    println!("created tag {name} at {head}");
                }
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{default_dest, ssh_options, DEFAULT_FETCH_REFSPECS};

    /// Every fetch refspec must name a **destination**.
    ///
    /// The shipped default was `refs/heads/*`, which resolves the refs and
    /// writes none of them, so `gunnar fetch` downloaded packs into an
    /// unreferenced heap. Seen red by asserting on the shape rather than on a
    /// count: a list of the right length made entirely of source-only specs
    /// would still be the bug.
    ///
    /// The verdict comes from `gunnar_client::check_fetch_refspec` — the same
    /// function that judges a user's `--refspec` — so the default and the
    /// argument cannot be held to two different standards (one
    /// implementation). The shape assertions below are what that function does
    /// *not* say: that the destinations are the ones `gunnar clone` creates.
    #[test]
    fn every_default_fetch_refspec_writes_somewhere() {
        assert!(!DEFAULT_FETCH_REFSPECS.is_empty());
        gunnar_client::check_fetch_refspecs(DEFAULT_FETCH_REFSPECS)
            .expect("the shipped default is a refspec git would refuse");
        for spec in DEFAULT_FETCH_REFSPECS {
            let (src, dst) = spec
                .split_once(':')
                .unwrap_or_else(|| panic!("{spec:?} names no destination, so it writes nothing"));
            assert!(!src.is_empty(), "{spec:?} has an empty source");
            assert!(!dst.is_empty(), "{spec:?} has an empty destination");
            assert!(
                dst.starts_with("refs/"),
                "{spec:?} writes outside refs/, which git will not accept"
            );
        }
        assert!(
            DEFAULT_FETCH_REFSPECS
                .iter()
                .any(|s| s.ends_with(":refs/remotes/origin/*")),
            "nothing updates the tracking refs `gunnar clone` creates"
        );
        // The anti-vacuity half: the validator must be able to say no, or the
        // line above is a green that can never be red.
        assert!(
            gunnar_client::check_fetch_refspecs(&["refs/heads/*"]).is_err(),
            "check_fetch_refspecs accepted the very string this defect was, so \
             asserting the defaults pass it proves nothing"
        );
    }

    /// A fetch must be able to authenticate with nothing but `ssh-agent`.
    ///
    /// `fetch` and `mirror sync` used to resolve a key **path** and fail when no
    /// file matched, while `push` used the agent — so on an agent-only machine
    /// half the client worked. Asserted on what is actually handed to
    /// `nornir-git` rather than on the struct, because it is `auth()` that the
    /// connector reads.
    #[test]
    fn a_fetch_can_authenticate_with_the_agent_alone() {
        let opts = ssh_options(None);
        assert!(opts.use_agent, "the agent is off by default");
        assert!(opts.key_path.is_none());
        assert!(
            opts.auth()
                .expect("no identity was named, so this cannot fail")
                .use_agent,
            "the agent setting did not survive into what nornir-git authenticates with"
        );
    }

    /// RED-WHEN-BROKEN: **`-i` must reach the wire, not merely the struct.**
    ///
    /// The half of the test above that used to sit here read
    ///
    /// ```text
    ///   // And an explicit key must still win, or `-i` would be decorative.
    ///   assert_eq!(explicit.key_path.as_deref(), Some(Path::new("/nonexistent/key")));
    /// ```
    ///
    /// — a **state round-trip**: it asserted that the field this same function
    /// had just set was set. `-i` was decorative anyway, and this could never
    /// have said so, because the substitution happened afterwards in
    /// `SshOptions::auth()`. That is exactly the shape "measure, never assert"
    /// exists for, and it sat one line under a comment claiming to guard the
    /// thing it did not guard.
    ///
    /// What is asserted now is the applied output: the `SshAuth` handed to
    /// `nornir-git`, which is the only thing that decides who connects.
    #[test]
    fn a_named_identity_reaches_the_wire_and_nothing_else_does() {
        let dir = tempfile::tempdir().unwrap();
        let key = dir.path().join("named");
        std::fs::write(&key, b"not really a key").unwrap();

        let auth = ssh_options(Some(key.clone()))
            .auth()
            .expect("a key that exists must resolve");
        assert_eq!(
            auth.keys,
            vec![key],
            "the named key must be the whole offer: {:?}",
            auth.keys
        );
        assert!(
            !auth.use_agent,
            "the agent was left on beside a named key, so the agent's identities \
             are still offered and -i is still advisory"
        );

        // A named key that is not there is a refusal, not a fallback.
        let err = ssh_options(Some(std::path::PathBuf::from("/nonexistent/key")))
            .auth()
            .expect_err("a -i pointing nowhere must refuse rather than substitute");
        assert!(
            matches!(err, gunnar_client::Error::Identity(_)),
            "wrong refusal: {err}"
        );
    }

    #[test]
    fn a_clone_destination_is_derived_from_both_url_forms() {
        assert_eq!(default_dest("git@host:team/proj.git").unwrap(), "proj");
        assert_eq!(
            default_dest("ssh://git@host/team/proj.git").unwrap(),
            "proj"
        );
        assert_eq!(default_dest("ssh://git@host/team/proj").unwrap(), "proj");
        // The identity-value guard: if this returned the whole URL, or the
        // empty string, the three assertions above would still need to hold.
        assert!(default_dest("git@host:").is_err());
    }
}