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());
}
}