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

nordisk/edda

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