crates/nornir-git/src/gitio.rs
//! Pure-Rust git helpers (via `gix`). The single home for read-side
//! git inspection so the rest of the crate never shells out to the
//! `git` binary. No `std::process::Command`, no libgit2/C — matches
//! nornir's pure-Rust ethos (gix over libgit2, like `ureq` over curl).
//!
//! Write/network release operations (commit-on-release, push) live in
//! nornir's `release::publish`. The write helpers here — [`init`] and
//! [`commit_all`] — exist only to build *new* repositories from scratch
//! (test fixtures and the synthetic-repo generator) without ever
//! shelling out; they use a fixed, deterministic identity so fixtures
//! are reproducible and need no ambient git config.
use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Context, Result};
/// Full 40-char hex SHA of the commit `HEAD` points at.
///
/// **Pure-gix, two independent gix code paths — NEVER shells `git`.** This is
/// the leaf whose whole reason to exist is to eliminate git-shelling, so it must
/// not shell git itself (LAW). The primary read is `Repository::head().id()`;
/// some server-cloned repos hit a gix `head()` quirk, so we fall back to
/// `rev_parse_single("HEAD")` — a *different* gix resolver that reads those
/// repos fine — rather than reaching for the `git` binary. The dependency Mímir
/// (change-detection / build-order) therefore never dead-ends on one repo, and
/// never leaves the pure-Rust world.
pub fn head_sha(root: &Path) -> Result<String> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
gix_head_id(&repo)
.or_else(|_| gix_rev_parse_head(&repo))
.with_context(|| format!("read HEAD of {} (both gix paths failed)", root.display()))
}
/// Primary gix HEAD read: `Repository::head().id()`.
fn gix_head_id(repo: &gix::Repository) -> Result<String> {
let id = repo
.head()
.context("read HEAD")?
.id()
.ok_or_else(|| anyhow!("HEAD has no commit (unborn?)"))?;
Ok(id.to_string())
}
/// Fallback gix HEAD read via the rev-spec parser — a distinct code path from
/// [`gix_head_id`] that resolves repos which trip the `head()` quirk. Same
/// answer as `git rev-parse HEAD`, no subprocess.
fn gix_rev_parse_head(repo: &gix::Repository) -> Result<String> {
Ok(repo
.rev_parse_single("HEAD")
.context("rev-parse HEAD")?
.detach()
.to_string())
}
/// Short branch name for `HEAD`, or `"(detached)"` when detached. Pure-gix
/// (reads HEAD's symbolic ref directly; never shells `git symbolic-ref`).
pub fn head_branch(root: &Path) -> Result<String> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
Ok(match repo.head_name().context("read HEAD name")? {
Some(name) => name.shorten().to_string(),
None => "(detached)".to_string(),
})
}
/// `(sha, branch)` for `HEAD`. Both halves are pure-gix (see [`head_sha`]).
pub fn head_sha_and_branch(root: &Path) -> Result<(String, String)> {
Ok((head_sha(root)?, head_branch(root)?))
}
/// Sentinel digest for a **clean** working tree (no tracked modifications,
/// nothing staged, no untracked files). A fixed string so clients can test it
/// without recomputing a hash of the empty set.
pub const CLEAN_WORKTREE_DIGEST: &str = "clean";
/// A working-tree **freshness** reading: whether the tree is dirty and a stable
/// content digest of the porcelain status (AUT6 / freshness gate).
///
/// The whole point: a commit SHA cannot detect *uncommitted* edits — two
/// different working trees can sit on the same `HEAD`. The digest does. A thin
/// client compares its local digest to the indexed one; if they differ the
/// warehouse is stale relative to the live tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeFreshness {
/// `true` ⇒ tracked modifications, staged changes, or untracked files exist.
pub dirty: bool,
/// Stable hex digest of the sorted porcelain status. Equals
/// [`CLEAN_WORKTREE_DIGEST`] when `dirty == false`.
pub digest: String,
}
/// How one path differs — the per-path state a `status` verb has to print, and
/// the reason [`porcelain_status`] is public rather than an implementation detail
/// of the two digests below.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ChangeKind {
/// Differs between `HEAD` and the index — **staged**. Tag `S`.
Staged,
/// Differs between the index and the working tree — a tracked
/// **modification**. Tag `M`.
Modified,
/// Not tracked at all — **untracked**. Tag `U`.
Untracked,
/// Moved (or copied) within the working tree — a **rewrite**. Tag `R`.
Renamed,
}
impl ChangeKind {
/// The one-letter porcelain tag. Load-bearing: it is what
/// [`worktree_freshness`] hashes, so changing a letter changes every recorded
/// digest in the warehouse.
pub fn tag(self) -> char {
match self {
ChangeKind::Staged => 'S',
ChangeKind::Modified => 'M',
ChangeKind::Untracked => 'U',
ChangeKind::Renamed => 'R',
}
}
}
/// One porcelain status entry: what kind of difference, and the repo-relative
/// path. The tuple the ONE shared status walk yields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusEntry {
/// What kind of difference this path has.
pub kind: ChangeKind,
/// Repository-relative path (`/`-separated, lossy-UTF8).
pub rela: String,
}
/// Walk the repo's porcelain status ONCE, **in-process via gix** (no `git`
/// subprocess): tree↔index (staged), index↔worktree (tracked modifications),
/// plus the dirwalk for untracked files.
///
/// # Returned shape
///
/// `(workdir, entries)`:
///
/// * `workdir` — the absolute working-tree root. `root` may be any path inside
/// the repository; this is the directory the `rela` paths are relative to, and
/// the one to join them onto before touching the filesystem. A **bare**
/// repository has none, and is an error rather than an empty status.
/// * `entries` — one [`StatusEntry`] per changed path, in gix's walk order,
/// **not sorted or deduplicated** (see [`worktree_changed_paths`] for that).
/// A clean tree yields an empty vec.
///
/// This is the single shared walk behind [`worktree_freshness`] (which hashes it),
/// [`worktree_changed_paths`] (which lists it), and any caller needing the
/// per-path state — no one re-implements the gix status iteration (LAW #5, reuse
/// the leaf, never twin it).
pub fn porcelain_status(root: &Path) -> Result<(PathBuf, Vec<StatusEntry>)> {
let repo = open_for_status(root)?;
porcelain_status_in(&repo)
}
/// `gix::open` with the **long-running** content-filter drivers disarmed — the
/// open every status walk must use. See [`disarm_long_running_filters`].
pub fn open_for_status(root: &Path) -> Result<gix::Repository> {
let mut repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
disarm_long_running_filters(&mut repo)
.with_context(|| format!("disarm long-running content filters for {}", root.display()))?;
Ok(repo)
}
/// Drop the `process` value from every `filter.<name>` section of `repo`'s
/// **in-memory** config, leaving `clean`/`smudge`/`required` untouched. Returns
/// how many drivers were disarmed.
///
/// ── WHY THIS EXISTS ────────────────────────────────────────────────────────
/// Measured on oden 2026-08-23: `dwarves-server` held **12 605** `<defunct>`
/// children, growing ~36/min, every one of them `/bin/sh -c "git-lfs
/// filter-process"`. The chain, each link verified in the dependency sources:
///
/// 1. `/etc/gitconfig` (shipped by the git-lfs package on 2026-07-22) declares
/// `filter.lfs.process = git-lfs filter-process` and `required = true`.
/// 2. Any `gix::open` picks that up — `gix::filter::extract_drivers` builds a
/// `gix_filter::Driver` whose `process` is set.
/// 3. The status walk hands a `gix_filter::Pipeline` **by value** to
/// `gix_status::index_as_worktree`. The pipeline's `driver::State` parks
/// every long-running filter in `State.running`, and that type has **no
/// `Drop`** — only `State::shutdown()` calls `wait()`. Dropped, the child's
/// pipes close and it exits, but nobody reaps it, so it stays `<defunct>`
/// for the life of the server process. One per repo per poll.
///
/// ── WHY *ONLY* `process` ───────────────────────────────────────────────────
/// The asymmetry is the whole fix. The single-file `clean`/`smudge` drivers are
/// waited on by gix itself (`gix-filter/src/driver/apply.rs`, two `child.wait()`
/// sites), so they never leak. **Only** the long-running `process` driver does.
/// Removing just that value keeps LFS semantically intact — git's own documented
/// fallback when `process` is unset is exactly the `clean`/`smudge` pair — so the
/// status walk still converts worktree content to git form and cannot report a
/// smudged LFS asset as spuriously modified. That false-dirty is precisely what
/// makes the blunter fixes wrong: dropping the whole `filter` section (or opening
/// without system config) leaves the walk comparing real content against a
/// pointer blob, and every LFS-tracked path reads dirty forever.
///
/// ── WHY NOT REAP ───────────────────────────────────────────────────────────
/// Reaping (`waitpid(…, NOHANG)` over our `<defunct>` children) was the other
/// candidate. It loses on two counts: it cannot tell OUR abandoned filter from a
/// child another thread is about to `wait()` on — stealing that status turns a
/// gix conversion into an `ECHILD` error on a live parallel repo scan — and it
/// treats the symptom while the pointless launches keep happening. This removes
/// the launch.
///
/// The mutation is **in-memory only**: `commit()` calls
/// `reread_values_and_clear_caches_replacing_config`, which never writes
/// `.git/config`. Nothing on disk changes, and a `git` client sees the machine's
/// config exactly as before.
pub fn disarm_long_running_filters(repo: &mut gix::Repository) -> Result<usize> {
// Collect first: `sections_and_ids_by_name` borrows the config immutably and
// `section_mut_by_id` needs it mutably.
let ids: Vec<_> = {
let snapshot = repo.config_snapshot();
let collected = match snapshot.sections_and_ids_by_name("filter") {
Some(iter) => iter.map(|(_, id)| id).collect(),
None => Vec::new(),
};
collected
};
if ids.is_empty() {
return Ok(0);
}
let mut disarmed = 0usize;
let mut snapshot = repo.config_snapshot_mut();
for id in ids {
if let Some(mut section) = snapshot.section_mut_by_id(id) {
if section.remove("process").is_some() {
disarmed += 1;
}
}
}
snapshot.commit().context("commit disarmed filter config")?;
Ok(disarmed)
}
/// Drop `smudge` AND `process` from every `filter.<name>` section of `repo`'s
/// **in-memory** config: a checkout through this handle writes each blob as
/// stored and launches no filter (gix-filter treats a driver with no command
/// for the operation as a no-op, `required` or not). `clean` stays, so a
/// status walk still reads a pointer as clean. The [`Smudge::Skip`] half of
/// [`materialize_worktree`]; like [`disarm_long_running_filters`], nothing on
/// disk changes. Returns how many drivers were touched.
pub fn skip_content_filters(repo: &mut gix::Repository) -> Result<usize> {
let ids: Vec<_> = {
let snapshot = repo.config_snapshot();
let collected = match snapshot.sections_and_ids_by_name("filter") {
Some(iter) => iter.map(|(_, id)| id).collect(),
None => Vec::new(),
};
collected
};
if ids.is_empty() {
return Ok(0);
}
let mut touched = 0usize;
let mut snapshot = repo.config_snapshot_mut();
for id in ids {
if let Some(mut section) = snapshot.section_mut_by_id(id) {
let smudge = section.remove("smudge").is_some();
let process = section.remove("process").is_some();
touched += usize::from(smudge || process);
}
}
snapshot.commit().context("commit skipped filter config")?;
Ok(touched)
}
/// [`porcelain_status`] for a repository that is **already open** — same walk,
/// same returned shape, one fewer `gix::open`.
///
/// This is the entry point for a caller that holds a `gix::Repository` already (a
/// `status` verb almost always does); reopening it to reach the path-taking form
/// would be paying twice for the same discovery.
pub fn porcelain_status_in(repo: &gix::Repository) -> Result<(PathBuf, Vec<StatusEntry>)> {
use gix::bstr::ByteSlice;
let workdir = repo
.workdir()
.ok_or_else(|| {
anyhow!(
"bare repo has no working tree: {}",
repo.path().display()
)
})?
.to_path_buf();
let platform = repo
.status(gix::progress::Discard)
.context("open status platform")?
.untracked_files(gix::status::UntrackedFiles::Files);
let iter = platform
.into_iter(None)
.context("start working-tree status iteration")?;
let mut entries: Vec<StatusEntry> = Vec::new();
for item in iter {
let item = item.context("read status item")?;
let rela = item.location().to_str_lossy().into_owned();
let kind = match &item {
gix::status::Item::TreeIndex(_) => ChangeKind::Staged, // staged (tree↔index)
gix::status::Item::IndexWorktree(iw) => match iw {
gix::status::index_worktree::Item::Modification { .. } => ChangeKind::Modified,
gix::status::index_worktree::Item::DirectoryContents { .. } => ChangeKind::Untracked,
gix::status::index_worktree::Item::Rewrite { .. } => ChangeKind::Renamed,
},
};
entries.push(StatusEntry { kind, rela });
}
Ok((workdir, entries))
}
/// The repo-relative paths of every uncommitted change (staged, tracked
/// modification, or untracked file) at `root`, **sorted + deduped**. A clean tree
/// yields an empty vec.
///
/// The porcelain path list the release doctor's **dirty-tree sweep** classifies
/// (`Cargo.lock` churn vs. source dirt). It reuses the ONE shared status walk
/// ([`porcelain_status`]) that [`worktree_freshness`] hashes — so the sweep never
/// re-implements a gix status walk of its own (LAW #5).
pub fn worktree_changed_paths(root: &Path) -> Result<Vec<String>> {
let (_workdir, entries) = porcelain_status(root)?;
let mut paths: Vec<String> = entries.into_iter().map(|e| e.rela).collect();
paths.sort();
paths.dedup();
Ok(paths)
}
/// Compute the working-tree freshness of the repo at `root`, **in-process via
/// gix** (no shelling out to the `git` binary).
///
/// The digest is a SHA-256 over the repo's porcelain status: every changed path
/// (tracked modification / staged change / tree-vs-index change) plus every
/// untracked path, each tagged with a one-letter status and — for paths that
/// exist on disk — their `(size, mtime)`, sorted by path for stability. A clean
/// tree hashes to the [`CLEAN_WORKTREE_DIGEST`] sentinel (the empty set), so two
/// clean checkouts of the same commit always agree, and any uncommitted edit —
/// which the `HEAD` SHA cannot see — flips the digest.
pub fn worktree_freshness(root: &Path) -> Result<WorktreeFreshness> {
let (workdir, entries) = porcelain_status(root)?;
// (path, "<status-tag> <size>:<mtime_nanos>") lines, sorted for stability.
let mut lines: Vec<String> = Vec::new();
for StatusEntry { kind, rela } in &entries {
let tag = kind.tag();
// Stat the on-disk file (best-effort): content edits move size/mtime even
// when the path set is unchanged. Missing/deleted files contribute "-".
let stat = match std::fs::metadata(workdir.join(rela)) {
Ok(md) => {
let mtime = md
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{}:{mtime}", md.len())
}
Err(_) => "-".to_string(),
};
lines.push(format!("{tag} {rela} {stat}"));
}
if lines.is_empty() {
return Ok(WorktreeFreshness { dirty: false, digest: CLEAN_WORKTREE_DIGEST.to_string() });
}
lines.sort();
// The ONE shared hash leaf — stream the sorted porcelain lines through
// nornir-hash's incremental hasher instead of re-invoking sha2 + a
// hand-rolled hex loop. Byte-identical digest.
let mut hasher = nornir_hash::Sha256Hasher::new();
for l in &lines {
hasher.update(l.as_bytes());
hasher.update(b"\n");
}
Ok(WorktreeFreshness { dirty: true, digest: hasher.finalize_hex() })
}
/// Commit SHA the tag `refs/tags/<tag>` resolves to, peeling annotated
/// tags down to their target commit. `Ok(None)` if the tag is absent.
pub fn tag_commit_sha(root: &Path, tag: &str) -> Result<Option<String>> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let full = format!("refs/tags/{tag}");
let Some(reference) = repo
.try_find_reference(full.as_str())
.with_context(|| format!("look up {full}"))?
else {
return Ok(None);
};
let id = reference
.into_fully_peeled_id()
.with_context(|| format!("peel {full}"))?;
Ok(Some(id.to_string()))
}
/// True iff `refs/tags/<tag>` exists and (after peeling) points at the
/// same commit as `HEAD`.
pub fn tag_points_at_head(root: &Path, tag: &str) -> Result<bool> {
match tag_commit_sha(root, tag)? {
Some(tag_sha) => Ok(tag_sha == head_sha(root)?),
None => Ok(false),
}
}
/// Fixed identity for fixture/generated commits — deterministic so test
/// repos hash reproducibly and require no ambient `git` config.
const FIXTURE_NAME: &str = "Nornir Fixture";
const FIXTURE_EMAIL: &str = "fixtures@nornir.invalid";
const FIXTURE_TIME: &str = "1700000000 +0000";
/// Initialise a fresh git repository at `root` (pure-Rust via `gix`).
/// The directory is created if missing. Used by test fixtures and the
/// synthetic-repo generator so setup code is also free of `git`
/// subprocesses.
pub fn init(root: &Path) -> Result<()> {
std::fs::create_dir_all(root).with_context(|| format!("mkdir {}", root.display()))?;
gix::init(root).with_context(|| format!("gix init {}", root.display()))?;
Ok(())
}
/// Snapshot the entire working tree (every file except those under
/// `.git/`) into a new commit on `HEAD`, returning the commit SHA.
///
/// Works for the initial commit (unborn `HEAD`, no parent) and for
/// subsequent commits (parent = current `HEAD`). Because the tree is
/// rebuilt from the filesystem each call, additions, modifications and
/// deletions are all captured — a faithful `git add -A && git commit`
/// for synthetic repos that have no `.gitattributes` content filters.
pub fn commit_all(root: &Path, message: &str) -> Result<String> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let empty = gix::ObjectId::empty_tree(repo.object_hash());
let mut editor = repo.edit_tree(empty).context("seed empty tree editor")?;
add_dir_recursive(&repo, &mut editor, root, root)?;
let tree = editor.write().context("write fixture tree")?.detach();
let parents: Vec<gix::ObjectId> = repo.head_commit().ok().map(|c| c.id).into_iter().collect();
let sig = gix::actor::SignatureRef {
name: gix::bstr::BStr::new(FIXTURE_NAME),
email: gix::bstr::BStr::new(FIXTURE_EMAIL),
time: FIXTURE_TIME,
};
let id = repo
.commit_as(sig, sig, "HEAD", message, tree, parents)
.context("create fixture commit")?;
// Write the index from the new tree so the freshly-built repo reads
// clean (gix::init leaves no index; without this the whole worktree
// would look untracked to a subsequent status check).
let mut index = repo
.index_from_tree(&tree)
.context("rebuild index from fixture tree")?;
index
.write(gix::index::write::Options::default())
.context("persist fixture index")?;
Ok(id.to_string())
}
/// Create a lightweight tag `refs/tags/<name>` pointing at `target_sha`.
/// Pure-gix (no shell). Fails if the tag already exists.
pub fn tag_lightweight(root: &Path, name: &str, target_sha: &str) -> Result<()> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let target = gix::ObjectId::from_hex(target_sha.as_bytes())
.with_context(|| format!("parse target sha `{target_sha}`"))?;
repo.tag_reference(name, target, gix::refs::transaction::PreviousValue::MustNotExist)
.with_context(|| format!("create tag `{name}` -> {target_sha}"))?;
Ok(())
}
// ── Network (server-monitored poll/fetch) ───────────────────────────────────
//
// Pure-Rust git, no `git`/`ssh` subprocess, no C:
// - HTTPS: gix + rustls (clone/fetch).
// - SSH: `russh` runs `git-upload-pack` over an authenticated channel and gix
// drives the wire protocol + pack indexing over it (see [`ssh_sync`]).
// gix's *own* SSH transport execs `ssh`, which we avoid.
/// `true` for SSH-style remotes (`git@host:…` or `ssh://…`).
pub fn is_ssh_url(url: &str) -> bool {
url.starts_with("ssh://") || (url.contains('@') && !url.contains("://"))
}
/// The **default FETCH remote's URL** for the checkout at `root` — the URL a poll
/// would actually contact, read through gix rather than parsed out of
/// `.git/config` by hand.
///
/// `Ok(None)` for a repo with no fetch remote (a local-only checkout), which is a
/// different answer from `Err` (not a repo / unreadable) — the deploy-key surface
/// draws them differently, because "this repo has no remote" is not "this repo
/// cannot authenticate".
pub fn default_remote_url(root: &Path) -> Result<Option<String>> {
let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
let Some(remote) = repo.find_default_remote(gix::remote::Direction::Fetch) else {
return Ok(None);
};
let remote = remote.context("resolve default fetch remote")?;
Ok(remote
.url(gix::remote::Direction::Fetch)
.map(|u| u.to_bstring().to_string()))
}
/// Clone `url` into `dest` (full worktree). Pure Rust over HTTPS.
fn clone_repo(url: &str, dest: &Path, smudge: Smudge) -> Result<()> {
std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
let mut prepare = gix::prepare_clone(url, dest)
.with_context(|| format!("prepare clone {url} → {}", dest.display()))?
// Fetch ALL tags — release tags drive the viz timeline's git-history
// fallback; gix's default refspec maps only branches.
.configure_remote(|remote| {
// gix 0.90: the hook's error type is `gix::Error` (`gix_error::Exn` in 0.88).
Ok::<_, gix::Error>(remote.with_fetch_tags(gix::remote::fetch::Tags::All))
});
let (checkout, _) = prepare
.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.with_context(|| format!("clone-fetch {url}"))?;
// `persist()` rather than `main_worktree()`: gix's `PrepareCheckout` deletes
// the destination on drop unless it is either checked out or persisted, and
// the checkout itself belongs to [`materialize_worktree`] — the ONE worktree
// writer in this crate (see its doc comment for why there is only one, and
// what the second one used to get wrong).
//
// This also retires the NAMED GAP that stood here: `main_worktree` leaked one
// `<defunct>` `git-lfs filter-process` child per fresh clone because
// `PrepareCheckout` exposes only `repo(&self)` and the disarm needs `&mut`.
// Going through `materialize_worktree` means the handle IS disarmed, so the
// smudge still runs — through the `clean`/`smudge` pair gix waits on — and
// nothing is left behind.
let repo = checkout.persist();
let Some(head) = repo.head().context("read cloned HEAD")?.try_into_peeled_id()? else {
// An empty remote clones to an unborn HEAD: there is no tree to write,
// and that is a legal clone, not a failure.
return Ok(());
};
materialize_worktree(&repo, &head.detach().to_string(), dest, smudge)
.with_context(|| format!("checkout worktree for {url}"))?;
Ok(())
}
/// Fetch the default remote of an existing non-SSH clone at `dest` (a local
/// path, `file://`, or an https remote the SSH-first rule fell back from) and
/// bring the checkout to the remote's `HEAD` — what [`ssh_sync`] does for an
/// SSH remote, so "clone if absent, else fetch" means the same over every
/// transport.
///
/// Two things this did not do until 2026-10-13:
///
/// * It opened the clone with a bare `gix::open`. On a machine with no git
/// identity gix then refused to write the reflog of the
/// `refs/remotes/origin/*` it moved, and EVERY re-fetch failed after the pack
/// had transferred (see [`open_for_ref_edits`]). A fresh clone was fine, so
/// it showed only on the second poll of a service user or a container.
/// * It moved `refs/remotes/origin/*` and nothing else. `HEAD`, the local
/// branch and the files on disk stayed at the commit of the first clone, and
/// [`clone_or_fetch`] — "returns the resulting `HEAD` SHA" — returned that
/// old SHA for ever: a poller comparing it never saw the remote move, and a
/// reader of the checkout kept reading the first clone.
///
/// The remote's `HEAD` is asked for by name (the extra `HEAD` refspec, as
/// gix's own clone does): the clone's configured `refs/heads/*` refspec makes
/// the server filter its advertisement down to branches, and a `HEAD` that is
/// not advertised cannot be followed. Like [`ssh_sync`], a `HEAD` that has not
/// moved leaves the worktree untouched.
fn fetch_repo(dest: &Path, smudge: Smudge) -> Result<()> {
let repo = open_for_ref_edits(dest)?;
let remote = repo
.find_default_remote(gix::remote::Direction::Fetch)
.ok_or_else(|| anyhow!("{} has no fetch remote", dest.display()))?
.context("resolve default remote")?
// Pull tags too, so newly-released versions appear in the timeline.
.with_fetch_tags(gix::remote::fetch::Tags::All);
let head_spec = gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch)
.expect("`HEAD` is a valid fetch refspec")
.to_owned();
let outcome = remote
.connect(gix::remote::Direction::Fetch)
.context("connect to remote")?
.prepare_fetch(
gix::progress::Discard,
gix::remote::ref_map::Options { extra_refspecs: vec![head_spec], ..Default::default() },
)
.context("prepare fetch")?
.receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.context("fetch")?;
let Some((branch, head_sha)) = advertised_head(&outcome.ref_map.remote_refs) else {
// The remote's HEAD is unborn: there is no commit to follow.
return Ok(());
};
if gix_head_id(&repo).ok().as_deref() == Some(head_sha.as_str()) {
return Ok(());
}
// Files first, HEAD last: see `ssh_sync`.
materialize_worktree(&repo, &head_sha, dest, smudge)
.with_context(|| format!("checkout {head_sha} into {}", dest.display()))?;
set_head(&repo, &branch, &head_sha)
}
/// The remote's `HEAD` out of its ref advertisement: `(branch, sha)` — the full
/// name of the branch it points at and the commit. `None` when `HEAD` is unborn
/// or was not advertised.
///
/// The branch is the symbolic target when the server sends one; a detached
/// `HEAD` is matched to a branch by SHA, else `refs/heads/main` — the rule
/// [`ssh_sync`] applies to the advertisement it reads.
fn advertised_head(refs: &[gix::protocol::handshake::Ref]) -> Option<(String, String)> {
use gix::protocol::handshake::Ref;
let (target, object) = refs.iter().find_map(|r| match r {
Ref::Symbolic { full_ref_name, target, object, .. } if full_ref_name == "HEAD" => {
Some((Some(target.to_string()), *object))
}
Ref::Direct { full_ref_name, object } if full_ref_name == "HEAD" => Some((None, *object)),
_ => None,
})?;
let branch = target
.filter(|t| t.starts_with("refs/heads/"))
.or_else(|| {
refs.iter().find_map(|r| match r {
Ref::Direct { full_ref_name, object: at }
if *at == object && full_ref_name.starts_with(b"refs/heads/") =>
{
Some(full_ref_name.to_string())
}
_ => None,
})
})
.unwrap_or_else(|| "refs/heads/main".to_string());
Some((branch, object.to_string()))
}
/// Resolve the deploy-key path used for git-over-SSH — the path every fetch on
/// this leaf authenticates with.
///
/// **DELEGATES to [`crate::deploykey::Resolution::resolve`]**; it does not carry a
/// ladder of its own. It used to: `NORNIR_SSH_DIR`, else `/home/nornir/.ssh`, else
/// `~/.nornir/ssh` — all three nornir-named, from when nornir was its own systemd
/// service. Today dwarves compiles nornir IN and there is ONE service, so the
/// default is dwarves' own `~/.dwarves/ssh` and the nornir-named paths are kept as
/// read-only fallbacks (see [`crate::deploykey`] for the full order and why the
/// explicit env override still wins).
///
/// A second copy of this ladder is exactly the bug to avoid: the pane that shows a
/// human the key must show the key THIS function returns, and the only way to
/// guarantee that is for there to be one function. (A duplicated `warehouse_root`
/// ladder in this constellation ended up with 4 call sites on one rule and 47 on
/// another.) Returns the private key path only if it exists.
pub fn nornir_ssh_key_path() -> Option<PathBuf> {
crate::deploykey::ssh_key_path()
}
/// Clone-or-fetch `url` into `dest` over SSH with the deploy key at `key_path`,
/// returning the fetched `HEAD` SHA.
///
/// `russh` opens an authenticated `git-upload-pack` channel; gix's blocking git
/// transport (`ConnectMode::Process`) negotiates and receives the pack over it,
/// writing objects + `refs/remotes/origin/*` into the repo. We then point HEAD
/// at the fetched commit and materialize the worktree from its tree.
fn ssh_sync(url: &str, dest: &Path, key_path: &Path, smudge: Smudge) -> Result<String> {
use gix::protocol::transport::client::git::blocking_io::Connection as GitConnection;
use gix::protocol::transport::client::git::ConnectMode;
use gix::protocol::transport::Protocol;
let loc = crate::ssh::parse_ssh_url(url)?;
// 1. Cheap ref advertisement: HEAD's SHA + the branch it points at.
let refs = crate::ssh::ls_remote_blocking(url, key_path)
.with_context(|| format!("ssh ls-remote {url}"))?;
let head_sha = refs
.iter()
.find(|(_, name)| name == "HEAD")
.map(|(sha, _)| sha.clone())
.ok_or_else(|| anyhow!("remote {url} advertised no HEAD"))?;
// GitHub advertises HEAD's branch too; match by SHA to learn its name.
let branch = refs
.iter()
.find(|(sha, name)| sha == &head_sha && name.starts_with("refs/heads/"))
.map(|(_, name)| name.clone())
.unwrap_or_else(|| "refs/heads/main".to_string());
// Cheap change-detection (ls-remote's purpose): if the worktree is already at
// this commit, there's nothing to fetch — the common steady-state poll.
if dest.join(".git").exists()
&& crate::gitio::head_sha(dest).ok().as_deref() == Some(head_sha.as_str())
{
return Ok(head_sha);
}
eprintln!("nornir-ssh: {url} HEAD={head_sha} changed; fetching pack…");
// 2. Repo: open if present, else init a fresh non-bare one. Match the REMOTE's
// object format: a 64-hex HEAD ⇒ a SHA-256 repo (codeberg hosts these, e.g.
// `mikronova/tunnr`). Initing SHA-1 for a SHA-256 remote makes the fetch + the
// `set_head`/`ObjectId::from_hex` below fail on the 64-char oids.
let mut repo = if dest.join(".git").exists() {
gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?
} else {
std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
let object_hash = (head_sha.len() == 64).then_some(gix::hash::Kind::Sha256);
let opts = gix::create::Options {
destination_must_be_empty: Some(false),
fs_capabilities: None,
object_hash,
};
gix::ThreadSafeRepository::init(dest, gix::create::Kind::WithWorktree, opts)
.with_context(|| format!("gix::init {}", dest.display()))?
.to_thread_local()
};
// A freshly init'd repo has no user identity; gix needs a committer to write
// reflogs for the fetched ref updates (and our HEAD update below). Inject the
// generic in-memory fallback so those succeed without ambient git config.
let _ = repo.committer_or_set_generic_fallback();
// 3. Fetch the pack over the russh channel. gix's blocking fetch runs on
// THIS thread; the upload-pack runtime's workers handle the socket I/O.
let mut up = crate::ssh::connect_upload_pack(&loc, key_path)
.with_context(|| format!("ssh upload-pack {url}"))?;
let (up_reader, up_writer) = up.io();
let transport = GitConnection::new(
up_reader,
up_writer,
Protocol::V2,
loc.path.clone(),
Option::<(String, Option<u16>)>::None,
ConnectMode::Process,
false,
);
let remote = repo
.remote_at(url)
.context("build in-memory remote")?
.with_refspecs(
[
"+refs/heads/*:refs/remotes/origin/*",
// Tags too — release tags drive the viz git-history fallback.
"+refs/tags/*:refs/tags/*",
],
gix::remote::Direction::Fetch,
)
.context("set fetch refspecs")?;
remote
.to_connection_with_transport(transport)
.prepare_fetch(gix::progress::Discard, Default::default())
.context("prepare ssh fetch")?
.receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.context("ssh fetch (pack transfer)")?;
drop(up); // UploadPack::drop closes the channel in-runtime, on every path
// 4. Materialize the worktree, THEN point HEAD at the fetched commit. HEAD is
// what step 1 compares against, so it may only say "at this commit" once
// the files are. The other order (until 2026-10-15) left a failed checkout
// — an emptied worktree — at the new HEAD, and every later poll returned
// early above: on loki 17 of 21 mirrors stayed half-empty for hours.
materialize_worktree(&repo, &head_sha, dest, smudge)
.with_context(|| format!("checkout {head_sha} into {}", dest.display()))?;
set_head(&repo, &branch, &head_sha)?;
eprintln!("nornir-ssh: {url} synced {head_sha} → {}", dest.display());
Ok(head_sha)
}
/// Point an existing clone at `branch` (→ commit `sha`) and materialize its
/// worktree from that commit. Pure-gix (no shell-out): updates `refs/heads/
/// <branch>` + `HEAD`, then writes the tree onto disk. `branch` is the SHORT
/// name (e.g. `main`); `sha` its peeled commit. Used by the fat auto-materialize
/// path to honor a member's pinned `branch` after a default-HEAD clone.
pub fn set_head_and_checkout(dest: &Path, branch: &str, sha: &str) -> Result<()> {
let repo = open_for_ref_edits(dest)?;
let full = format!("refs/heads/{branch}");
// Files first, HEAD last: a failed checkout leaves HEAD where it was.
materialize_worktree(&repo, sha, dest, Smudge::Run)
.with_context(|| format!("checkout {sha} into {}", dest.display()))?;
set_head(&repo, &full, sha)
}
/// **Open a repository that is about to have a ref edited.**
///
/// The one opener for every entry point below that writes a ref, because every
/// one of them needs the same thing and two of them did not have it.
///
/// gix refuses to write a **reflog** without a committer identity, and a
/// repository has one only from ambient git config — `~/.gitconfig`, the
/// system config, or `GIT_COMMITTER_*`. A machine with none (a fresh
/// container, a build agent, an appliance) therefore failed every ref edit
/// here with
///
/// ```text
/// The reflog could not be created or updated: reflog messages need a
/// committer which isn't set
/// ```
///
/// which is **not** what `git` does: git falls back to a generic identity for
/// reflogs and refuses only for commits, and `committer_or_set_generic_fallback`
/// is gix's spelling of exactly that fallback. It is in-memory and per-handle:
/// nothing is written to the repository's config, so a later `git commit` in
/// that repository still asks the user for their own identity rather than
/// silently attributing to a name this crate invented.
///
/// MEASURED 2026-09-12: `gunnar clone --depth 1` in a toolchain container
/// failed here *after the whole pack had transferred*, through
/// [`set_head_and_checkout`]. Three other call sites in this crate already made
/// this call — and the comment beside one of them said "Same call
/// `set_head_and_checkout` … make before their ref edits", which was a
/// description of what should have been true. The fix is one opener rather
/// than a fourth copy of the line, so the next entry point cannot forget it.
pub(crate) fn open_for_ref_edits(path: &Path) -> Result<gix::Repository> {
let mut repo = gix::open(path).with_context(|| format!("gix::open {}", path.display()))?;
let _ = repo.committer_or_set_generic_fallback();
Ok(repo)
}
/// Point `HEAD` at the local branch `<branch>`, creating/moving that branch to
/// `sha` — WITHOUT touching the working tree. Used for `git checkout -B <branch>`
/// at the current `HEAD` (the worktree already matches `sha`, so no
/// materialisation is needed). See [`gitops::create_or_reset_branch_at_head`](crate::gitops::create_or_reset_branch_at_head).
pub fn point_head_at_branch(root: &Path, branch: &str, sha: &str) -> Result<()> {
let repo = open_for_ref_edits(root)?;
let full = format!("refs/heads/{branch}");
set_head(&repo, &full, sha)
}
/// Detach `HEAD` directly at `sha` and materialise that tree into the working
/// directory — `git checkout --detach <sha>`.
pub fn detach_head_and_checkout(root: &Path, sha: &str) -> Result<()> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix::refs::Target;
let repo = open_for_ref_edits(root)?;
let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
repo.edit_reference(RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "nornir-git: detach HEAD".into(),
},
expected: PreviousValue::Any,
new: Target::Object(oid),
},
name: "HEAD".try_into().expect("HEAD is a valid ref name"),
deref: false,
})
.context("detach HEAD")?;
materialize_worktree(&repo, sha, root, Smudge::Run)
.with_context(|| format!("checkout {sha} into {}", root.display()))
}
/// Create/update `branch` → `sha` (a direct ref) and point `HEAD` at `branch`.
fn set_head(repo: &gix::Repository, branch: &str, sha: &str) -> Result<()> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix::refs::{FullName, Target};
let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
let branch_name: FullName = branch
.try_into()
.map_err(|e| anyhow!("invalid branch ref `{branch}`: {e}"))?;
let log = LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "nornir: ssh fetch".into(),
};
let edits = vec![
RefEdit {
change: Change::Update {
log: log.clone(),
expected: PreviousValue::Any,
new: Target::Object(oid),
},
name: branch_name.clone(),
deref: false,
},
RefEdit {
change: Change::Update {
log,
expected: PreviousValue::Any,
new: Target::Symbolic(branch_name),
},
name: "HEAD".try_into().expect("HEAD is a valid ref name"),
deref: false,
},
];
repo.edit_references(edits).context("set HEAD + branch")?;
Ok(())
}
/// **THE worktree writer of this crate.** Materialize `dest` from the tree of
/// commit `sha`: clear everything but `.git`, then run gix's own checkout.
///
/// Every entry point that puts files on disk — [`clone_repo`], [`ssh_sync`],
/// [`set_head_and_checkout`], [`detach_head_and_checkout`] — goes through here,
/// and `tests/checkout_parity.rs::there_is_exactly_one_worktree_writer` fails
/// the build if a second one appears.
///
/// # Why there is exactly one, and why it applies filters
///
/// Until 2026-09-12 there were **two**, and they disagreed. `clone_repo` used
/// gix's `PrepareCheckout::main_worktree`, which runs the configured **smudge**
/// driver; this function hand-wrote each index blob with `std::fs::write`, which
/// does not. So the same commit produced *different files on disk* depending on
/// the URL scheme in a descriptor: over `file://`/`https` an LFS-tracked path
/// arrived as the asset, over `ssh` as its 131-byte pointer. gunnar measured all
/// four writers on 2026-08-05 (`gunnar-client/src/attributes.rs`) and
/// `gunnar-client::unfiltered_paths` REFUSES the pointer when
/// `filter.lfs.required=true` — git's own contract — which is how skade, holger
/// and Skidbladnir came to fail checkout while the same clone over the other
/// transport was fine.
///
/// A test that the two writers agree would only have watched them; one writer is
/// the fix by construction (LAW 5). It is gix's rather than the hand-rolled loop
/// because gix's is the one that already knew about filters, `core.symlinks`,
/// `core.protect*` and case collisions.
///
/// # The long-running driver stays disarmed
///
/// The handle is [cloned and disarmed](disarm_long_running_filters) before the
/// checkout, so the filter runs through the single-file `clean`/`smudge` pair —
/// git's own documented fallback when `process` is unset — which
/// `gix-filter/src/driver/apply.rs` waits on. gix's long-running `process`
/// driver has no `Drop` and is never reaped, and `ssh_sync` is a **poll** path:
/// leaving it armed here would reinstate the 1330/h `<defunct>` bleed that
/// `tests/lfs_filter_reaping.rs` exists for, one child per polled repo per
/// change. Disarming costs nothing semantically — the same smudge runs — and it
/// also closes the one-shot leak `clone_repo` used to be excused for.
fn materialize_worktree(repo: &gix::Repository, sha: &str, dest: &Path, smudge: Smudge) -> Result<()> {
// Per-handle and in-memory: nothing is written to the repository's config.
let mut repo = repo.clone();
match smudge {
Smudge::Run => disarm_long_running_filters(&mut repo).map(drop),
Smudge::Skip => skip_content_filters(&mut repo).map(drop),
}
.with_context(|| format!("disarm content filters for {}", dest.display()))?;
let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
let tree_id = repo
.find_commit(oid)
.with_context(|| format!("find commit {sha}"))?
.tree_id()
.context("commit tree")?
.detach();
let mut index = repo
.index_from_tree(&tree_id)
.with_context(|| format!("index from tree {tree_id}"))?;
// Clear the worktree (keep `.git`) so deletions upstream are reflected.
for entry in std::fs::read_dir(dest).with_context(|| format!("read {}", dest.display()))? {
let path = entry?.path();
if path.file_name().map(|n| n == ".git").unwrap_or(false) {
continue;
}
if path.is_dir() {
std::fs::remove_dir_all(&path).ok();
} else {
std::fs::remove_file(&path).ok();
}
}
// `IdMapping`: read `.gitattributes` out of the tree being checked out, not
// off disk. The loop above just emptied the worktree, so a worktree-first
// source would resolve the attributes of the first files written against
// nothing and silently skip their filter.
let mut opts = repo
.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)
.context("resolve checkout options")?;
opts.destination_is_initially_empty = true;
let outcome = gix::worktree::state::checkout(
&mut index,
dest,
repo.objects.clone().into_arc().context("odb handle into arc")?,
&gix::progress::Discard,
&gix::progress::Discard,
&gix::interrupt::IS_INTERRUPTED,
opts,
)
.with_context(|| format!("checkout {sha} into {}", dest.display()))?;
// A checkout that reports per-path errors still returns `Ok`. Swallowing them
// is how a half-written worktree becomes "synced" in a log line.
if !outcome.errors.is_empty() || !outcome.collisions.is_empty() {
bail!(
"checkout of {sha} into {} left {} path error(s) and {} collision(s): {:?}",
dest.display(),
outcome.errors.len(),
outcome.collisions.len(),
outcome.errors.iter().take(5).map(|e| e.path.to_string()).collect::<Vec<_>>()
);
}
// Persist the index built from the checked-out tree. WITHOUT this the on-disk index
// still reflects the PRE-checkout state, so a subsequent `gix status` reports every
// path as a spurious staged/modified change — which makes a branch-switch look like a
// dirty working tree (the release doctors' `uncommitted_source` / freshness checks
// would then wrongly refuse). Same reason `commit_all` writes its index.
index
.write(gix::index::write::Options::default())
.with_context(|| format!("persist index for checkout into {}", dest.display()))?;
Ok(())
}
/// What a checkout does with the content filters a repo declares
/// (`*.png filter=lfs` …).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Smudge {
/// Run the configured smudge drivers: the checkout holds the real files, as
/// stock git writes them. For a checkout a human or a build uses.
Run,
/// Write every blob as stored and launch no filter at all: an LFS-tracked
/// path holds its pointer. For a reader that only needs the source.
///
/// Measured on loki 2026-10-15: the server's mirrors are fetched through an
/// in-memory remote, so `git-lfs smudge` has no endpoint to download from
/// (`batch request: missing protocol: ""`), `filter.lfs.required=true`
/// turns that into a failed checkout, and every member carrying an LFS file
/// (facett, edda, gunnar, nornir, skade, …) was left without a worktree.
Skip,
}
/// Ensure `dest` is an up-to-date clone of `url`: clone if absent, else fetch.
/// Returns the resulting `HEAD` SHA. SSH remotes use the deploy key at
/// `ssh_key` (resolved via [`nornir_ssh_key_path`] when `None`). Runs the smudge
/// drivers; see [`clone_or_fetch_with`] for a checkout that does not.
pub fn clone_or_fetch(url: &str, dest: &Path, ssh_key: Option<&Path>) -> Result<String> {
clone_or_fetch_with(url, dest, ssh_key, Smudge::Run)
}
/// [`clone_or_fetch`] with the checkout's [`Smudge`] mode named.
pub fn clone_or_fetch_with(url: &str, dest: &Path, ssh_key: Option<&Path>, smudge: Smudge) -> Result<String> {
// nornir fetches over SSH ONLY — the https transport is unsupported. Coerce
// any `http(s)://host/owner/repo` remote to its scp-like SSH form
// (`git@host:owner/repo.git`) up front, so a deploy key drives every fetch (no
// credential prompt, no broken-https path). Non-http remotes (local paths,
// already-SSH URLs) are returned unchanged by `https_to_ssh`, so they pass
// through. This makes "ssh always" hold regardless of what a descriptor wrote.
let coerced = https_to_ssh(url);
let url: &str = coerced.as_deref().unwrap_or(url);
if is_ssh_url(url) {
let resolved;
let key = match ssh_key {
Some(k) => k,
None => {
resolved = nornir_ssh_key_path().ok_or_else(|| {
// Name every rung that was looked at, not just the fact of
// failure. Eight repos logged this line every 60 seconds for most
// of 2026-07-22 and it named ONE path that was not the one anybody
// had a key in.
let res = crate::deploykey::Resolution::resolve();
let looked: Vec<String> = res
.candidates
.iter()
.map(|c| format!("{} -> {}", c.origin.as_str(), c.private.display()))
.collect();
anyhow!(
"SSH remote `{url}` needs a deploy key, but none was found. \
Looked at, in order: {}. Mint one with `dwarves key generate` \
(default: ~/.dwarves/ssh/id_ed25519) and register the public \
half as a deploy key on the repo — the dwarves UI's 🔑 Deploy \
key pane shows it with a copy button.",
looked.join("; ")
)
})?;
resolved.as_path()
}
};
return ssh_sync(url, dest, key, smudge);
}
// SSH-FIRST: an `https://` remote prompts for credentials on a private repo,
// which is fatal headless (the server has no tty). Most of our remotes ARE
// private and reachable via the deploy key, so for a FRESH clone try the
// scp-like SSH form first and fall back to the original https URL only if
// SSH fails (e.g. a public host we hold no key for). Existing checkouts just
// fetch through their stored remote.
if !dest.join(".git").exists() {
let key = ssh_key.map(|k| k.to_path_buf()).or_else(nornir_ssh_key_path);
if let (Some(ssh_url), Some(key)) = (https_to_ssh(url), key.as_ref()) {
match ssh_sync(&ssh_url, dest, key, smudge) {
Ok(sha) => return Ok(sha),
Err(e) => {
eprintln!(
"nornir-gitio: SSH-first clone of `{ssh_url}` failed ({e:#}); \
falling back to `{url}`"
);
// ssh_sync may have left a partial dir — clear it so the https
// clone starts clean.
if !dest.join(".git").exists() {
let _ = std::fs::remove_dir_all(dest);
}
}
}
}
}
if dest.join(".git").exists() {
fetch_repo(dest, smudge)?;
} else {
clone_repo(url, dest, smudge)?;
}
head_sha(dest)
}
/// Outcome of a [`push_to_local_remote`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalPushReport {
/// The commit SHA the remote branch now points at (the pushed head).
pub sha: String,
/// The branch that was updated on the remote.
pub branch: String,
/// `true` when the remote branch did not exist before (a create, not an update).
pub created: bool,
}
/// **Fast-forward PUSH to a LOCAL/file git remote, pure-Rust (no send-pack).**
///
/// gix (0.84) implements fetch but not the send-pack side of the git wire
/// protocol, so an over-the-network push is impossible without a `git`
/// subprocess or libgit2 (both forbidden). For a LOCAL/file remote we don't need
/// the wire protocol at all: the object database is content-addressed loose/pack
/// files on a filesystem we can already read. So we (1) union-copy `src`'s
/// `.git/objects` into `remote`'s (idempotent — identical content hashes), then
/// (2) fast-forward `remote`'s `refs/heads/<branch>` to `src`'s HEAD. The ref is
/// updated LAST (after all objects land), so a failed copy never leaves a ref
/// pointing at absent objects — atomic-ish.
///
/// FAIL-CLEAN on non-fast-forward: if the remote branch already exists and its
/// commit is NOT an ancestor of `src`'s HEAD (the remote advanced independently),
/// we return an error and touch nothing — never clobber. Over-the-network
/// remotes (`ssh://` / `git@…` / `http(s)://`) return an error (unsupported).
pub fn push_to_local_remote(src: &Path, remote: &str) -> Result<LocalPushReport> {
// Only local/file remotes: no send-pack for network transports.
let remote = remote.strip_prefix("file://").unwrap_or(remote);
if is_ssh_url(remote) || remote.starts_with("http://") || remote.starts_with("https://") {
bail!(
"in-process push is only supported for LOCAL/file git remotes — gix (0.84) has no \
send-pack for `{remote}`. Push the warehouse mirror out-of-band, or use a local remote."
);
}
let remote_path = Path::new(remote);
if !remote_path.exists() {
bail!("remote warehouse path `{remote}` does not exist (open the remote warehouse first)");
}
let src_repo = gix::open(src).with_context(|| format!("gix::open src {}", src.display()))?;
// The remote warehouse repo has no ambient git identity; without a generic
// committer fallback, appending to an existing reflog on the ref update
// below fails with "reflog messages need a committer". [`open_for_ref_edits`]
// is that one opener — and until 2026-09-12 this comment claimed
// `set_head_and_checkout` made the same call when it did not, which is how
// `gunnar clone` came to fail on a machine with no `~/.gitconfig`.
let remote_repo = open_for_ref_edits(remote_path)
.with_context(|| format!("gix::open remote {remote}"))?;
let branch = head_branch(src)?;
let new_sha = head_sha(src)?;
let new_oid =
gix::ObjectId::from_hex(new_sha.as_bytes()).with_context(|| format!("parse {new_sha}"))?;
let full = format!("refs/heads/{branch}");
// Current remote head for this branch (None = the branch is new).
let old_oid: Option<gix::ObjectId> = remote_repo
.try_find_reference(full.as_str())
.with_context(|| format!("look up {full} on remote"))?
.map(|r| r.into_fully_peeled_id().map(|id| id.detach()))
.transpose()
.with_context(|| format!("peel {full} on remote"))?;
// Fast-forward guard: the remote head must be an ancestor of what we push.
if let Some(old) = old_oid {
if old != new_oid && !is_ancestor(&src_repo, old, new_oid) {
bail!(
"non-fast-forward push to `{remote}`: remote `{branch}` is at {old} which is not an \
ancestor of {new_sha} — the remote advanced. Re-open (fetch) the remote warehouse \
to reconcile before pushing; refusing to clobber."
);
}
}
// (1) Transfer objects: union-copy src's object DB into the remote's — each side's
// REAL object store as gix resolved it (`<git-dir>/objects`), not a hard-coded
// `<root>/.git/objects`: a BARE remote (the shape every local `origin` mirror has)
// keeps its objects at `<bare>/objects`, and a linked worktree's `.git` is a FILE
// pointing at the common dir. The hard-coded path wrote a bare remote's objects
// into `<bare>/.git/objects`, where git never looks — the ref then advanced onto
// objects the remote could not read (caught by dwarves' finalize push, 2026-08-22).
let src_objects = src_repo.objects.store_ref().path().to_path_buf();
let remote_objects = remote_repo.objects.store_ref().path().to_path_buf();
copy_objects_union(&src_objects, &remote_objects).context("transfer git objects to remote")?;
// (2) Fast-forward the remote branch ref (guarded on the observed previous
// value for atomicity — a concurrent change fails the transaction).
update_remote_branch(&remote_repo, &full, new_oid, old_oid)
.with_context(|| format!("fast-forward {full} on remote"))?;
Ok(LocalPushReport { sha: new_sha, branch, created: old_oid.is_none() })
}
/// Is `ancestor` reachable from `tip` (i.e. an ancestor of, or equal to, `tip`)
/// within `repo`? Walks `tip`'s first-parent-and-merges history. A missing
/// `ancestor` object (remote commit the src never saw) yields `false` → the
/// caller treats it as a non-fast-forward.
fn is_ancestor(repo: &gix::Repository, ancestor: gix::ObjectId, tip: gix::ObjectId) -> bool {
if ancestor == tip {
return true;
}
let Ok(walk) = repo.rev_walk(Some(tip)).all() else {
return false;
};
for info in walk {
match info {
Ok(info) if info.id == ancestor => return true,
Ok(_) => {}
Err(_) => return false,
}
}
false
}
/// Update (or create) a direct branch ref on `repo` to `new`, guarding on the
/// previously-observed value `prev` (`None` ⇒ must-not-exist create) so a
/// concurrent writer can't be silently clobbered.
fn update_remote_branch(
repo: &gix::Repository,
full: &str,
new: gix::ObjectId,
prev: Option<gix::ObjectId>,
) -> Result<()> {
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix::refs::{FullName, Target};
let name: FullName = full.try_into().map_err(|e| anyhow!("invalid ref `{full}`: {e}"))?;
let expected = match prev {
Some(old) => PreviousValue::MustExistAndMatch(Target::Object(old)),
None => PreviousValue::MustNotExist,
};
let edit = RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "nornir: warehouse republish (push)".into(),
},
expected,
new: Target::Object(new),
},
name,
deref: false,
};
repo.edit_references(vec![edit]).context("edit remote branch ref")?;
Ok(())
}
/// Recursively copy every object file under `src_objects` into `dst_objects`,
/// skipping any that already exist (git objects are content-addressed, so an
/// existing file is byte-identical). Creates parent dirs as needed. Skips the
/// transient `objects/info` housekeeping dir.
fn copy_objects_union(src_objects: &Path, dst_objects: &Path) -> Result<()> {
let mut stack = vec![src_objects.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
let rel = p.strip_prefix(src_objects).unwrap_or(&p);
if is_dir {
// `objects/info` holds no immutable objects — skip it.
if rel == Path::new("info") {
continue;
}
stack.push(p);
continue;
}
let dst = dst_objects.join(rel);
if dst.exists() {
continue; // content-addressed ⇒ identical, don't recopy
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir {}", parent.display()))?;
}
std::fs::copy(&p, &dst)
.with_context(|| format!("copy {} → {}", p.display(), dst.display()))?;
}
}
Ok(())
}
/// Rewrite an `https://host/owner/repo(.git)` (or `http://`) URL to its scp-like
/// SSH form `git@host:owner/repo.git`. Returns `None` when `url` isn't a plain
/// http(s) URL with both a host and a path we can rewrite. Used to prefer SSH
/// (deploy-key, no credential prompt) over https for private remotes.
pub fn https_to_ssh(url: &str) -> Option<String> {
let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?;
let (host, path) = rest.split_once('/')?;
let host = host.trim();
let path = path.trim().trim_matches('/');
if host.is_empty() || path.is_empty() {
return None;
}
let path = path.strip_suffix(".git").unwrap_or(path);
Some(format!("git@{host}:{path}.git"))
}
/// Recursively upsert every file under `dir` into `editor`, keyed by its
/// path relative to `repo_root`. Skips the `.git` directory. `pub(crate)` so
/// [`gitops::commit_dir_as_orphan_branch`](crate::gitops::commit_dir_as_orphan_branch)
/// can snapshot a built `dist/` directory into a tree without twinning this walk.
pub(crate) fn add_dir_recursive(
repo: &gix::Repository,
editor: &mut gix::object::tree::Editor<'_>,
repo_root: &Path,
dir: &Path,
) -> Result<()> {
use gix::object::tree::EntryKind;
let mut entries: Vec<_> = std::fs::read_dir(dir)
.with_context(|| format!("read_dir {}", dir.display()))?
.collect::<std::io::Result<Vec<_>>>()
.with_context(|| format!("iterate {}", dir.display()))?;
// Deterministic order keeps generated history reproducible.
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let name = entry.file_name();
if name == ".git" {
continue;
}
let meta = std::fs::symlink_metadata(&path)
.with_context(|| format!("stat {}", path.display()))?;
let ft = meta.file_type();
if ft.is_dir() {
add_dir_recursive(repo, editor, repo_root, &path)?;
continue;
}
let rela = path
.strip_prefix(repo_root)
.expect("path is under repo_root");
let rela = gix::path::into_bstr(rela)?.into_owned();
let (bytes, kind): (Vec<u8>, EntryKind) = if ft.is_symlink() {
let target = std::fs::read_link(&path)
.with_context(|| format!("readlink {}", path.display()))?;
(gix::path::into_bstr(target)?.into_owned().into(), EntryKind::Link)
} else {
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
#[cfg(unix)]
let kind = {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 != 0 {
EntryKind::BlobExecutable
} else {
EntryKind::Blob
}
};
#[cfg(not(unix))]
let kind = EntryKind::Blob;
(bytes, kind)
};
let blob = repo.write_blob(&bytes).context("write blob")?;
editor
.upsert(rela.as_ref() as &gix::bstr::BStr, kind, blob.detach())
.with_context(|| format!("tree upsert {rela}"))?;
}
Ok(())
}
#[cfg(test)]
mod ssh_first_tests {
use super::*;
/// LAW (inject-assert): feed real https URLs and assert the EXACT scp-like
/// SSH form, the host/owner/repo round-trip, idempotent `.git`, trailing-slash
/// trim — and that the rewritten URL is recognised as SSH by `is_ssh_url`.
#[test]
fn https_rewrites_to_scp_like_ssh_and_is_recognised() {
let cases = [
("https://forge.example/nordisk/korp", "git@forge.example:nordisk/korp.git"),
("https://forge.example/nordisk/ordning.git", "git@forge.example:nordisk/ordning.git"),
("https://forge.example/nordisk/knut/", "git@forge.example:nordisk/knut.git"),
("http://github.com/rustsec/advisory-db", "git@github.com:rustsec/advisory-db.git"),
];
for (https, want) in cases {
let got = https_to_ssh(https).unwrap_or_else(|| panic!("no rewrite for {https}"));
assert_eq!(got, want, "rewrite of {https}");
assert!(is_ssh_url(&got), "rewritten `{got}` must be SSH-shaped");
}
}
/// Non-rewritable inputs return None (so `clone_or_fetch` leaves them alone):
/// already-SSH, scheme-less, and host-only URLs.
#[test]
fn non_https_or_pathless_urls_do_not_rewrite() {
for url in [
"git@forge.example:nordisk/korp.git", // already SSH
"ssh://git@host/owner/repo.git", // already SSH scheme
"https://codeberg.org", // host only, no path
"https://codeberg.org/", // empty path
"/local/path/repo", // not a url
] {
assert!(https_to_ssh(url).is_none(), "{url} must not rewrite");
}
}
/// The private-sibling clone base is SSH by default (no https credential
/// prompt), and `<base>/<repo>` is a valid SSH remote.
#[test]
fn dep_clone_base_default_is_ssh() {
// Only assert the default when the env override is absent.
if std::env::var("NORNIR_DEP_CLONE_BASE").is_err() {
// The dep-clone base default lives in nornir's `security` module; the
// standalone crate hard-codes the known default so the test stays
// dependency-free (nornir asserts the real value on its side).
let base = "ssh://git@git.gunnar.rs:2222/nordisk";
assert!(is_ssh_url(&format!("{base}/nornir-orch")), "`{base}/repo` must be SSH");
}
}
}
#[cfg(test)]
mod push_tests {
use super::*;
fn seed(root: &Path, body: &str) -> String {
init(root).unwrap();
std::fs::write(root.join("data.txt"), body).unwrap();
commit_all(root, "seed").unwrap()
}
/// A BARE local remote (the shape of every local `origin` mirror) receives the
/// OBJECTS where git keeps them — `<bare>/objects`, not `<bare>/.git/objects` — so
/// the pushed commit is READABLE on the remote, not merely named by its ref.
/// RED before the fix: the ref advanced, but `find_object` on the remote failed
/// (the objects sat under a `.git/` dir a bare repo never reads) — a ref pointing
/// at absent objects is exactly the state the "ref updated LAST" rule exists to
/// prevent.
#[test]
fn push_to_a_bare_remote_lands_the_objects_where_the_bare_repo_reads_them() {
let td = tempfile::tempdir().unwrap();
let src = td.path().join("src");
let sha = seed(&src, "v1\n");
let bare = td.path().join("origin.git");
gix::init_bare(&bare).unwrap();
let report = push_to_local_remote(&src, bare.to_str().unwrap()).unwrap();
assert_eq!(report.sha, sha);
assert!(report.created);
let remote = gix::open(&bare).unwrap();
let oid = gix::ObjectId::from_hex(sha.as_bytes()).unwrap();
let commit = remote
.find_object(oid)
.expect("the pushed commit is readable on the bare remote")
.into_commit();
assert!(commit.tree().is_ok(), "and so is its tree");
assert!(
!bare.join(".git").exists(),
"nothing was written into a `.git/` dir a bare repo never reads"
);
}
/// Happy path: clone a local remote, add a commit on the clone, push it back
/// over the pure-Rust local transport — the remote branch fast-forwards and a
/// fresh clone sees the new file.
#[test]
fn push_local_fast_forwards_and_a_fresh_clone_sees_it() {
let td = tempfile::tempdir().unwrap();
let remote = td.path().join("remote");
seed(&remote, "v1\n");
// Clone it, then add a new commit on the clone.
let src = td.path().join("clone");
clone_or_fetch(remote.to_str().unwrap(), &src, None).unwrap();
std::fs::write(src.join("data.txt"), "v2\n").unwrap();
let new_sha = commit_all(&src, "v2").unwrap();
// Push back over the local transport.
let report = push_to_local_remote(&src, remote.to_str().unwrap()).unwrap();
assert_eq!(report.sha, new_sha, "remote branch now at the pushed commit");
assert!(!report.created, "the branch already existed → fast-forward update");
// The remote branch ref advanced, and a fresh clone reads the new content.
assert_eq!(head_sha(&remote).unwrap(), new_sha, "remote HEAD ref fast-forwarded");
let fresh = td.path().join("fresh");
clone_or_fetch(remote.to_str().unwrap(), &fresh, None).unwrap();
assert_eq!(std::fs::read_to_string(fresh.join("data.txt")).unwrap(), "v2\n");
}
/// FAIL-CLEAN on non-fast-forward: the remote advanced independently, so the
/// clone's push is refused and the remote is NOT clobbered.
#[test]
fn push_local_refuses_non_fast_forward() {
let td = tempfile::tempdir().unwrap();
let remote = td.path().join("remote");
seed(&remote, "v1\n");
let src = td.path().join("clone");
clone_or_fetch(remote.to_str().unwrap(), &src, None).unwrap();
// Diverge: a new commit on the clone AND an independent new commit on the
// remote (both children of v1) — a classic non-ff.
std::fs::write(src.join("data.txt"), "clone-side\n").unwrap();
commit_all(&src, "clone edit").unwrap();
std::fs::write(remote.join("data.txt"), "remote-side\n").unwrap();
let remote_sha = commit_all(&remote, "remote edit").unwrap();
let err = push_to_local_remote(&src, remote.to_str().unwrap()).unwrap_err().to_string();
assert!(err.contains("non-fast-forward"), "clear non-ff error: {err}");
// The remote was NOT clobbered — still at its own commit.
assert_eq!(head_sha(&remote).unwrap(), remote_sha, "remote ref untouched on non-ff");
}
/// Over-the-network remotes are rejected cleanly (gix has no send-pack).
#[test]
fn push_local_rejects_network_remotes() {
let td = tempfile::tempdir().unwrap();
let src = td.path().join("clone");
seed(&src, "v1\n");
for url in ["https://forge.example/nordisk/warehouse.git", "git@forge.example:nordisk/warehouse.git"] {
let err = push_to_local_remote(&src, url).unwrap_err().to_string();
assert!(err.contains("LOCAL/file"), "network remote rejected with a clear reason: {err}");
}
}
}
#[cfg(test)]
mod refetch_tests {
//! **A re-fetch of a non-SSH clone follows the remote's `HEAD`.**
//!
//! `clone_or_fetch` over a local path / `file://` / https used to move
//! `refs/remotes/origin/*` only: the SHA it returned, `HEAD` and the files
//! on disk stayed at the first clone. Over SSH (`ssh_sync`) all three moved.
//! One function, one meaning.
use super::*;
fn seed(root: &Path, body: &str) -> String {
init(root).unwrap();
std::fs::write(root.join("data.txt"), body).unwrap();
commit_all(root, "seed").unwrap()
}
#[test]
fn a_refetch_of_a_local_remote_returns_the_new_head_and_moves_the_checkout() {
let td = tempfile::tempdir().unwrap();
let remote = td.path().join("remote");
let first = seed(&remote, "v1\n");
let url = remote.to_str().unwrap();
let dest = td.path().join("clone");
assert_eq!(clone_or_fetch(url, &dest, None).unwrap(), first, "the clone is at the remote's HEAD");
// The remote moves: one file changed, one added.
std::fs::write(remote.join("data.txt"), "v2\n").unwrap();
std::fs::write(remote.join("added.txt"), "new\n").unwrap();
let second = commit_all(&remote, "v2").unwrap();
assert_ne!(first, second);
// RED before the fix: this returned `first` — the clone's own stale HEAD.
let seen = clone_or_fetch(url, &dest, None).unwrap();
assert_eq!(seen, second, "a re-fetch must report the commit the remote is at now");
assert_eq!(head_sha(&dest).unwrap(), second, "HEAD moved");
assert_eq!(
head_branch(&dest).unwrap(),
head_branch(&remote).unwrap(),
"HEAD is still ON the remote's branch, not detached"
);
assert_eq!(std::fs::read_to_string(dest.join("data.txt")).unwrap(), "v2\n", "the files moved with it");
assert_eq!(std::fs::read_to_string(dest.join("added.txt")).unwrap(), "new\n");
let (_, status) = porcelain_status(&dest).unwrap();
assert!(status.is_empty(), "the checkout is clean at the new commit: {status:?}");
// A removal upstream is a removal here.
std::fs::remove_file(remote.join("added.txt")).unwrap();
let third = commit_all(&remote, "drop added.txt").unwrap();
assert_eq!(clone_or_fetch(url, &dest, None).unwrap(), third);
assert!(!dest.join("added.txt").exists(), "a file deleted upstream is gone from the checkout");
}
/// The steady-state poll: nothing moved upstream, so nothing is rewritten —
/// the same early return `ssh_sync` takes. An untracked file is the witness
/// (a re-materialised worktree is cleared first).
#[test]
fn a_refetch_with_nothing_new_leaves_the_worktree_alone() {
let td = tempfile::tempdir().unwrap();
let remote = td.path().join("remote");
let first = seed(&remote, "v1\n");
let url = remote.to_str().unwrap();
let dest = td.path().join("clone");
clone_or_fetch(url, &dest, None).unwrap();
std::fs::write(dest.join("scratch.tmp"), "local\n").unwrap();
assert_eq!(clone_or_fetch(url, &dest, None).unwrap(), first);
assert!(dest.join("scratch.tmp").exists(), "no upstream change → the worktree was not rewritten");
}
/// A clone whose checkout config names a smudge driver that fails, `required`
/// — what loki's mirrors met on 2026-10-15 (`filter.lfs.required=true` from
/// `/etc/gitconfig`, and `git-lfs smudge` with no endpoint to download from).
fn failing_required_filter(dest: &Path) {
let mut cfg = std::fs::read_to_string(dest.join(".git/config")).unwrap();
cfg.push_str("[filter \"boom\"]\n\tsmudge = false\n\trequired = true\n");
std::fs::write(dest.join(".git/config"), cfg).unwrap();
}
/// The remote gains a file its `.gitattributes` routes through `boom`.
fn add_filtered_asset(remote: &Path) -> String {
std::fs::write(remote.join(".gitattributes"), "*.bin filter=boom\n").unwrap();
std::fs::write(remote.join("asset.bin"), "stored\n").unwrap();
commit_all(remote, "asset").unwrap()
}
/// A checkout that fails must not leave HEAD at the new commit. HEAD is what
/// the next poll compares the remote against; moved first, it made every
/// later fetch return early with `Ok` over an emptied worktree.
#[test]
fn a_failed_checkout_leaves_head_behind_so_the_next_fetch_tries_again() {
let td = tempfile::tempdir().unwrap();
let remote = td.path().join("remote");
let first = seed(&remote, "v1\n");
let url = remote.to_str().unwrap();
let dest = td.path().join("clone");
clone_or_fetch(url, &dest, None).unwrap();
failing_required_filter(&dest);
add_filtered_asset(&remote);
assert!(clone_or_fetch(url, &dest, None).is_err(), "the required smudge fails, so the checkout fails");
assert_eq!(head_sha(&dest).unwrap(), first, "a failed checkout leaves HEAD where it was");
assert!(
clone_or_fetch(url, &dest, None).is_err(),
"the next fetch tries the checkout again instead of reporting the half-written tree as synced"
);
}
/// `Smudge::Skip` writes blobs as stored and launches no filter, so a
/// failing required driver cannot fail it; it also completes the checkout a
/// failed `Smudge::Run` left behind.
#[test]
fn smudge_skip_checks_out_the_stored_blob_past_a_failing_required_filter() {
let td = tempfile::tempdir().unwrap();
let remote = td.path().join("remote");
seed(&remote, "v1\n");
let url = remote.to_str().unwrap();
let dest = td.path().join("clone");
clone_or_fetch(url, &dest, None).unwrap();
failing_required_filter(&dest);
let second = add_filtered_asset(&remote);
assert!(clone_or_fetch(url, &dest, None).is_err(), "precondition: Run fails here");
assert_eq!(clone_or_fetch_with(url, &dest, None, Smudge::Skip).unwrap(), second);
assert_eq!(head_sha(&dest).unwrap(), second);
assert_eq!(std::fs::read_to_string(dest.join("asset.bin")).unwrap(), "stored\n", "the blob as stored");
assert_eq!(std::fs::read_to_string(dest.join("data.txt")).unwrap(), "v1\n", "the rest of the tree is there");
}
}
#[cfg(test)]
mod freshness_tests {
use super::*;
/// Build a one-file repo with a committed `README.md`. Returns the temp dir
/// (kept alive by the caller) and the repo root.
fn fixture() -> (tempfile::TempDir, PathBuf) {
let td = tempfile::tempdir().expect("tempdir");
let root = td.path().to_path_buf();
init(&root).expect("git init");
std::fs::write(root.join("README.md"), b"hello\n").expect("write README");
commit_all(&root, "initial").expect("commit");
(td, root)
}
/// LAW 1 (inject-assert): feed a real repo, compute the digest CLEAN; then
/// modify the working tree and assert (a) the digest CHANGES, (b) clean→dirty
/// flips, and (c) the HEAD SHA does NOT move — the whole reason a SHA cannot
/// stand in for a working-tree digest.
#[test]
fn worktree_edit_changes_digest_and_dirty_but_not_head_sha() {
let (_td, root) = fixture();
// Freshly committed → clean, sentinel digest.
let clean = worktree_freshness(&root).expect("freshness clean");
assert!(!clean.dirty, "freshly committed tree must read clean");
assert_eq!(clean.digest, CLEAN_WORKTREE_DIGEST, "clean ⇒ sentinel digest");
let sha_before = head_sha(&root).expect("head sha before");
// Inject a tracked modification (edit the committed file in place).
std::fs::write(root.join("README.md"), b"hello, dirty world\n").expect("modify");
let modified = worktree_freshness(&root).expect("freshness after modify");
assert!(modified.dirty, "an uncommitted edit must read dirty");
assert_ne!(modified.digest, clean.digest, "edit must CHANGE the digest");
assert_ne!(modified.digest, CLEAN_WORKTREE_DIGEST, "dirty ⇒ not the sentinel");
// THE POINT: HEAD did NOT move — the SHA cannot see the uncommitted edit.
let sha_after = head_sha(&root).expect("head sha after");
assert_eq!(
sha_before, sha_after,
"editing the working tree must NOT change HEAD — so the SHA cannot \
detect staleness; only the digest can"
);
// Inject an untracked file → digest changes again, still dirty.
std::fs::write(root.join("NEW.txt"), b"brand new\n").expect("add untracked");
let added = worktree_freshness(&root).expect("freshness after add");
assert!(added.dirty, "an untracked file must read dirty");
assert_ne!(added.digest, modified.digest, "adding a file must CHANGE the digest again");
assert_eq!(head_sha(&root).expect("head sha"), sha_before, "still no HEAD movement");
}
/// RED-WHEN-BROKEN: `worktree_changed_paths` lists exactly the uncommitted
/// paths (staged + tracked-mod + untracked), sorted + deduped, and reuses the
/// SAME status walk as `worktree_freshness` — so its emptiness tracks the
/// `dirty` bit. A clean tree lists nothing; each seeded change surfaces by its
/// repo-relative path. If the sweep's porcelain reader ever stops seeing an
/// untracked file or a tracked edit, this fails.
#[test]
fn changed_paths_lists_uncommitted_and_tracks_dirty() {
let (_td, root) = fixture();
// Clean tree ⇒ no changed paths, and consistent with the freshness bit.
let clean = worktree_changed_paths(&root).expect("changed paths clean");
assert!(clean.is_empty(), "a freshly committed tree lists no changed paths");
assert!(!worktree_freshness(&root).expect("fresh").dirty);
// Tracked modification + a brand-new untracked file both surface.
std::fs::write(root.join("README.md"), b"edited
").expect("modify tracked");
std::fs::write(root.join("NEW.txt"), b"brand new
").expect("add untracked");
let dirty = worktree_changed_paths(&root).expect("changed paths dirty");
assert!(dirty.contains(&"README.md".to_string()), "tracked edit surfaces: {dirty:?}");
assert!(dirty.contains(&"NEW.txt".to_string()), "untracked file surfaces: {dirty:?}");
assert!(worktree_freshness(&root).expect("dirty").dirty, "dirty bit agrees");
// Sorted + deduped, and consistent across reads.
let mut sorted = dirty.clone();
sorted.sort();
assert_eq!(dirty, sorted, "paths come back sorted");
assert_eq!(dirty, worktree_changed_paths(&root).expect("stable"), "stable across reads");
}
/// RED-WHEN-BROKEN: `porcelain_status` carries the PER-PATH state, which is
/// the whole reason it is public — `worktree_changed_paths` flattens it away
/// and `worktree_freshness` hashes it away, so neither can back a `status`
/// verb.
///
/// The assertion is on the state of each path, not merely on its presence: a
/// walk that reported every entry as `Untracked` would pass a
/// contains-the-path check and is exactly the failure this must catch. The
/// returned workdir is asserted too, because the `rela` paths are meaningless
/// without the root they hang off.
#[test]
fn porcelain_status_carries_per_path_state() {
let (_td, root) = fixture();
let (workdir, clean) = porcelain_status(&root).expect("status clean");
assert!(clean.is_empty(), "a freshly committed tree has no entries");
assert_eq!(
workdir.canonicalize().expect("canon workdir"),
root.canonicalize().expect("canon root"),
"the workdir the rela paths hang off"
);
// One tracked edit, one brand-new untracked file — two DIFFERENT states.
std::fs::write(root.join("README.md"), b"edited\n").expect("modify tracked");
std::fs::write(root.join("NEW.txt"), b"brand new\n").expect("add untracked");
let (_workdir, entries) = porcelain_status(&root).expect("status dirty");
let readme = entries
.iter()
.find(|e| e.rela == "README.md")
.unwrap_or_else(|| panic!("README.md missing from {entries:?}"));
let fresh = entries
.iter()
.find(|e| e.rela == "NEW.txt")
.unwrap_or_else(|| panic!("NEW.txt missing from {entries:?}"));
assert_eq!(
readme.kind,
ChangeKind::Modified,
"a tracked edit is Modified, not Untracked: {entries:?}"
);
assert_eq!(
fresh.kind,
ChangeKind::Untracked,
"a new file is Untracked, not Modified: {entries:?}"
);
assert_ne!(readme.kind, fresh.kind, "the two states must differ at all");
// The tag alphabet is load-bearing — it is what the freshness digest
// hashes, so a renamed letter silently invalidates every stored digest.
assert_eq!(ChangeKind::Staged.tag(), 'S');
assert_eq!(ChangeKind::Modified.tag(), 'M');
assert_eq!(ChangeKind::Untracked.tag(), 'U');
assert_eq!(ChangeKind::Renamed.tag(), 'R');
}
/// A clean tree's digest is stable across reopens — two reads of the same
/// untouched checkout agree (so a thin client comparing to the indexed
/// digest never reports a false "stale").
#[test]
fn clean_digest_is_stable_and_matches_sentinel() {
let (_td, root) = fixture();
let a = worktree_freshness(&root).expect("a");
let b = worktree_freshness(&root).expect("b");
assert_eq!(a, b, "two reads of an untouched tree must agree");
assert_eq!(a.digest, CLEAN_WORKTREE_DIGEST);
assert!(!a.dirty);
}
}
#[cfg(test)]
mod tag_tests {
use super::*;
/// A one-file repo with an initial commit; returns the temp dir (kept alive
/// by the caller), the root, and the initial commit SHA.
fn fixture(body: &str) -> (tempfile::TempDir, PathBuf, String) {
let td = tempfile::tempdir().expect("tempdir");
let root = td.path().to_path_buf();
init(&root).expect("git init");
std::fs::write(root.join("data.txt"), body).expect("write");
let sha = commit_all(&root, "initial").expect("commit");
(td, root, sha)
}
/// The release-tagging lifecycle the doctor/publish path relies on: an absent
/// tag reads clean, a lightweight tag resolves to and points at its target,
/// and — the load-bearing invariant — the tag STAYS pinned to its commit when
/// HEAD moves on (so `tag_points_at_head` correctly flips to false).
#[test]
fn tag_resolves_points_at_head_and_stays_pinned_when_head_advances() {
let (_td, root, sha1) = fixture("v1\n");
// Absent tag: no sha, does not point at HEAD, and no panic.
assert_eq!(tag_commit_sha(&root, "v1.0.0").unwrap(), None);
assert!(!tag_points_at_head(&root, "v1.0.0").unwrap());
// Tag the current HEAD.
tag_lightweight(&root, "v1.0.0", &sha1).unwrap();
assert_eq!(tag_commit_sha(&root, "v1.0.0").unwrap().as_deref(), Some(sha1.as_str()));
assert!(tag_points_at_head(&root, "v1.0.0").unwrap(), "tag == HEAD right after tagging");
// Advance HEAD with a second commit; the tag must NOT follow it.
std::fs::write(root.join("data.txt"), "v2\n").unwrap();
let sha2 = commit_all(&root, "second").unwrap();
assert_ne!(sha1, sha2);
assert_eq!(head_sha(&root).unwrap(), sha2, "HEAD advanced");
assert_eq!(
tag_commit_sha(&root, "v1.0.0").unwrap().as_deref(),
Some(sha1.as_str()),
"the tag stays pinned to the commit it was cut from"
);
assert!(
!tag_points_at_head(&root, "v1.0.0").unwrap(),
"tag no longer points at the advanced HEAD"
);
}
/// Re-cutting an existing tag onto a DIFFERENT commit is refused
/// (`PreviousValue::MustNotExist`) and leaves the original tag untouched — a
/// release can't silently re-point a version tag at another commit.
#[test]
fn tag_lightweight_refuses_to_repoint_an_existing_tag_to_another_commit() {
let (_td, root, sha1) = fixture("v1\n");
std::fs::write(root.join("data.txt"), "v2\n").unwrap();
let sha2 = commit_all(&root, "second").unwrap();
assert_ne!(sha1, sha2);
tag_lightweight(&root, "v1.0.0", &sha1).unwrap();
// The dangerous case: re-tag the SAME name at a DIFFERENT commit.
let err = tag_lightweight(&root, "v1.0.0", &sha2).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("tag"),
"clobber attempt fails with a tag-related error: {err:#}"
);
// The original tag must NOT have moved.
assert_eq!(
tag_commit_sha(&root, "v1.0.0").unwrap().as_deref(),
Some(sha1.as_str()),
"the existing tag stays pinned to its original commit"
);
}
/// A malformed target SHA is rejected cleanly rather than creating a dangling
/// or panicking tag.
#[test]
fn tag_lightweight_rejects_a_non_hex_sha() {
let (_td, root, _sha) = fixture("v1\n");
assert!(tag_lightweight(&root, "bad", "not-a-sha").is_err());
}
/// `head_sha_and_branch` agrees with the two single-purpose readers.
#[test]
fn head_sha_and_branch_agrees_with_the_split_readers() {
let (_td, root, sha) = fixture("v1\n");
let (h_sha, h_branch) = head_sha_and_branch(&root).unwrap();
assert_eq!(h_sha, sha);
assert_eq!(h_sha, head_sha(&root).unwrap());
assert_eq!(h_branch, head_branch(&root).unwrap());
assert!(!h_branch.is_empty(), "HEAD resolves to a named branch");
}
/// `set_head_and_checkout` moves HEAD to a named branch at a specific commit
/// AND materialises that commit's tree on disk — proving it honours a pinned
/// branch after a default-HEAD clone (the fat auto-materialize path).
#[test]
fn set_head_and_checkout_repoints_branch_and_reverts_the_worktree() {
let (_td, root, sha1) = fixture("v1\n");
std::fs::write(root.join("data.txt"), "v2\n").unwrap();
let sha2 = commit_all(&root, "second").unwrap();
assert_eq!(std::fs::read_to_string(root.join("data.txt")).unwrap(), "v2\n");
// Repoint onto a NEW branch name at the OLDER commit.
set_head_and_checkout(&root, "release", &sha1).unwrap();
assert_eq!(head_sha(&root).unwrap(), sha1, "HEAD is at the pinned commit");
assert_eq!(head_branch(&root).unwrap(), "release", "HEAD is on the requested branch");
assert_eq!(
std::fs::read_to_string(root.join("data.txt")).unwrap(),
"v1\n",
"the working tree was materialised from the pinned commit"
);
assert_ne!(sha1, sha2);
}
}
// ── SHA-256 object-format regression ───────────────────────────────────────
//
// Codeberg hosts `object-format = sha256` repositories (nordisk's own `tunnr`
// is one). Their oids are 64 hex chars, not 40, and the v2 fetch client MUST
// send `object-format=sha256` in its capability list — a real `git-upload-pack`
// serving a sha256 repo `die()`s on a client that stays silent (it assumes the
// client is sha1) and the connection drops. Through `gix 0.84` gitoxide never
// sent the capability AND `gix-protocol`'s refmap hard-rejected any advertised
// format other than `"sha1"`, so every sha256 fetch surfaced as a bare
// `prepare ssh fetch: early eof`. Upstream fixed both halves in
// GitoxideLabs/gitoxide#2642, released in `gix 0.85`; this crate pins `0.86`.
//
// These tests pin BOTH halves of that behaviour so a gix downgrade — or a
// re-introduction of a hardcoded sha1 assumption — fails here instead of
// silently parking a workspace member in `sync_state = error` forever.
#[cfg(test)]
mod sha256_tests {
use super::*;
/// Init a repo at `root` whose object format is SHA-256, seed one file and
/// commit it. Returns the 64-hex commit sha. Pure-gix — no `git` subprocess.
fn sha256_fixture(root: &Path, body: &str) -> String {
std::fs::create_dir_all(root).unwrap();
gix::ThreadSafeRepository::init(
root,
gix::create::Kind::WithWorktree,
gix::create::Options {
destination_must_be_empty: Some(false),
fs_capabilities: None,
object_hash: Some(gix::hash::Kind::Sha256),
},
)
.unwrap();
std::fs::write(root.join("data.txt"), body).unwrap();
commit_all(root, "sha256 fixture").unwrap()
}
/// The local half: a SHA-256 repo round-trips through this crate's own
/// read/write surface. 64-hex oids must survive `commit_all`, `head_sha`,
/// `tag_lightweight` and `set_head_and_checkout` — the four places that
/// parse or emit an oid and would truncate/reject a 32-byte hash if any of
/// them assumed SHA-1.
#[test]
fn sha256_repo_round_trips_through_the_local_surface() {
let td = tempfile::tempdir().unwrap();
let root = td.path().join("repo");
let sha1st = sha256_fixture(&root, "v1\n");
assert_eq!(sha1st.len(), 64, "a sha256 repo yields 64-hex oids, got `{sha1st}`");
assert!(sha1st.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
gix::open(&root).unwrap().object_hash(),
gix::hash::Kind::Sha256,
"the repo really is sha256 on disk"
);
assert_eq!(head_sha(&root).unwrap(), sha1st, "head_sha reads the 64-hex oid back");
// A second commit, then repoint onto it — exercises ObjectId::from_hex
// and the worktree materialiser with a 32-byte hash.
std::fs::write(root.join("data.txt"), "v2\n").unwrap();
let sha2nd = commit_all(&root, "second").unwrap();
assert_eq!(sha2nd.len(), 64);
tag_lightweight(&root, "v0.1.0", &sha2nd).unwrap();
set_head_and_checkout(&root, "release", &sha1st).unwrap();
assert_eq!(head_sha(&root).unwrap(), sha1st);
assert_eq!(std::fs::read_to_string(root.join("data.txt")).unwrap(), "v1\n");
}
/// The WIRE half — the one that was actually broken. Runs a real protocol-v2
/// fetch against a SHA-256 remote and asserts the pack arrives.
///
/// The fixture *server* is `git-upload-pack`, reached through gix's local
/// (file) transport. That is deliberate and does NOT breach this crate's
/// no-subprocess law: the law binds nornir-git's own code paths, and the
/// code under test here — gix's v2 capability negotiation + pack receive —
/// is byte-identical to what runs over the russh channel in [`ssh_sync`].
/// A real upload-pack is the only thing that will `die()` on a client that
/// fails to declare `object-format=sha256`, which is precisely the
/// regression. Skips (rather than fails) where no upload-pack exists.
#[test]
fn sha256_remote_fetch_negotiates_the_object_format() {
if which_upload_pack().is_none() {
eprintln!("skipping: no `git-upload-pack` on PATH to act as the fixture server");
return;
}
let td = tempfile::tempdir().unwrap();
let remote = td.path().join("remote");
let head = sha256_fixture(&remote, "wire\n");
let dest = td.path().join("clone");
// On gix <= 0.84 this fails: the client never sends
// `object-format=sha256`, upload-pack dies with "mismatched object
// format", and the error surfaces as an early EOF mid-negotiation.
clone_repo(&remote.display().to_string(), &dest, Smudge::Run)
.expect("fetch a sha256 remote — gix must negotiate object-format=sha256");
assert_eq!(
gix::open(&dest).unwrap().object_hash(),
gix::hash::Kind::Sha256,
"the clone adopted the remote's object format instead of defaulting to sha1"
);
assert_eq!(head_sha(&dest).unwrap(), head, "the 64-hex HEAD came across intact");
assert_eq!(std::fs::read_to_string(dest.join("data.txt")).unwrap(), "wire\n");
}
/// `git-upload-pack` on `PATH`, if any. Only ever used to decide whether the
/// wire test above can run — never invoked by this crate.
fn which_upload_pack() -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|p| p.join("git-upload-pack"))
.find(|p| p.is_file())
})
}
}
#[cfg(test)]
mod no_ambient_identity_tests {
//! **A ref edit must work on a machine with no git identity.**
//!
//! gix refuses to write a reflog without a committer, and a repository has
//! one only from ambient config. `git` itself does not behave that way — it
//! falls back to a generic identity for reflogs and refuses only for
//! commits — so a build agent, a container or a fresh appliance with no
//! `~/.gitconfig` made every entry point here fail. `gunnar clone --depth 1`
//! failed exactly so on 2026-09-12, *after* transferring the whole pack.
//!
//! # Why a subprocess
//!
//! The condition under test is the ABSENCE of environment: no `HOME`, no
//! global or system config, no `GIT_COMMITTER_*`. Mutating the environment
//! of a running test binary is process-wide and races every other test in
//! it, so the assertion runs in a child of this same binary with a cleared
//! environment instead — deterministic, and it costs one process.
//!
//! # Why the red is inside the child
//!
//! A green here would be worthless if the child still had an identity from
//! somewhere: it would pass with the fix reverted. So the child FIRST
//! proves a plain `gix::open` + ref edit fails ("reflog messages need a
//! committer"), which is only true when the environment really is bare, and
//! only then proves [`open_for_ref_edits`] succeeds on the same repository.
use super::*;
/// The child. `#[ignore]`, so the ordinary suite does not run it with the
/// parent's environment — [`a_ref_edit_survives_a_machine_with_no_git_identity`]
/// is the only thing that invokes it, and it does so with none
/// ([`run_child_with_no_identity`]).
#[test]
#[ignore = "driven by a_ref_edit_survives_a_machine_with_no_git_identity, in a cleared environment"]
fn inner_ref_edit_with_no_identity() {
let td = tempfile::tempdir().expect("tempdir");
let root = td.path().to_path_buf();
init(&root).expect("git init");
std::fs::write(root.join("data.txt"), "v1\n").expect("write");
let sha = commit_all(&root, "initial").expect("commit");
// RED: the bare open — what these entry points used to do — cannot
// write the reflog here. If this SUCCEEDS, the environment is not bare
// and the green below proves nothing, so it is an assertion and not a
// skip.
let bare = gix::open(&root).expect("open");
let err = set_head(&bare, "refs/heads/release", &sha)
.expect_err("a bare handle must not be able to write a reflog with no identity");
let text = format!("{err:#}");
assert!(
text.contains("committer"),
"the refusal must be the missing committer, or this test is measuring \
something else: {text}"
);
// GREEN: the one opener every ref-editing entry point now uses.
let repo = open_for_ref_edits(&root).expect("open for ref edits");
set_head(&repo, "refs/heads/release", &sha).expect("the fallback identity must let the reflog be written");
// Applied output, not a return value: the ref moved and the reflog is
// on disk with a line in it.
assert_eq!(head_sha(&root).expect("head"), sha);
assert_eq!(head_branch(&root).expect("branch"), "release");
let reflog = root.join(".git/logs/refs/heads/release");
let logged = std::fs::read_to_string(&reflog)
.unwrap_or_else(|e| panic!("no reflog at {}: {e}", reflog.display()));
assert!(
logged.contains(&sha),
"the reflog must record the commit it moved to: {logged:?}"
);
// And the whole public entry point, which is what gunnar calls.
set_head_and_checkout(&root, "second", &sha)
.expect("set_head_and_checkout must work with no ambient identity");
assert_eq!(head_branch(&root).expect("branch"), "second");
}
/// The second child: the RE-FETCH of an existing non-SSH clone. The fetch
/// itself moves `refs/remotes/origin/*` and writes their reflogs, so it
/// needs the identity before any of this crate's own ref edits do — a fresh
/// clone worked and every later poll failed.
#[test]
#[ignore = "driven by a_refetch_survives_a_machine_with_no_git_identity, in a cleared environment"]
fn inner_refetch_with_no_identity() {
let td = tempfile::tempdir().expect("tempdir");
let remote = td.path().join("remote");
init(&remote).expect("git init");
std::fs::write(remote.join("data.txt"), "v1\n").expect("write");
let first = commit_all(&remote, "initial").expect("commit");
let url = remote.to_str().expect("utf-8 path");
let dest = td.path().join("clone");
assert_eq!(clone_or_fetch(url, &dest, None).expect("the first clone"), first);
// The environment really is bare, or the green below proves nothing.
let bare = gix::open(&dest).expect("open");
let err = set_head(&bare, "refs/heads/probe", &first)
.expect_err("a bare handle must not be able to write a reflog with no identity");
assert!(format!("{err:#}").contains("committer"), "not the missing committer: {err:#}");
std::fs::write(remote.join("data.txt"), "v2\n").expect("write");
let second = commit_all(&remote, "second").expect("commit");
// RED before the fix: "reflog messages need a committer which isn't set".
let seen = clone_or_fetch(url, &dest, None)
.expect("a re-fetch must work with no ambient identity");
assert_eq!(seen, second);
assert_eq!(head_sha(&dest).expect("head"), second);
assert_eq!(std::fs::read_to_string(dest.join("data.txt")).expect("read"), "v2\n");
// Applied output: the remote-tracking ref moved and its reflog says so.
let branch = head_branch(&remote).expect("the remote's branch");
let reflog = dest.join(".git/logs/refs/remotes/origin").join(&branch);
let logged = std::fs::read_to_string(&reflog)
.unwrap_or_else(|e| panic!("no reflog at {}: {e}", reflog.display()));
assert!(logged.contains(&second), "the reflog must record the fetched commit: {logged:?}");
}
/// The parent: run the child with **nothing** in its environment.
#[test]
fn a_ref_edit_survives_a_machine_with_no_git_identity() {
run_child_with_no_identity("inner_ref_edit_with_no_identity", "a ref edit");
}
/// The parent of [`inner_refetch_with_no_identity`].
#[test]
fn a_refetch_survives_a_machine_with_no_git_identity() {
run_child_with_no_identity("inner_refetch_with_no_identity", "a re-fetch of an existing clone");
}
/// Run the `#[ignore]`d test `child_fn` of this module in a child of this
/// same binary with a cleared environment, and require that it RAN and passed.
fn run_child_with_no_identity(child_fn: &str, what: &str) {
let exe = std::env::current_exe().expect("this test binary");
let empty = tempfile::tempdir().expect("tempdir");
// Derived, not written out: libtest's name for the child is this
// module's path minus the crate segment, plus the function. Spelling it
// by hand is how the first run of this test filtered everything away
// and reported "0 tests" — which exits 0 and looks like a pass.
let here = module_path!();
let here = here.split_once("::").map_or(here, |(_crate, rest)| rest);
let child = format!("{here}::{child_fn}");
let out = std::process::Command::new(exe)
.args([
"--exact",
&child,
"--ignored",
"--nocapture",
"--test-threads=1",
])
// `env_clear` and then only what a process needs to run at all.
// No HOME, so there is no `~/.gitconfig`; the two GIT_CONFIG_*
// overrides shut the global and system files even if gix finds a
// home another way; no GIT_COMMITTER_* / GIT_AUTHOR_*.
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("TMPDIR", empty.path())
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.output()
.expect("spawn the child test");
assert!(
out.status.success(),
"{what} on a machine with no git identity failed.\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
// The child must actually have RUN, not been filtered away to nothing:
// "0 passed" is the shape a renamed test leaves behind, and it exits 0.
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("1 passed"),
"the child ran no test, so nothing was proven: {stdout}"
);
}
}