Skip to main content

nornir_git/
gitio.rs

1//! Pure-Rust git helpers (via `gix`). The single home for read-side
2//! git inspection so the rest of the crate never shells out to the
3//! `git` binary. No `std::process::Command`, no libgit2/C — matches
4//! nornir's pure-Rust ethos (gix over libgit2, like `ureq` over curl).
5//!
6//! Write/network release operations (commit-on-release, push) live in
7//! nornir's `release::publish`. The write helpers here — [`init`] and
8//! [`commit_all`] — exist only to build *new* repositories from scratch
9//! (test fixtures and the synthetic-repo generator) without ever
10//! shelling out; they use a fixed, deterministic identity so fixtures
11//! are reproducible and need no ambient git config.
12
13use std::path::{Path, PathBuf};
14
15use anyhow::{anyhow, bail, Context, Result};
16
17/// Full 40-char hex SHA of the commit `HEAD` points at.
18///
19/// **gix-first, `git`-fallback.** Some server-cloned repos hit a gix
20/// `Repository::head()` quirk that the `git` binary reads fine (the same reason
21/// `viz::model` already shells out — see its `git-fallback:` logging). gix stays
22/// the fast path; when it errs we fall back to `git rev-parse` so the dependency
23/// Mímir (change-detection / build-order) never dead-ends on one repo.
24pub fn head_sha(root: &Path) -> Result<String> {
25    match gix_head_sha(root) {
26        Ok(sha) => Ok(sha),
27        Err(_) => git_head_sha(root)
28            .with_context(|| format!("read HEAD of {} (gix + git both failed)", root.display())),
29    }
30}
31
32fn gix_head_sha(root: &Path) -> Result<String> {
33    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
34    let id = repo
35        .head()
36        .context("read HEAD")?
37        .id()
38        .ok_or_else(|| anyhow!("HEAD has no commit (unborn?) in {}", root.display()))?;
39    Ok(id.to_string())
40}
41
42/// `git -C <root> rev-parse HEAD` — the fallback when gix can't read HEAD.
43fn git_head_sha(root: &Path) -> Result<String> {
44    let out = std::process::Command::new("git")
45        .arg("-C")
46        .arg(root)
47        .args(["rev-parse", "HEAD"])
48        .output()
49        .with_context(|| format!("spawn `git rev-parse HEAD` in {}", root.display()))?;
50    if !out.status.success() {
51        return Err(anyhow!(
52            "git rev-parse HEAD failed in {}: {}",
53            root.display(),
54            String::from_utf8_lossy(&out.stderr).trim()
55        ));
56    }
57    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
58}
59
60/// Short branch name for `HEAD`, or `"(detached)"` when detached. gix-first,
61/// `git symbolic-ref` fallback (see [`head_sha`]).
62pub fn head_branch(root: &Path) -> Result<String> {
63    if let Ok(b) = gix_head_branch(root) {
64        return Ok(b);
65    }
66    git_head_branch(root)
67}
68
69fn gix_head_branch(root: &Path) -> Result<String> {
70    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
71    Ok(match repo.head_name().context("read HEAD name")? {
72        Some(name) => name.shorten().to_string(),
73        None => "(detached)".to_string(),
74    })
75}
76
77/// `git -C <root> symbolic-ref --short HEAD` — `"(detached)"` when it isn't a branch.
78fn git_head_branch(root: &Path) -> Result<String> {
79    let out = std::process::Command::new("git")
80        .arg("-C")
81        .arg(root)
82        .args(["symbolic-ref", "--short", "HEAD"])
83        .output()
84        .with_context(|| format!("spawn `git symbolic-ref HEAD` in {}", root.display()))?;
85    if !out.status.success() {
86        return Ok("(detached)".to_string());
87    }
88    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
89}
90
91/// `(sha, branch)` for `HEAD`. gix-first, `git`-fallback for each half (see
92/// [`head_sha`]).
93pub fn head_sha_and_branch(root: &Path) -> Result<(String, String)> {
94    Ok((head_sha(root)?, head_branch(root)?))
95}
96
97/// Sentinel digest for a **clean** working tree (no tracked modifications,
98/// nothing staged, no untracked files). A fixed string so clients can test it
99/// without recomputing a hash of the empty set.
100pub const CLEAN_WORKTREE_DIGEST: &str = "clean";
101
102/// A working-tree **freshness** reading: whether the tree is dirty and a stable
103/// content digest of the porcelain status (AUT6 / freshness gate).
104///
105/// The whole point: a commit SHA cannot detect *uncommitted* edits — two
106/// different working trees can sit on the same `HEAD`. The digest does. A thin
107/// client compares its local digest to the indexed one; if they differ the
108/// warehouse is stale relative to the live tree.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct WorktreeFreshness {
111    /// `true` ⇒ tracked modifications, staged changes, or untracked files exist.
112    pub dirty: bool,
113    /// Stable hex digest of the sorted porcelain status. Equals
114    /// [`CLEAN_WORKTREE_DIGEST`] when `dirty == false`.
115    pub digest: String,
116}
117
118/// Compute the working-tree freshness of the repo at `root`, **in-process via
119/// gix** (no shelling out to the `git` binary).
120///
121/// The digest is a SHA-256 over the repo's porcelain status: every changed path
122/// (tracked modification / staged change / tree-vs-index change) plus every
123/// untracked path, each tagged with a one-letter status and — for paths that
124/// exist on disk — their `(size, mtime)`, sorted by path for stability. A clean
125/// tree hashes to the [`CLEAN_WORKTREE_DIGEST`] sentinel (the empty set), so two
126/// clean checkouts of the same commit always agree, and any uncommitted edit —
127/// which the `HEAD` SHA cannot see — flips the digest.
128pub fn worktree_freshness(root: &Path) -> Result<WorktreeFreshness> {
129    use gix::bstr::ByteSlice;
130    use sha2::{Digest, Sha256};
131
132    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
133    let workdir = repo
134        .workdir()
135        .ok_or_else(|| anyhow!("bare repo has no working tree: {}", root.display()))?
136        .to_path_buf();
137
138    // `status` walks: tree↔index (staged), index↔worktree (tracked mods), and
139    // the dirwalk for untracked files. Untracked files are included so an
140    // entirely new, unstaged file marks the tree dirty.
141    let platform = repo
142        .status(gix::progress::Discard)
143        .context("open status platform")?
144        .untracked_files(gix::status::UntrackedFiles::Files);
145    let iter = platform
146        .into_iter(None)
147        .context("start working-tree status iteration")?;
148
149    // (path, "<status-tag> <size>:<mtime_nanos>") lines, sorted for stability.
150    let mut lines: Vec<String> = Vec::new();
151    for item in iter {
152        let item = item.context("read status item")?;
153        let rela = item.location().to_str_lossy().into_owned();
154        let tag = match &item {
155            gix::status::Item::TreeIndex(_) => 'S',          // staged (tree↔index)
156            gix::status::Item::IndexWorktree(iw) => match iw {
157                gix::status::index_worktree::Item::Modification { .. } => 'M',
158                gix::status::index_worktree::Item::DirectoryContents { .. } => 'U', // untracked
159                gix::status::index_worktree::Item::Rewrite { .. } => 'R',
160            },
161        };
162        // Stat the on-disk file (best-effort): content edits move size/mtime even
163        // when the path set is unchanged. Missing/deleted files contribute "-".
164        let stat = match std::fs::metadata(workdir.join(&rela)) {
165            Ok(md) => {
166                let mtime = md
167                    .modified()
168                    .ok()
169                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
170                    .map(|d| d.as_nanos())
171                    .unwrap_or(0);
172                format!("{}:{mtime}", md.len())
173            }
174            Err(_) => "-".to_string(),
175        };
176        lines.push(format!("{tag} {rela} {stat}"));
177    }
178
179    if lines.is_empty() {
180        return Ok(WorktreeFreshness { dirty: false, digest: CLEAN_WORKTREE_DIGEST.to_string() });
181    }
182    lines.sort();
183    let mut hasher = Sha256::new();
184    for l in &lines {
185        hasher.update(l.as_bytes());
186        hasher.update(b"\n");
187    }
188    use std::fmt::Write as _;
189    let mut digest = String::with_capacity(64);
190    for b in hasher.finalize() {
191        let _ = write!(digest, "{b:02x}");
192    }
193    Ok(WorktreeFreshness { dirty: true, digest })
194}
195
196/// Commit SHA the tag `refs/tags/<tag>` resolves to, peeling annotated
197/// tags down to their target commit. `Ok(None)` if the tag is absent.
198pub fn tag_commit_sha(root: &Path, tag: &str) -> Result<Option<String>> {
199    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
200    let full = format!("refs/tags/{tag}");
201    let Some(reference) = repo
202        .try_find_reference(full.as_str())
203        .with_context(|| format!("look up {full}"))?
204    else {
205        return Ok(None);
206    };
207    let id = reference
208        .into_fully_peeled_id()
209        .with_context(|| format!("peel {full}"))?;
210    Ok(Some(id.to_string()))
211}
212
213/// True iff `refs/tags/<tag>` exists and (after peeling) points at the
214/// same commit as `HEAD`.
215pub fn tag_points_at_head(root: &Path, tag: &str) -> Result<bool> {
216    match tag_commit_sha(root, tag)? {
217        Some(tag_sha) => Ok(tag_sha == head_sha(root)?),
218        None => Ok(false),
219    }
220}
221
222/// Fixed identity for fixture/generated commits — deterministic so test
223/// repos hash reproducibly and require no ambient `git` config.
224const FIXTURE_NAME: &str = "Nornir Fixture";
225const FIXTURE_EMAIL: &str = "fixtures@nornir.invalid";
226const FIXTURE_TIME: &str = "1700000000 +0000";
227
228/// Initialise a fresh git repository at `root` (pure-Rust via `gix`).
229/// The directory is created if missing. Used by test fixtures and the
230/// synthetic-repo generator so setup code is also free of `git`
231/// subprocesses.
232pub fn init(root: &Path) -> Result<()> {
233    std::fs::create_dir_all(root).with_context(|| format!("mkdir {}", root.display()))?;
234    gix::init(root).with_context(|| format!("gix init {}", root.display()))?;
235    Ok(())
236}
237
238/// Snapshot the entire working tree (every file except those under
239/// `.git/`) into a new commit on `HEAD`, returning the commit SHA.
240///
241/// Works for the initial commit (unborn `HEAD`, no parent) and for
242/// subsequent commits (parent = current `HEAD`). Because the tree is
243/// rebuilt from the filesystem each call, additions, modifications and
244/// deletions are all captured — a faithful `git add -A && git commit`
245/// for synthetic repos that have no `.gitattributes` content filters.
246pub fn commit_all(root: &Path, message: &str) -> Result<String> {
247    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
248    let empty = gix::ObjectId::empty_tree(repo.object_hash());
249    let mut editor = repo.edit_tree(empty).context("seed empty tree editor")?;
250
251    add_dir_recursive(&repo, &mut editor, root, root)?;
252    let tree = editor.write().context("write fixture tree")?.detach();
253
254    let parents: Vec<gix::ObjectId> = repo.head_commit().ok().map(|c| c.id).into_iter().collect();
255    let sig = gix::actor::SignatureRef {
256        name: gix::bstr::BStr::new(FIXTURE_NAME),
257        email: gix::bstr::BStr::new(FIXTURE_EMAIL),
258        time: FIXTURE_TIME,
259    };
260    let id = repo
261        .commit_as(sig, sig, "HEAD", message, tree, parents)
262        .context("create fixture commit")?;
263
264    // Write the index from the new tree so the freshly-built repo reads
265    // clean (gix::init leaves no index; without this the whole worktree
266    // would look untracked to a subsequent status check).
267    let mut index = repo
268        .index_from_tree(&tree)
269        .context("rebuild index from fixture tree")?;
270    index
271        .write(gix::index::write::Options::default())
272        .context("persist fixture index")?;
273
274    Ok(id.to_string())
275}
276
277/// Create a lightweight tag `refs/tags/<name>` pointing at `target_sha`.
278/// Pure-gix (no shell). Fails if the tag already exists.
279pub fn tag_lightweight(root: &Path, name: &str, target_sha: &str) -> Result<()> {
280    let repo = gix::open(root).with_context(|| format!("gix::open {}", root.display()))?;
281    let target = gix::ObjectId::from_hex(target_sha.as_bytes())
282        .with_context(|| format!("parse target sha `{target_sha}`"))?;
283    repo.tag_reference(name, target, gix::refs::transaction::PreviousValue::MustNotExist)
284        .with_context(|| format!("create tag `{name}` -> {target_sha}"))?;
285    Ok(())
286}
287
288// ── Network (server-monitored poll/fetch) ───────────────────────────────────
289//
290// Pure-Rust git, no `git`/`ssh` subprocess, no C:
291// - HTTPS: gix + rustls (clone/fetch).
292// - SSH:   `russh` runs `git-upload-pack` over an authenticated channel and gix
293//          drives the wire protocol + pack indexing over it (see [`ssh_sync`]).
294//          gix's *own* SSH transport execs `ssh`, which we avoid.
295
296/// `true` for SSH-style remotes (`git@host:…` or `ssh://…`).
297pub fn is_ssh_url(url: &str) -> bool {
298    url.starts_with("ssh://") || (url.contains('@') && !url.contains("://"))
299}
300
301/// Clone `url` into `dest` (full worktree). Pure Rust over HTTPS.
302fn clone_repo(url: &str, dest: &Path) -> Result<()> {
303    std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
304    let mut prepare = gix::prepare_clone(url, dest)
305        .with_context(|| format!("prepare clone {url} → {}", dest.display()))?
306        // Fetch ALL tags — release tags drive the viz timeline's git-history
307        // fallback; gix's default refspec maps only branches.
308        .configure_remote(|remote| {
309            Ok::<_, Box<dyn std::error::Error + Send + Sync>>(
310                remote.with_fetch_tags(gix::remote::fetch::Tags::All),
311            )
312        });
313    let (mut checkout, _) = prepare
314        .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
315        .with_context(|| format!("clone-fetch {url}"))?;
316    checkout
317        .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
318        .with_context(|| format!("checkout worktree for {url}"))?;
319    Ok(())
320}
321
322/// Fetch the default remote of an existing HTTPS clone at `dest`.
323fn fetch_repo(dest: &Path) -> Result<()> {
324    let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
325    let remote = repo
326        .find_default_remote(gix::remote::Direction::Fetch)
327        .ok_or_else(|| anyhow!("{} has no fetch remote", dest.display()))?
328        .context("resolve default remote")?
329        // Pull tags too, so newly-released versions appear in the timeline.
330        .with_fetch_tags(gix::remote::fetch::Tags::All);
331    remote
332        .connect(gix::remote::Direction::Fetch)
333        .context("connect to remote")?
334        .prepare_fetch(gix::progress::Discard, Default::default())
335        .context("prepare fetch")?
336        .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
337        .context("fetch")?;
338    Ok(())
339}
340
341/// Resolve the deploy-key path the server uses for git-over-SSH. Mirrors the
342/// CLI's `nornir key` resolution: honors `NORNIR_SSH_DIR`, else the `nornir`
343/// system user's home (`/home/nornir/.ssh`), else a per-user dev location that
344/// never touches the operator's own `~/.ssh`. Returns the private key path only
345/// if it exists.
346pub fn nornir_ssh_key_path() -> Option<PathBuf> {
347    let dir = if let Some(d) = std::env::var_os("NORNIR_SSH_DIR") {
348        PathBuf::from(d)
349    } else {
350        let sys = Path::new("/home/nornir/.ssh");
351        if sys.exists() {
352            sys.to_path_buf()
353        } else if let Some(home) = std::env::var_os("HOME") {
354            Path::new(&home).join(".nornir/ssh")
355        } else {
356            return None;
357        }
358    };
359    let key = dir.join("id_ed25519");
360    key.exists().then_some(key)
361}
362
363/// Clone-or-fetch `url` into `dest` over SSH with the deploy key at `key_path`,
364/// returning the fetched `HEAD` SHA.
365///
366/// `russh` opens an authenticated `git-upload-pack` channel; gix's blocking git
367/// transport (`ConnectMode::Process`) negotiates and receives the pack over it,
368/// writing objects + `refs/remotes/origin/*` into the repo. We then point HEAD
369/// at the fetched commit and materialize the worktree from its tree.
370fn ssh_sync(url: &str, dest: &Path, key_path: &Path) -> Result<String> {
371    use gix::protocol::transport::client::git::blocking_io::Connection as GitConnection;
372    use gix::protocol::transport::client::git::ConnectMode;
373    use gix::protocol::transport::Protocol;
374
375    let loc = crate::ssh::parse_ssh_url(url)?;
376
377    // 1. Cheap ref advertisement: HEAD's SHA + the branch it points at.
378    let refs = crate::ssh::ls_remote_blocking(url, key_path)
379        .with_context(|| format!("ssh ls-remote {url}"))?;
380    let head_sha = refs
381        .iter()
382        .find(|(_, name)| name == "HEAD")
383        .map(|(sha, _)| sha.clone())
384        .ok_or_else(|| anyhow!("remote {url} advertised no HEAD"))?;
385    // GitHub advertises HEAD's branch too; match by SHA to learn its name.
386    let branch = refs
387        .iter()
388        .find(|(sha, name)| sha == &head_sha && name.starts_with("refs/heads/"))
389        .map(|(_, name)| name.clone())
390        .unwrap_or_else(|| "refs/heads/main".to_string());
391
392    // Cheap change-detection (ls-remote's purpose): if the worktree is already at
393    // this commit, there's nothing to fetch — the common steady-state poll.
394    if dest.join(".git").exists()
395        && crate::gitio::head_sha(dest).ok().as_deref() == Some(head_sha.as_str())
396    {
397        return Ok(head_sha);
398    }
399    eprintln!("nornir-ssh: {url} HEAD={head_sha} changed; fetching pack…");
400
401    // 2. Repo: open if present, else init a fresh non-bare one.
402    let mut repo = if dest.join(".git").exists() {
403        gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?
404    } else {
405        std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
406        gix::init(dest).with_context(|| format!("gix::init {}", dest.display()))?
407    };
408    // A freshly init'd repo has no user identity; gix needs a committer to write
409    // reflogs for the fetched ref updates (and our HEAD update below). Inject the
410    // generic in-memory fallback so those succeed without ambient git config.
411    let _ = repo.committer_or_set_generic_fallback();
412
413    // 3. Fetch the pack over the russh channel. gix's blocking fetch runs on
414    //    THIS thread; the upload-pack runtime's workers handle the socket I/O.
415    let mut up = crate::ssh::connect_upload_pack(&loc, key_path)
416        .with_context(|| format!("ssh upload-pack {url}"))?;
417    let transport = GitConnection::new(
418        &mut up.reader,
419        &mut up.writer,
420        Protocol::V2,
421        loc.path.clone(),
422        Option::<(String, Option<u16>)>::None,
423        ConnectMode::Process,
424        false,
425    );
426    let remote = repo
427        .remote_at(url)
428        .context("build in-memory remote")?
429        .with_refspecs(
430            [
431                "+refs/heads/*:refs/remotes/origin/*",
432                // Tags too — release tags drive the viz git-history fallback.
433                "+refs/tags/*:refs/tags/*",
434            ],
435            gix::remote::Direction::Fetch,
436        )
437        .context("set fetch refspecs")?;
438    remote
439        .to_connection_with_transport(transport)
440        .prepare_fetch(gix::progress::Discard, Default::default())
441        .context("prepare ssh fetch")?
442        .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
443        .context("ssh fetch (pack transfer)")?;
444    drop(up); // close the ssh channel before touching the worktree
445
446    // 4. Point HEAD at the fetched commit and materialize the worktree.
447    set_head(&repo, &branch, &head_sha)?;
448    materialize_worktree(&repo, &head_sha, dest)
449        .with_context(|| format!("checkout {head_sha} into {}", dest.display()))?;
450    eprintln!("nornir-ssh: {url} synced {head_sha} → {}", dest.display());
451    Ok(head_sha)
452}
453
454/// Point an existing clone at `branch` (→ commit `sha`) and materialize its
455/// worktree from that commit. Pure-gix (no shell-out): updates `refs/heads/
456/// <branch>` + `HEAD`, then writes the tree onto disk. `branch` is the SHORT
457/// name (e.g. `main`); `sha` its peeled commit. Used by the fat auto-materialize
458/// path to honor a member's pinned `branch` after a default-HEAD clone.
459pub fn set_head_and_checkout(dest: &Path, branch: &str, sha: &str) -> Result<()> {
460    let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
461    let full = format!("refs/heads/{branch}");
462    set_head(&repo, &full, sha)?;
463    materialize_worktree(&repo, sha, dest)
464        .with_context(|| format!("checkout {sha} into {}", dest.display()))
465}
466
467/// Create/update `branch` → `sha` (a direct ref) and point `HEAD` at `branch`.
468fn set_head(repo: &gix::Repository, branch: &str, sha: &str) -> Result<()> {
469    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
470    use gix::refs::{FullName, Target};
471
472    let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
473    let branch_name: FullName = branch
474        .try_into()
475        .map_err(|e| anyhow!("invalid branch ref `{branch}`: {e}"))?;
476    let log = LogChange {
477        mode: RefLog::AndReference,
478        force_create_reflog: false,
479        message: "nornir: ssh fetch".into(),
480    };
481    let edits = vec![
482        RefEdit {
483            change: Change::Update {
484                log: log.clone(),
485                expected: PreviousValue::Any,
486                new: Target::Object(oid),
487            },
488            name: branch_name.clone(),
489            deref: false,
490        },
491        RefEdit {
492            change: Change::Update {
493                log,
494                expected: PreviousValue::Any,
495                new: Target::Symbolic(branch_name),
496            },
497            name: "HEAD".try_into().expect("HEAD is a valid ref name"),
498            deref: false,
499        },
500    ];
501    repo.edit_references(edits).context("set HEAD + branch")?;
502    Ok(())
503}
504
505/// Materialize the worktree at `dest` from the tree of commit `sha`, using only
506/// public gix APIs (no `gix-worktree-state`): clear everything but `.git`, then
507/// write each index blob. Handles regular/executable files and symlinks; skips
508/// submodule gitlinks. Sufficient for the read-only scans the monitor runs.
509fn materialize_worktree(repo: &gix::Repository, sha: &str, dest: &Path) -> Result<()> {
510    use gix::index::entry::Mode;
511
512    let oid = gix::ObjectId::from_hex(sha.as_bytes()).with_context(|| format!("parse sha {sha}"))?;
513    let tree_id = repo
514        .find_commit(oid)
515        .with_context(|| format!("find commit {sha}"))?
516        .tree_id()
517        .context("commit tree")?
518        .detach();
519    let index = repo
520        .index_from_tree(&tree_id)
521        .with_context(|| format!("index from tree {tree_id}"))?;
522
523    // Clear the worktree (keep `.git`) so deletions upstream are reflected.
524    for entry in std::fs::read_dir(dest).with_context(|| format!("read {}", dest.display()))? {
525        let path = entry?.path();
526        if path.file_name().map(|n| n == ".git").unwrap_or(false) {
527            continue;
528        }
529        if path.is_dir() {
530            std::fs::remove_dir_all(&path).ok();
531        } else {
532            std::fs::remove_file(&path).ok();
533        }
534    }
535
536    for entry in index.entries() {
537        if entry.mode == Mode::COMMIT {
538            continue; // submodule gitlink — no blob to write
539        }
540        let rel = gix::path::from_bstr(entry.path(&index));
541        let full = dest.join(rel.as_ref());
542        if let Some(parent) = full.parent() {
543            std::fs::create_dir_all(parent)
544                .with_context(|| format!("mkdir {}", parent.display()))?;
545        }
546        let blob = repo
547            .find_object(entry.id)
548            .with_context(|| format!("blob {} for {}", entry.id, full.display()))?;
549
550        if entry.mode == Mode::SYMLINK {
551            #[cfg(unix)]
552            {
553                use std::os::unix::ffi::OsStrExt;
554                let target = std::ffi::OsStr::from_bytes(&blob.data);
555                std::fs::remove_file(&full).ok();
556                std::os::unix::fs::symlink(target, &full)
557                    .with_context(|| format!("symlink {}", full.display()))?;
558            }
559            #[cfg(not(unix))]
560            std::fs::write(&full, &blob.data).with_context(|| format!("write {}", full.display()))?;
561        } else {
562            std::fs::write(&full, &blob.data).with_context(|| format!("write {}", full.display()))?;
563            #[cfg(unix)]
564            if entry.mode == Mode::FILE_EXECUTABLE {
565                use std::os::unix::fs::PermissionsExt;
566                std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).ok();
567            }
568        }
569    }
570    Ok(())
571}
572
573/// Ensure `dest` is an up-to-date clone of `url`: clone if absent, else fetch.
574/// Returns the resulting `HEAD` SHA. SSH remotes use the deploy key at
575/// `ssh_key` (resolved via [`nornir_ssh_key_path`] when `None`).
576pub fn clone_or_fetch(url: &str, dest: &Path, ssh_key: Option<&Path>) -> Result<String> {
577    // nornir fetches over SSH ONLY — the https transport is unsupported. Coerce
578    // any `http(s)://host/owner/repo` remote to its scp-like SSH form
579    // (`git@host:owner/repo.git`) up front, so a deploy key drives every fetch (no
580    // credential prompt, no broken-https path). Non-http remotes (local paths,
581    // already-SSH URLs) are returned unchanged by `https_to_ssh`, so they pass
582    // through. This makes "ssh always" hold regardless of what a descriptor wrote.
583    let coerced = https_to_ssh(url);
584    let url: &str = coerced.as_deref().unwrap_or(url);
585    if is_ssh_url(url) {
586        let resolved;
587        let key = match ssh_key {
588            Some(k) => k,
589            None => {
590                resolved = nornir_ssh_key_path().ok_or_else(|| {
591                    anyhow!(
592                        "SSH remote `{url}` needs a deploy key, but none was found \
593                         (set NORNIR_SSH_DIR or install the service so the key lives \
594                         at /home/nornir/.ssh/id_ed25519 — see `nornir key show`)"
595                    )
596                })?;
597                resolved.as_path()
598            }
599        };
600        return ssh_sync(url, dest, key);
601    }
602    // SSH-FIRST: an `https://` remote prompts for credentials on a private repo,
603    // which is fatal headless (the server has no tty). Most of our remotes ARE
604    // private and reachable via the deploy key, so for a FRESH clone try the
605    // scp-like SSH form first and fall back to the original https URL only if
606    // SSH fails (e.g. a public host we hold no key for). Existing checkouts just
607    // fetch through their stored remote.
608    if !dest.join(".git").exists() {
609        let key = ssh_key.map(|k| k.to_path_buf()).or_else(nornir_ssh_key_path);
610        if let (Some(ssh_url), Some(key)) = (https_to_ssh(url), key.as_ref()) {
611            match ssh_sync(&ssh_url, dest, key) {
612                Ok(sha) => return Ok(sha),
613                Err(e) => {
614                    eprintln!(
615                        "nornir-gitio: SSH-first clone of `{ssh_url}` failed ({e:#}); \
616                         falling back to `{url}`"
617                    );
618                    // ssh_sync may have left a partial dir — clear it so the https
619                    // clone starts clean.
620                    if !dest.join(".git").exists() {
621                        let _ = std::fs::remove_dir_all(dest);
622                    }
623                }
624            }
625        }
626    }
627    if dest.join(".git").exists() {
628        fetch_repo(dest)?;
629    } else {
630        clone_repo(url, dest)?;
631    }
632    head_sha(dest)
633}
634
635/// Outcome of a [`push_to_local_remote`].
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub struct LocalPushReport {
638    /// The commit SHA the remote branch now points at (the pushed head).
639    pub sha: String,
640    /// The branch that was updated on the remote.
641    pub branch: String,
642    /// `true` when the remote branch did not exist before (a create, not an update).
643    pub created: bool,
644}
645
646/// **Fast-forward PUSH to a LOCAL/file git remote, pure-Rust (no send-pack).**
647///
648/// gix (0.84) implements fetch but not the send-pack side of the git wire
649/// protocol, so an over-the-network push is impossible without a `git`
650/// subprocess or libgit2 (both forbidden). For a LOCAL/file remote we don't need
651/// the wire protocol at all: the object database is content-addressed loose/pack
652/// files on a filesystem we can already read. So we (1) union-copy `src`'s
653/// `.git/objects` into `remote`'s (idempotent — identical content hashes), then
654/// (2) fast-forward `remote`'s `refs/heads/<branch>` to `src`'s HEAD. The ref is
655/// updated LAST (after all objects land), so a failed copy never leaves a ref
656/// pointing at absent objects — atomic-ish.
657///
658/// FAIL-CLEAN on non-fast-forward: if the remote branch already exists and its
659/// commit is NOT an ancestor of `src`'s HEAD (the remote advanced independently),
660/// we return an error and touch nothing — never clobber. Over-the-network
661/// remotes (`ssh://` / `git@…` / `http(s)://`) return an error (unsupported).
662pub fn push_to_local_remote(src: &Path, remote: &str) -> Result<LocalPushReport> {
663    // Only local/file remotes: no send-pack for network transports.
664    let remote = remote.strip_prefix("file://").unwrap_or(remote);
665    if is_ssh_url(remote) || remote.starts_with("http://") || remote.starts_with("https://") {
666        bail!(
667            "in-process push is only supported for LOCAL/file git remotes — gix (0.84) has no \
668             send-pack for `{remote}`. Push the warehouse mirror out-of-band, or use a local remote."
669        );
670    }
671    let remote_path = Path::new(remote);
672    if !remote_path.exists() {
673        bail!("remote warehouse path `{remote}` does not exist (open the remote warehouse first)");
674    }
675
676    let src_repo = gix::open(src).with_context(|| format!("gix::open src {}", src.display()))?;
677    let mut remote_repo =
678        gix::open(remote_path).with_context(|| format!("gix::open remote {remote}"))?;
679    // The remote warehouse repo has no ambient git identity; give it a generic
680    // committer fallback so appending to an existing reflog on the ref update
681    // below doesn't fail ("reflog messages need a committer"). Same call
682    // `set_head_and_checkout` / gitclean make before their ref edits.
683    let _ = remote_repo.committer_or_set_generic_fallback();
684
685    let branch = head_branch(src)?;
686    let new_sha = head_sha(src)?;
687    let new_oid =
688        gix::ObjectId::from_hex(new_sha.as_bytes()).with_context(|| format!("parse {new_sha}"))?;
689    let full = format!("refs/heads/{branch}");
690
691    // Current remote head for this branch (None = the branch is new).
692    let old_oid: Option<gix::ObjectId> = remote_repo
693        .try_find_reference(full.as_str())
694        .with_context(|| format!("look up {full} on remote"))?
695        .map(|r| r.into_fully_peeled_id().map(|id| id.detach()))
696        .transpose()
697        .with_context(|| format!("peel {full} on remote"))?;
698
699    // Fast-forward guard: the remote head must be an ancestor of what we push.
700    if let Some(old) = old_oid {
701        if old != new_oid && !is_ancestor(&src_repo, old, new_oid) {
702            bail!(
703                "non-fast-forward push to `{remote}`: remote `{branch}` is at {old} which is not an \
704                 ancestor of {new_sha} — the remote advanced. Re-open (fetch) the remote warehouse \
705                 to reconcile before pushing; refusing to clobber."
706            );
707        }
708    }
709
710    // (1) Transfer objects: union-copy src's object DB into the remote's.
711    copy_objects_union(&src.join(".git/objects"), &remote_path.join(".git/objects"))
712        .context("transfer git objects to remote")?;
713
714    // (2) Fast-forward the remote branch ref (guarded on the observed previous
715    // value for atomicity — a concurrent change fails the transaction).
716    update_remote_branch(&remote_repo, &full, new_oid, old_oid)
717        .with_context(|| format!("fast-forward {full} on remote"))?;
718
719    Ok(LocalPushReport { sha: new_sha, branch, created: old_oid.is_none() })
720}
721
722/// Is `ancestor` reachable from `tip` (i.e. an ancestor of, or equal to, `tip`)
723/// within `repo`? Walks `tip`'s first-parent-and-merges history. A missing
724/// `ancestor` object (remote commit the src never saw) yields `false` → the
725/// caller treats it as a non-fast-forward.
726fn is_ancestor(repo: &gix::Repository, ancestor: gix::ObjectId, tip: gix::ObjectId) -> bool {
727    if ancestor == tip {
728        return true;
729    }
730    let Ok(walk) = repo.rev_walk(Some(tip)).all() else {
731        return false;
732    };
733    for info in walk {
734        match info {
735            Ok(info) if info.id == ancestor => return true,
736            Ok(_) => {}
737            Err(_) => return false,
738        }
739    }
740    false
741}
742
743/// Update (or create) a direct branch ref on `repo` to `new`, guarding on the
744/// previously-observed value `prev` (`None` ⇒ must-not-exist create) so a
745/// concurrent writer can't be silently clobbered.
746fn update_remote_branch(
747    repo: &gix::Repository,
748    full: &str,
749    new: gix::ObjectId,
750    prev: Option<gix::ObjectId>,
751) -> Result<()> {
752    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
753    use gix::refs::{FullName, Target};
754
755    let name: FullName = full.try_into().map_err(|e| anyhow!("invalid ref `{full}`: {e}"))?;
756    let expected = match prev {
757        Some(old) => PreviousValue::MustExistAndMatch(Target::Object(old)),
758        None => PreviousValue::MustNotExist,
759    };
760    let edit = RefEdit {
761        change: Change::Update {
762            log: LogChange {
763                mode: RefLog::AndReference,
764                force_create_reflog: false,
765                message: "nornir: warehouse republish (push)".into(),
766            },
767            expected,
768            new: Target::Object(new),
769        },
770        name,
771        deref: false,
772    };
773    repo.edit_references(vec![edit]).context("edit remote branch ref")?;
774    Ok(())
775}
776
777/// Recursively copy every object file under `src_objects` into `dst_objects`,
778/// skipping any that already exist (git objects are content-addressed, so an
779/// existing file is byte-identical). Creates parent dirs as needed. Skips the
780/// transient `objects/info` housekeeping dir.
781fn copy_objects_union(src_objects: &Path, dst_objects: &Path) -> Result<()> {
782    let mut stack = vec![src_objects.to_path_buf()];
783    while let Some(dir) = stack.pop() {
784        let Ok(entries) = std::fs::read_dir(&dir) else { continue };
785        for e in entries.flatten() {
786            let p = e.path();
787            let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
788            let rel = p.strip_prefix(src_objects).unwrap_or(&p);
789            if is_dir {
790                // `objects/info` holds no immutable objects — skip it.
791                if rel == Path::new("info") {
792                    continue;
793                }
794                stack.push(p);
795                continue;
796            }
797            let dst = dst_objects.join(rel);
798            if dst.exists() {
799                continue; // content-addressed ⇒ identical, don't recopy
800            }
801            if let Some(parent) = dst.parent() {
802                std::fs::create_dir_all(parent)
803                    .with_context(|| format!("mkdir {}", parent.display()))?;
804            }
805            std::fs::copy(&p, &dst)
806                .with_context(|| format!("copy {} → {}", p.display(), dst.display()))?;
807        }
808    }
809    Ok(())
810}
811
812/// Rewrite an `https://host/owner/repo(.git)` (or `http://`) URL to its scp-like
813/// SSH form `git@host:owner/repo.git`. Returns `None` when `url` isn't a plain
814/// http(s) URL with both a host and a path we can rewrite. Used to prefer SSH
815/// (deploy-key, no credential prompt) over https for private remotes.
816pub fn https_to_ssh(url: &str) -> Option<String> {
817    let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?;
818    let (host, path) = rest.split_once('/')?;
819    let host = host.trim();
820    let path = path.trim().trim_matches('/');
821    if host.is_empty() || path.is_empty() {
822        return None;
823    }
824    let path = path.strip_suffix(".git").unwrap_or(path);
825    Some(format!("git@{host}:{path}.git"))
826}
827
828/// Recursively upsert every file under `dir` into `editor`, keyed by its
829/// path relative to `repo_root`. Skips the `.git` directory.
830fn add_dir_recursive(
831    repo: &gix::Repository,
832    editor: &mut gix::object::tree::Editor<'_>,
833    repo_root: &Path,
834    dir: &Path,
835) -> Result<()> {
836    use gix::object::tree::EntryKind;
837
838    let mut entries: Vec<_> = std::fs::read_dir(dir)
839        .with_context(|| format!("read_dir {}", dir.display()))?
840        .collect::<std::io::Result<Vec<_>>>()
841        .with_context(|| format!("iterate {}", dir.display()))?;
842    // Deterministic order keeps generated history reproducible.
843    entries.sort_by_key(|e| e.file_name());
844
845    for entry in entries {
846        let path = entry.path();
847        let name = entry.file_name();
848        if name == ".git" {
849            continue;
850        }
851        let meta = std::fs::symlink_metadata(&path)
852            .with_context(|| format!("stat {}", path.display()))?;
853        let ft = meta.file_type();
854        if ft.is_dir() {
855            add_dir_recursive(repo, editor, repo_root, &path)?;
856            continue;
857        }
858        let rela = path
859            .strip_prefix(repo_root)
860            .expect("path is under repo_root");
861        let rela = gix::path::into_bstr(rela).into_owned();
862
863        let (bytes, kind): (Vec<u8>, EntryKind) = if ft.is_symlink() {
864            let target = std::fs::read_link(&path)
865                .with_context(|| format!("readlink {}", path.display()))?;
866            (gix::path::into_bstr(target).into_owned().into(), EntryKind::Link)
867        } else {
868            let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
869            #[cfg(unix)]
870            let kind = {
871                use std::os::unix::fs::PermissionsExt;
872                if meta.permissions().mode() & 0o111 != 0 {
873                    EntryKind::BlobExecutable
874                } else {
875                    EntryKind::Blob
876                }
877            };
878            #[cfg(not(unix))]
879            let kind = EntryKind::Blob;
880            (bytes, kind)
881        };
882
883        let blob = repo.write_blob(&bytes).context("write blob")?;
884        editor
885            .upsert(rela.as_ref() as &gix::bstr::BStr, kind, blob.detach())
886            .with_context(|| format!("tree upsert {rela}"))?;
887    }
888    Ok(())
889}
890
891#[cfg(test)]
892mod ssh_first_tests {
893    use super::*;
894
895    /// LAW (inject-assert): feed real https URLs and assert the EXACT scp-like
896    /// SSH form, the host/owner/repo round-trip, idempotent `.git`, trailing-slash
897    /// trim — and that the rewritten URL is recognised as SSH by `is_ssh_url`.
898    #[test]
899    fn https_rewrites_to_scp_like_ssh_and_is_recognised() {
900        let cases = [
901            ("https://codeberg.org/nordisk/korp", "git@codeberg.org:nordisk/korp.git"),
902            ("https://codeberg.org/nordisk/ordning.git", "git@codeberg.org:nordisk/ordning.git"),
903            ("https://codeberg.org/nordisk/knut/", "git@codeberg.org:nordisk/knut.git"),
904            ("http://github.com/rustsec/advisory-db", "git@github.com:rustsec/advisory-db.git"),
905        ];
906        for (https, want) in cases {
907            let got = https_to_ssh(https).unwrap_or_else(|| panic!("no rewrite for {https}"));
908            assert_eq!(got, want, "rewrite of {https}");
909            assert!(is_ssh_url(&got), "rewritten `{got}` must be SSH-shaped");
910        }
911    }
912
913    /// Non-rewritable inputs return None (so `clone_or_fetch` leaves them alone):
914    /// already-SSH, scheme-less, and host-only URLs.
915    #[test]
916    fn non_https_or_pathless_urls_do_not_rewrite() {
917        for url in [
918            "git@codeberg.org:nordisk/korp.git", // already SSH
919            "ssh://git@host/owner/repo.git",     // already SSH scheme
920            "https://codeberg.org",              // host only, no path
921            "https://codeberg.org/",             // empty path
922            "/local/path/repo",                  // not a url
923        ] {
924            assert!(https_to_ssh(url).is_none(), "{url} must not rewrite");
925        }
926    }
927
928    /// The private-sibling clone base is SSH by default (no https credential
929    /// prompt), and `<base>/<repo>` is a valid SSH remote.
930    #[test]
931    fn dep_clone_base_default_is_ssh() {
932        // Only assert the default when the env override is absent.
933        if std::env::var("NORNIR_DEP_CLONE_BASE").is_err() {
934            // The dep-clone base default lives in nornir's `security` module; the
935            // standalone crate hard-codes the known default so the test stays
936            // dependency-free (nornir asserts the real value on its side).
937            let base = "git@codeberg.org:nordisk";
938            assert!(is_ssh_url(&format!("{base}/nornir-orch")), "`{base}/repo` must be SSH");
939        }
940    }
941}
942
943#[cfg(test)]
944mod push_tests {
945    use super::*;
946
947    fn seed(root: &Path, body: &str) -> String {
948        init(root).unwrap();
949        std::fs::write(root.join("data.txt"), body).unwrap();
950        commit_all(root, "seed").unwrap()
951    }
952
953    /// Happy path: clone a local remote, add a commit on the clone, push it back
954    /// over the pure-Rust local transport — the remote branch fast-forwards and a
955    /// fresh clone sees the new file.
956    #[test]
957    fn push_local_fast_forwards_and_a_fresh_clone_sees_it() {
958        let td = tempfile::tempdir().unwrap();
959        let remote = td.path().join("remote");
960        seed(&remote, "v1\n");
961
962        // Clone it, then add a new commit on the clone.
963        let src = td.path().join("clone");
964        clone_or_fetch(remote.to_str().unwrap(), &src, None).unwrap();
965        std::fs::write(src.join("data.txt"), "v2\n").unwrap();
966        let new_sha = commit_all(&src, "v2").unwrap();
967
968        // Push back over the local transport.
969        let report = push_to_local_remote(&src, remote.to_str().unwrap()).unwrap();
970        assert_eq!(report.sha, new_sha, "remote branch now at the pushed commit");
971        assert!(!report.created, "the branch already existed → fast-forward update");
972
973        // The remote branch ref advanced, and a fresh clone reads the new content.
974        assert_eq!(head_sha(&remote).unwrap(), new_sha, "remote HEAD ref fast-forwarded");
975        let fresh = td.path().join("fresh");
976        clone_or_fetch(remote.to_str().unwrap(), &fresh, None).unwrap();
977        assert_eq!(std::fs::read_to_string(fresh.join("data.txt")).unwrap(), "v2\n");
978    }
979
980    /// FAIL-CLEAN on non-fast-forward: the remote advanced independently, so the
981    /// clone's push is refused and the remote is NOT clobbered.
982    #[test]
983    fn push_local_refuses_non_fast_forward() {
984        let td = tempfile::tempdir().unwrap();
985        let remote = td.path().join("remote");
986        seed(&remote, "v1\n");
987
988        let src = td.path().join("clone");
989        clone_or_fetch(remote.to_str().unwrap(), &src, None).unwrap();
990
991        // Diverge: a new commit on the clone AND an independent new commit on the
992        // remote (both children of v1) — a classic non-ff.
993        std::fs::write(src.join("data.txt"), "clone-side\n").unwrap();
994        commit_all(&src, "clone edit").unwrap();
995        std::fs::write(remote.join("data.txt"), "remote-side\n").unwrap();
996        let remote_sha = commit_all(&remote, "remote edit").unwrap();
997
998        let err = push_to_local_remote(&src, remote.to_str().unwrap()).unwrap_err().to_string();
999        assert!(err.contains("non-fast-forward"), "clear non-ff error: {err}");
1000        // The remote was NOT clobbered — still at its own commit.
1001        assert_eq!(head_sha(&remote).unwrap(), remote_sha, "remote ref untouched on non-ff");
1002    }
1003
1004    /// Over-the-network remotes are rejected cleanly (gix has no send-pack).
1005    #[test]
1006    fn push_local_rejects_network_remotes() {
1007        let td = tempfile::tempdir().unwrap();
1008        let src = td.path().join("clone");
1009        seed(&src, "v1\n");
1010        for url in ["https://codeberg.org/nordisk/warehouse.git", "git@codeberg.org:nordisk/warehouse.git"] {
1011            let err = push_to_local_remote(&src, url).unwrap_err().to_string();
1012            assert!(err.contains("LOCAL/file"), "network remote rejected with a clear reason: {err}");
1013        }
1014    }
1015}
1016
1017#[cfg(test)]
1018mod freshness_tests {
1019    use super::*;
1020
1021    /// Build a one-file repo with a committed `README.md`. Returns the temp dir
1022    /// (kept alive by the caller) and the repo root.
1023    fn fixture() -> (tempfile::TempDir, PathBuf) {
1024        let td = tempfile::tempdir().expect("tempdir");
1025        let root = td.path().to_path_buf();
1026        init(&root).expect("git init");
1027        std::fs::write(root.join("README.md"), b"hello\n").expect("write README");
1028        commit_all(&root, "initial").expect("commit");
1029        (td, root)
1030    }
1031
1032    /// LAW 1 (inject-assert): feed a real repo, compute the digest CLEAN; then
1033    /// modify the working tree and assert (a) the digest CHANGES, (b) clean→dirty
1034    /// flips, and (c) the HEAD SHA does NOT move — the whole reason a SHA cannot
1035    /// stand in for a working-tree digest.
1036    #[test]
1037    fn worktree_edit_changes_digest_and_dirty_but_not_head_sha() {
1038        let (_td, root) = fixture();
1039
1040        // Freshly committed → clean, sentinel digest.
1041        let clean = worktree_freshness(&root).expect("freshness clean");
1042        assert!(!clean.dirty, "freshly committed tree must read clean");
1043        assert_eq!(clean.digest, CLEAN_WORKTREE_DIGEST, "clean ⇒ sentinel digest");
1044        let sha_before = head_sha(&root).expect("head sha before");
1045
1046        // Inject a tracked modification (edit the committed file in place).
1047        std::fs::write(root.join("README.md"), b"hello, dirty world\n").expect("modify");
1048        let modified = worktree_freshness(&root).expect("freshness after modify");
1049        assert!(modified.dirty, "an uncommitted edit must read dirty");
1050        assert_ne!(modified.digest, clean.digest, "edit must CHANGE the digest");
1051        assert_ne!(modified.digest, CLEAN_WORKTREE_DIGEST, "dirty ⇒ not the sentinel");
1052
1053        // THE POINT: HEAD did NOT move — the SHA cannot see the uncommitted edit.
1054        let sha_after = head_sha(&root).expect("head sha after");
1055        assert_eq!(
1056            sha_before, sha_after,
1057            "editing the working tree must NOT change HEAD — so the SHA cannot \
1058             detect staleness; only the digest can"
1059        );
1060
1061        // Inject an untracked file → digest changes again, still dirty.
1062        std::fs::write(root.join("NEW.txt"), b"brand new\n").expect("add untracked");
1063        let added = worktree_freshness(&root).expect("freshness after add");
1064        assert!(added.dirty, "an untracked file must read dirty");
1065        assert_ne!(added.digest, modified.digest, "adding a file must CHANGE the digest again");
1066        assert_eq!(head_sha(&root).expect("head sha"), sha_before, "still no HEAD movement");
1067    }
1068
1069    /// A clean tree's digest is stable across reopens — two reads of the same
1070    /// untouched checkout agree (so a thin client comparing to the indexed
1071    /// digest never reports a false "stale").
1072    #[test]
1073    fn clean_digest_is_stable_and_matches_sentinel() {
1074        let (_td, root) = fixture();
1075        let a = worktree_freshness(&root).expect("a");
1076        let b = worktree_freshness(&root).expect("b");
1077        assert_eq!(a, b, "two reads of an untouched tree must agree");
1078        assert_eq!(a.digest, CLEAN_WORKTREE_DIGEST);
1079        assert!(!a.dirty);
1080    }
1081}