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/gitclean.rs

//! # `gitclean` — pure-Rust git-history deep-clean over gix (gitoxide)
//!
//! The pure-Rust analogue of `git filter-repo --invert-paths --path <p>`:
//! remove one or more paths (e.g. a committed `docs/book.pdf`) from **every
//! commit across every ref** of a repository, rewriting history in place — no
//! `git`/`git-filter-repo` subprocess, no Python, no C. Everything runs through
//! `gix` 0.84 plumbing (`gix::open`, ref iteration, commit/tree reading, the
//! `tree-editor` for tree rebuild, `write_object` for new commits, and
//! `edit_references` for the ref repoint).
//!
//! Two modes, both exposed as a reusable library fn so they are unit-testable
//! without the CLI:
//!
//! * [`scan_blobs`] — read-only. Walk all refs + their ancestry, enumerate every
//!   blob and its size, and report the N largest (size, an example path, how many
//!   commits reference it) + the total. `--path <glob>` narrows the report.
//! * [`purge_paths`] — the deep clean. Default is a **dry-run** (report only);
//!   `apply = true` performs the rewrite: back up the original ref SHAs, rewrite
//!   every commit parents-first with the target path(s) stripped from its tree
//!   (subtrees that become empty are dropped by the tree editor), remap parents
//!   through the old→new map, then repoint every branch/lightweight-tag ref to
//!   the rewritten tip and leave the worktree consistent with the new `HEAD`.
//!
//! ## Honest seams
//! * **Annotated tags** (`refs/tags/*` that point at a *tag object*, not a commit
//!   directly) are traversed for reachability but **not** repointed — the tag
//!   object still references the old commit. They are reported in
//!   [`PurgeReport::annotated_tags_skipped`]. Lightweight tags (direct-to-commit)
//!   ARE repointed like branches.
//! * **On-disk GC** — the rewrite makes the old objects unreachable, but the
//!   loose/packed objects still occupy `.git` until a repack+prune. gix 0.84 has
//!   no stable high-level repack API, so `git gc --prune=now` (or
//!   `git reflog expire --expire=now --all && git gc --prune=now`) finalizes the
//!   on-disk reclamation. The **history is rewritten regardless**; only the byte
//!   savings on disk wait for GC. [`PurgeReport::bytes_reclaimed`] is the exact
//!   size of the now-unreachable blobs.
//! * **Reflogs** — old reflog entries still pin the pre-rewrite commits; expiring
//!   them (`git reflog expire --expire=now --all`) is part of the same GC seam.
//! * **Empty commits** are NOT pruned — a commit whose only content was the purged
//!   path is kept (with an empty/parent-equal tree) so the commit **count is
//!   preserved**. (Documented rather than defaulted-on to avoid surprising
//!   history shape changes.)

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use gix::bstr::{BString, ByteSlice};

// ── Reports ──────────────────────────────────────────────────────────────────

/// One blob's aggregate stats in [`ScanReport`].
#[derive(Debug, Clone)]
pub struct BlobStat {
    /// The blob object id (hex via `Display`).
    pub oid: gix::ObjectId,
    /// Uncompressed object size in bytes (from the odb header — no full read).
    pub size: u64,
    /// One repo path this blob is stored at (first seen; illustrative).
    pub example_path: String,
    /// How many reachable commits contain this blob in their tree.
    pub commit_count: usize,
}

/// Read-only scan result from [`scan_blobs`].
#[derive(Debug, Clone)]
pub struct ScanReport {
    /// Distinct blob objects seen across all reachable history (after `--path`).
    pub total_blobs: usize,
    /// Sum of the distinct blobs' sizes (what a full clean of the filter could
    /// reclaim if every matched blob became unreachable).
    pub total_bytes: u64,
    /// The `top_n` largest blobs, descending by size.
    pub largest: Vec<BlobStat>,
    /// Number of reachable commits walked.
    pub commits_scanned: usize,
    /// Number of refs (branch/tag/…) that resolved to a commit.
    pub refs_scanned: usize,
}

/// Options for [`purge_paths`].
#[derive(Debug, Clone)]
pub struct PurgeOptions {
    /// Exact repo paths to strip from ALL history, e.g. `["docs/book.pdf"]`.
    /// Nested paths are supported (the tree editor descends component-by-
    /// component and drops subtrees that become empty).
    pub paths: Vec<String>,
    /// `false` (default) = dry-run: report only, mutate nothing. `true` = perform
    /// the rewrite (after writing the backup).
    pub apply: bool,
}

/// Result of [`purge_paths`] (identical shape for dry-run and apply).
#[derive(Debug, Clone)]
pub struct PurgeReport {
    /// Whether the rewrite was actually performed (`apply` was set and there was
    /// something to do).
    pub applied: bool,
    /// `true` when nothing referenced any target path — a genuine no-op (this is
    /// what a second, idempotent purge of the same path reports).
    pub no_op: bool,
    /// Total reachable commits considered.
    pub commits_total: usize,
    /// Commits whose object changed (tree stripped and/or a parent remapped).
    pub commits_rewritten: usize,
    /// Distinct blob objects that become unreachable (were referenced ONLY via a
    /// purged path).
    pub blobs_dropped: usize,
    /// Sum of the dropped blobs' sizes — the exact on-disk reclamation a
    /// subsequent `git gc --prune=now` will realize.
    pub bytes_reclaimed: u64,
    /// `(ref_name, old_sha, new_sha)` for every ref repointed (apply) or that
    /// would be repointed (dry-run).
    pub refs_updated: Vec<(String, String, String)>,
    /// Annotated-tag refs left untouched (documented seam), by full ref name.
    pub annotated_tags_skipped: Vec<String>,
    /// On apply: the `refs/original/…` backup namespace prefix that now mirrors the
    /// pre-rewrite ref tips.
    pub backup_ref_prefix: Option<String>,
    /// On apply: path to the plain-text backup file recording old ref→sha lines.
    pub backup_file: Option<PathBuf>,
}

// ── Internal traversal ───────────────────────────────────────────────────────

/// A repointable ref (branch / lightweight tag / remote) resolved to its commit.
struct RepointRef {
    name: gix::refs::FullName,
    commit: gix::ObjectId,
}

/// Everything we learn from one pass over the refs.
struct RefsResolved {
    /// Refs that point *directly* at a commit → repointable.
    repointable: Vec<RepointRef>,
    /// Full names of annotated-tag refs (point at a tag object) — a seam.
    annotated_tags: Vec<String>,
    /// The union of commit tips to traverse from (deduped).
    tips: Vec<gix::ObjectId>,
}

/// Resolve every ref to a commit, classifying direct-commit refs (repointable)
/// vs annotated tags (a documented seam). Symbolic refs (e.g. `HEAD`) are skipped
/// — their branch is handled directly.
fn resolve_refs(repo: &gix::Repository) -> Result<RefsResolved> {
    let mut repointable = Vec::new();
    let mut annotated_tags = Vec::new();
    let mut tip_set: HashSet<gix::ObjectId> = HashSet::new();

    let platform = repo.references().context("open ref store")?;
    for r in platform.all().context("iterate refs")? {
        let mut r = match r {
            Ok(r) => r,
            Err(_) => continue,
        };
        // Skip symbolic refs (HEAD → refs/heads/x); the branch itself is visited.
        if matches!(r.target(), gix::refs::TargetRef::Symbolic(_)) {
            continue;
        }
        // Skip our own backup namespace: `refs/original/*` deliberately pins the
        // PRE-rewrite tips for recovery, so it must not count as live history (else
        // a re-run would see the blob "still reachable" and never be a no-op).
        if r.name().as_bstr().starts_with(b"refs/original/") {
            continue;
        }
        let direct = r.id().detach();
        let peeled = match r.peel_to_id() {
            Ok(id) => id.detach(),
            Err(_) => continue, // not peelable to an object we can use
        };
        // Only commits are traversable tips; a ref peeling to a non-commit (e.g. a
        // ref straight to a blob/tree) is ignored for history rewrite.
        if repo.find_commit(peeled).is_err() {
            continue;
        }
        tip_set.insert(peeled);
        let name = r.name().to_owned();
        if direct == peeled {
            repointable.push(RepointRef {
                name,
                commit: peeled,
            });
        } else {
            // direct target is a tag object that peeled to a commit → annotated tag.
            annotated_tags.push(name.as_bstr().to_str_lossy().into_owned());
        }
    }
    Ok(RefsResolved {
        repointable,
        annotated_tags,
        tips: tip_set.into_iter().collect(),
    })
}

/// Collect every commit reachable from `tips` in **parents-first** topological
/// order (a parent always precedes its children), iteratively (no recursion, so
/// deep histories don't overflow the stack).
fn topo_order_parents_first(
    repo: &gix::Repository,
    tips: &[gix::ObjectId],
) -> Result<Vec<gix::ObjectId>> {
    let mut order: Vec<gix::ObjectId> = Vec::new();
    let mut visited: HashSet<gix::ObjectId> = HashSet::new();
    // (oid, children_expanded?) — post-order DFS: emit a node only after all its
    // parents have been emitted.
    let mut stack: Vec<(gix::ObjectId, bool)> = Vec::new();
    for t in tips {
        stack.push((*t, false));
    }
    while let Some((oid, expanded)) = stack.pop() {
        if expanded {
            order.push(oid);
            continue;
        }
        if !visited.insert(oid) {
            continue;
        }
        // Re-push self as "expanded" first so it is emitted AFTER its parents.
        stack.push((oid, true));
        let commit = repo
            .find_commit(oid)
            .with_context(|| format!("find commit {oid}"))?;
        for pid in commit.parent_ids() {
            let pid = pid.detach();
            if !visited.contains(&pid) {
                stack.push((pid, false));
            }
        }
    }
    Ok(order)
}

/// The blobs (as `(path-within-subtree, oid, size)`) at the leaves of the subtree
/// rooted at `tree_oid`, memoized by tree oid. The paths are relative to this
/// subtree, so the value is prefix-independent and safe to cache and reuse under
/// any parent path.
fn subtree_blobs<'a>(
    repo: &gix::Repository,
    tree_oid: gix::ObjectId,
    cache: &'a mut HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>>,
) -> Result<&'a Vec<(BString, gix::ObjectId, u64)>> {
    if !cache.contains_key(&tree_oid) {
        let mut out: Vec<(BString, gix::ObjectId, u64)> = Vec::new();
        let tree = repo
            .find_tree(tree_oid)
            .with_context(|| format!("find tree {tree_oid}"))?;
        // Collect entries first so we don't hold the tree borrow across recursion.
        let mut subdirs: Vec<(BString, gix::ObjectId)> = Vec::new();
        for entry in tree.iter() {
            let entry = entry.context("decode tree entry")?;
            let name = entry.inner.filename.to_owned();
            let oid = entry.inner.oid.to_owned();
            let mode = entry.inner.mode;
            if mode.is_tree() {
                subdirs.push((name, oid));
            } else if mode.is_blob() || mode.is_link() {
                let size = repo.find_header(oid).map(|h| h.size()).unwrap_or(0);
                out.push((name, oid, size));
            }
            // gitlinks (commit entries) reference no blob — skipped.
        }
        for (dname, doid) in subdirs {
            // Recurse (fills cache for the child), then prefix names.
            let child = subtree_blobs(repo, doid, cache)?.clone();
            for (cpath, coid, csize) in child {
                let mut full = dname.clone();
                full.push(b'/');
                full.extend_from_slice(&cpath);
                out.push((full, coid, csize));
            }
        }
        cache.insert(tree_oid, out);
    }
    Ok(cache.get(&tree_oid).expect("just inserted"))
}

// ── scan ─────────────────────────────────────────────────────────────────────

/// Read-only blob scan over ALL reachable history — "what would a clean reclaim".
///
/// Walks every ref's ancestry, enumerates blobs and their sizes, and reports the
/// `top_n` largest (size, an example path, commits referencing it) plus the
/// total. `path_filter` (a simple glob supporting `*`/`?`, `*` spanning `/`)
/// narrows the report to matching repo paths; `None` = everything.
pub fn scan_blobs(repo_root: &Path, path_filter: Option<&str>, top_n: usize) -> Result<ScanReport> {
    let repo =
        gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;
    let refs = resolve_refs(&repo)?;
    let commits = topo_order_parents_first(&repo, &refs.tips)?;

    let mut cache: HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>> = HashMap::new();
    // blob oid → (size, example_path, set-of-commit-indices count)
    let mut agg: HashMap<gix::ObjectId, (u64, String, usize)> = HashMap::new();

    for &c in &commits {
        let commit = repo
            .find_commit(c)
            .with_context(|| format!("find commit {c}"))?;
        let tree_oid = commit.tree_id().context("commit tree id")?.detach();
        let blobs = subtree_blobs(&repo, tree_oid, &mut cache)?.clone();
        // Dedup blob oids within this one commit so commit_count is per-commit.
        let mut seen_here: HashSet<gix::ObjectId> = HashSet::new();
        for (path, oid, size) in blobs {
            let path_str = path.to_str_lossy();
            if let Some(f) = path_filter {
                if !glob_match(f, &path_str) {
                    continue;
                }
            }
            let e = agg
                .entry(oid)
                .or_insert_with(|| (size, path_str.clone().into_owned(), 0));
            if seen_here.insert(oid) {
                e.2 += 1;
            }
        }
    }

    let total_blobs = agg.len();
    let total_bytes: u64 = agg.values().map(|(s, _, _)| *s).sum();
    let mut largest: Vec<BlobStat> = agg
        .into_iter()
        .map(|(oid, (size, example_path, commit_count))| BlobStat {
            oid,
            size,
            example_path,
            commit_count,
        })
        .collect();
    largest.sort_by(|a, b| b.size.cmp(&a.size).then(a.oid.cmp(&b.oid)));
    largest.truncate(top_n);

    Ok(ScanReport {
        total_blobs,
        total_bytes,
        largest,
        commits_scanned: commits.len(),
        refs_scanned: refs.repointable.len() + refs.annotated_tags.len(),
    })
}

// ── purge ────────────────────────────────────────────────────────────────────

/// Deep-clean: strip `opts.paths` from every commit across every ref.
///
/// Dry-run by default (`opts.apply == false`) — computes and returns the exact
/// report (commits to rewrite, blobs/bytes reclaimed, refs to repoint) without
/// mutating anything. With `opts.apply == true` it writes a backup, rewrites the
/// history parents-first, repoints branch/lightweight-tag refs, and reconciles the
/// worktree/index with the new `HEAD`.
///
/// Idempotent: a second purge of the same path finds nothing to strip and reports
/// `no_op = true`, changing no ref.
pub fn purge_paths(repo_root: &Path, opts: &PurgeOptions) -> Result<PurgeReport> {
    if opts.paths.is_empty() {
        bail!("purge: no --path given (nothing to remove)");
    }
    // Normalize: drop leading "./" and "/", reject empty.
    let targets: Vec<String> = opts
        .paths
        .iter()
        .map(|p| {
            p.trim()
                .trim_start_matches("./")
                .trim_start_matches('/')
                .to_string()
        })
        .collect();
    if targets.iter().any(|p| p.is_empty()) {
        bail!("purge: empty path component in --path");
    }
    let target_set: HashSet<&str> = targets.iter().map(|s| s.as_str()).collect();

    let mut repo =
        gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;
    // Ref updates below write reflog entries, which need a committer identity. A
    // freshly-`init`'d repo has none; inject gix's generic in-memory fallback so
    // the repoint/backup ref edits succeed without ambient git config (mirrors
    // `gitio::ssh_sync`). No-op when a real identity is already configured.
    let _ = repo.committer_or_set_generic_fallback();
    let refs = resolve_refs(&repo)?;
    let commits = topo_order_parents_first(&repo, &refs.tips)?;

    // Pass 1 (read-only): per-commit "does its tree hold any target path", plus
    // the blob-reclaim math (a blob is reclaimed iff it appears ONLY at target
    // paths anywhere in history).
    let mut cache: HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>> = HashMap::new();
    let mut tree_hits: HashMap<gix::ObjectId, bool> = HashMap::new(); // commit oid → tree contains a target
    let mut target_blobs: HashMap<gix::ObjectId, u64> = HashMap::new(); // blob → size (seen at a target path)
    let mut kept_blobs: HashSet<gix::ObjectId> = HashSet::new(); // blob seen at a NON-target path

    for &c in &commits {
        let commit = repo
            .find_commit(c)
            .with_context(|| format!("find commit {c}"))?;
        let tree_oid = commit.tree_id().context("commit tree id")?.detach();
        let blobs = subtree_blobs(&repo, tree_oid, &mut cache)?.clone();
        let mut hit = false;
        for (path, oid, size) in blobs {
            let path_str = path.to_str_lossy();
            if target_set.contains(path_str.as_ref()) {
                hit = true;
                target_blobs.insert(oid, size);
            } else {
                kept_blobs.insert(oid);
            }
        }
        tree_hits.insert(c, hit);
    }

    let reclaimed: Vec<(gix::ObjectId, u64)> = target_blobs
        .iter()
        .filter(|(oid, _)| !kept_blobs.contains(*oid))
        .map(|(o, s)| (*o, *s))
        .collect();
    let bytes_reclaimed: u64 = reclaimed.iter().map(|(_, s)| *s).sum();

    // Pass 2: propagate "changed" parents-first. A commit is rewritten if its tree
    // holds a target OR any parent was rewritten (so its parent link moves).
    let mut changed: HashMap<gix::ObjectId, bool> = HashMap::new();
    for &c in &commits {
        let commit = repo.find_commit(c)?;
        let tree_hit = *tree_hits.get(&c).unwrap_or(&false);
        let parent_changed = commit
            .parent_ids()
            .any(|p| *changed.get(&p.detach()).unwrap_or(&false));
        changed.insert(c, tree_hit || parent_changed);
    }
    let commits_rewritten = changed.values().filter(|v| **v).count();

    // Which repointable refs would move (tip is a changed commit).
    let would_move: Vec<&RepointRef> = refs
        .repointable
        .iter()
        .filter(|r| *changed.get(&r.commit).unwrap_or(&false))
        .collect();

    let no_op = commits_rewritten == 0;

    // ── Dry-run: report and stop. ──
    if !opts.apply {
        let refs_updated = would_move
            .iter()
            .map(|r| {
                (
                    r.name.as_bstr().to_str_lossy().into_owned(),
                    r.commit.to_string(),
                    "(dry-run)".to_string(),
                )
            })
            .collect();
        return Ok(PurgeReport {
            applied: false,
            no_op,
            commits_total: commits.len(),
            commits_rewritten,
            blobs_dropped: reclaimed.len(),
            bytes_reclaimed,
            refs_updated,
            annotated_tags_skipped: refs.annotated_tags,
            backup_ref_prefix: None,
            backup_file: None,
        });
    }

    // ── Apply. ──
    if no_op {
        // Nothing references a target path — a true idempotent no-op. Don't write a
        // backup or touch a single ref.
        return Ok(PurgeReport {
            applied: false,
            no_op: true,
            commits_total: commits.len(),
            commits_rewritten: 0,
            blobs_dropped: 0,
            bytes_reclaimed: 0,
            refs_updated: Vec::new(),
            annotated_tags_skipped: refs.annotated_tags,
            backup_ref_prefix: None,
            backup_file: None,
        });
    }

    // 1) Backup FIRST: record old ref SHAs to a file AND mirror every repointable
    //    ref under `refs/original/…` so the pre-rewrite tips stay recoverable.
    let (backup_file, backup_ref_prefix) = write_backup(&repo, &refs.repointable)?;

    // 2) Rewrite every commit parents-first, threading the old→new map.
    let mut map: HashMap<gix::ObjectId, gix::ObjectId> = HashMap::new();
    for &c in &commits {
        let is_changed = *changed.get(&c).unwrap_or(&false);
        if !is_changed {
            map.insert(c, c); // byte-identical → same oid (nothing to write)
            continue;
        }
        let new_oid = rewrite_commit(
            &repo,
            c,
            &targets,
            &map,
            *tree_hits.get(&c).unwrap_or(&false),
        )?;
        map.insert(c, new_oid);
    }

    // 3) Repoint refs (branches + lightweight tags). Annotated tags are a seam.
    let mut refs_updated = Vec::new();
    let mut edits = Vec::new();
    for r in &refs.repointable {
        let new = *map.get(&r.commit).unwrap_or(&r.commit);
        if new == r.commit {
            continue;
        }
        edits.push(update_ref_edit(r.name.clone(), new));
        refs_updated.push((
            r.name.as_bstr().to_str_lossy().into_owned(),
            r.commit.to_string(),
            new.to_string(),
        ));
    }
    if !edits.is_empty() {
        repo.edit_references(edits)
            .context("repoint refs to rewritten tips")?;
    }

    // 4) Reconcile the worktree/index with the new HEAD (non-bare repos only).
    reconcile_worktree(repo_root, &targets)?;

    Ok(PurgeReport {
        applied: true,
        no_op: false,
        commits_total: commits.len(),
        commits_rewritten,
        blobs_dropped: reclaimed.len(),
        bytes_reclaimed,
        refs_updated,
        annotated_tags_skipped: refs.annotated_tags,
        backup_ref_prefix: Some(backup_ref_prefix),
        backup_file: Some(backup_file),
    })
}

/// Rebuild commit `c` with the target paths stripped and parents remapped through
/// `map`, write the new commit object, and return its oid. The new commit drops
/// any `gpgsig` (a rewrite invalidates it — "gpg-less"), but preserves author,
/// committer, message, encoding and all other extra headers.
fn rewrite_commit(
    repo: &gix::Repository,
    c: gix::ObjectId,
    targets: &[String],
    map: &HashMap<gix::ObjectId, gix::ObjectId>,
    tree_hit: bool,
) -> Result<gix::ObjectId> {
    let commit = repo
        .find_commit(c)
        .with_context(|| format!("find commit {c}"))?;
    let old_tree = commit.tree_id().context("commit tree id")?.detach();

    // New tree: strip each target path (no-op if absent — idempotent).
    let new_tree = if tree_hit {
        let mut editor = repo.edit_tree(old_tree).context("open tree editor")?;
        for p in targets {
            editor
                .remove(p.as_str())
                .with_context(|| format!("tree remove {p}"))?;
        }
        editor.write().context("write rewritten tree")?.detach()
    } else {
        old_tree
    };

    // Remap parents.
    let new_parents: Vec<gix::ObjectId> = commit
        .parent_ids()
        .map(|p| *map.get(&p.detach()).unwrap_or(&p.detach()))
        .collect();

    // Extract owned author/committer/message/encoding/extra-headers.
    let decoded = commit.decode().context("decode commit")?;
    let author: gix::actor::Signature = decoded.author().context("decode author")?.into();
    let committer: gix::actor::Signature = decoded.committer().context("decode committer")?.into();
    let message: BString = decoded.message.to_owned();
    let encoding: Option<BString> = decoded.encoding.map(|e| e.to_owned());
    let extra_headers: Vec<(BString, BString)> = decoded
        .extra_headers
        .iter()
        .filter(|(k, _)| k.as_bytes() != b"gpgsig")
        .map(|(k, v)| ((*k).to_owned(), v.as_ref().to_owned()))
        .collect();

    let new_commit = gix::objs::Commit {
        tree: new_tree,
        parents: new_parents.into_iter().collect(),
        author,
        committer,
        encoding,
        message,
        extra_headers,
    };
    let id = repo
        .write_object(&new_commit)
        .context("write rewritten commit")?
        .detach();
    Ok(id)
}

/// Build a force-update `RefEdit` pointing `name` at `new` (with a reflog note).
fn update_ref_edit(
    name: gix::refs::FullName,
    new: gix::ObjectId,
) -> gix::refs::transaction::RefEdit {
    use gix::refs::Target;
    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
    RefEdit {
        change: Change::Update {
            log: LogChange {
                mode: RefLog::AndReference,
                force_create_reflog: false,
                message: "nornir: deep-clean history rewrite".into(),
            },
            expected: PreviousValue::Any,
            new: Target::Object(new),
        },
        name,
        deref: false,
    }
}

/// Write the pre-rewrite backup: a plain-text `old ref → sha` file under `.git/`
/// AND a `refs/original/<full ref>` mirror of every repointable tip. Returns
/// `(file_path, ref_prefix)`.
fn write_backup(repo: &gix::Repository, repointable: &[RepointRef]) -> Result<(PathBuf, String)> {
    use gix::refs::Target;
    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};

    let git_dir = repo.git_dir();
    let stamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let file = git_dir.join(format!("nornir-deepclean-backup-{stamp}.txt"));
    let mut body = String::from("# nornir deep-clean backup — pre-rewrite ref tips\n");
    let mut edits = Vec::new();
    for r in repointable {
        let name = r.name.as_bstr().to_str_lossy();
        body.push_str(&format!("{} {}\n", r.commit, name));
        // refs/original/refs/heads/foo (git-filter-repo convention).
        let backup_name = format!("refs/original/{name}");
        if let Ok(full) = gix::refs::FullName::try_from(backup_name.as_str()) {
            edits.push(RefEdit {
                change: Change::Update {
                    log: LogChange {
                        mode: RefLog::AndReference,
                        force_create_reflog: false,
                        message: "nornir: deep-clean backup".into(),
                    },
                    // Don't clobber a backup from an earlier run.
                    expected: PreviousValue::MustNotExist,
                    new: Target::Object(r.commit),
                },
                name: full,
                deref: false,
            });
        }
    }
    std::fs::write(&file, body).with_context(|| format!("write backup file {}", file.display()))?;
    if !edits.is_empty() {
        // A pre-existing backup ref (earlier partial run) is fine — ignore the
        // clobber error rather than abort the whole clean.
        let _ = repo.edit_references(edits);
    }
    Ok((file, "refs/original/".to_string()))
}

/// Reconcile the worktree + index with the new `HEAD`: delete any purged files
/// still on disk and rebuild the index from the new HEAD tree, so `git status` is
/// clean. Bare repos (no worktree) are a no-op.
fn reconcile_worktree(repo_root: &Path, targets: &[String]) -> Result<()> {
    let repo = gix::open(repo_root).context("reopen for worktree reconcile")?;
    let Some(work_dir) = repo.workdir().map(|p| p.to_path_buf()) else {
        return Ok(()); // bare repo
    };
    for p in targets {
        let f = work_dir.join(p);
        if f.exists() {
            let _ = std::fs::remove_file(&f);
        }
    }
    if let Ok(head) = repo.head_commit() {
        if let Ok(tree) = head.tree_id() {
            let tree = tree.detach();
            if let Ok(mut index) = repo.index_from_tree(&tree) {
                index
                    .write(gix::index::write::Options::default())
                    .context("write reconciled index")?;
            }
        }
    }
    Ok(())
}

// ── tiny glob (no new dep) ───────────────────────────────────────────────────

/// Minimal glob match for `--path` filters: `*` matches any run of chars
/// (including `/`), `?` matches one char, everything else is literal. Enough for
/// `docs/book.pdf`, `*.pdf`, `docs/*`.
fn glob_match(pattern: &str, text: &str) -> bool {
    fn m(p: &[u8], t: &[u8]) -> bool {
        if p.is_empty() {
            return t.is_empty();
        }
        match p[0] {
            b'*' => {
                // Try to consume zero-or-more chars of `t`.
                if m(&p[1..], t) {
                    return true;
                }
                !t.is_empty() && m(p, &t[1..])
            }
            b'?' => !t.is_empty() && m(&p[1..], &t[1..]),
            c => !t.is_empty() && t[0] == c && m(&p[1..], &t[1..]),
        }
    }
    m(pattern.as_bytes(), text.as_bytes())
}

// ── tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    /// Return the verdict so the caller can `assert!` on it. (In nornir the
    /// matrix-row emit lives at the call sites that re-export this crate; the
    /// standalone crate keeps its tests dependency-free.)
    fn emit(check: &str, ok: bool, detail: &str) -> bool {
        let _ = (check, detail);
        ok
    }

    /// Build a throwaway repo with a keeper file, a big blob committed at
    /// `docs/book.pdf` in commit 2, and more commits after. Returns (tempdir,
    /// big-blob-size, big-blob-oid-hex).
    fn make_repo() -> (tempfile::TempDir, u64, String) {
        let td = tempfile::tempdir().expect("tempdir");
        let root = td.path();
        crate::gitio::init(root).expect("init");

        // Commit 1: keeper only.
        std::fs::write(root.join("README.md"), b"# keeper\nhello\n").unwrap();
        crate::gitio::commit_all(root, "c1: readme").unwrap();

        // Commit 2: add a big blob at docs/book.pdf (nested path) + a source file.
        std::fs::create_dir_all(root.join("docs")).unwrap();
        let big = vec![0x42u8; 200_000];
        std::fs::write(root.join("docs/book.pdf"), &big).unwrap();
        std::fs::create_dir_all(root.join("src")).unwrap();
        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
        crate::gitio::commit_all(root, "c2: add book.pdf + src").unwrap();

        // Commit 3: modify keeper, book.pdf still present.
        std::fs::write(root.join("README.md"), b"# keeper\nhello world\n").unwrap();
        crate::gitio::commit_all(root, "c3: edit readme").unwrap();

        // Commit 4: modify src, book.pdf still present.
        std::fs::write(
            root.join("src/main.rs"),
            b"fn main() { println!(\"hi\"); }\n",
        )
        .unwrap();
        crate::gitio::commit_all(root, "c4: edit src").unwrap();

        // Compute the big blob oid the way git would (blob hash of the content).
        let repo = gix::open(root).unwrap();
        let oid = repo.write_blob(&big).unwrap().detach(); // content-addressed → same as committed
        (td, big.len() as u64, oid.to_string())
    }

    /// Walk every ref's ancestry and return the set of (path, blob-oid) tree
    /// entries + the set of all blob oids reachable.
    fn all_entries(root: &Path) -> (HashSet<String>, HashSet<String>) {
        let repo = gix::open(root).unwrap();
        let refs = resolve_refs(&repo).unwrap();
        let commits = topo_order_parents_first(&repo, &refs.tips).unwrap();
        let mut cache = HashMap::new();
        let mut paths = HashSet::new();
        let mut blobs = HashSet::new();
        for c in commits {
            let commit = repo.find_commit(c).unwrap();
            let tree = commit.tree_id().unwrap().detach();
            for (p, oid, _) in super::subtree_blobs(&repo, tree, &mut cache)
                .unwrap()
                .clone()
            {
                paths.insert(p.to_str_lossy().into_owned());
                blobs.insert(oid.to_string());
            }
        }
        (paths, blobs)
    }

    /// Map: commit message → tree entry paths, to assert content survives.
    fn snapshot(root: &Path) -> Vec<(String, Vec<String>)> {
        let repo = gix::open(root).unwrap();
        let refs = resolve_refs(&repo).unwrap();
        let commits = topo_order_parents_first(&repo, &refs.tips).unwrap();
        let mut cache = HashMap::new();
        let mut out = Vec::new();
        for c in commits {
            let commit = repo.find_commit(c).unwrap();
            let msg = commit
                .message_raw()
                .unwrap()
                .to_str_lossy()
                .trim()
                .to_string();
            let tree = commit.tree_id().unwrap().detach();
            let mut paths: Vec<String> = super::subtree_blobs(&repo, tree, &mut cache)
                .unwrap()
                .iter()
                .map(|(p, _, _)| p.to_str_lossy().into_owned())
                .collect();
            paths.sort();
            out.push((msg, paths));
        }
        out
    }

    #[test]
    fn scan_reports_big_blob_as_largest() {
        let (td, size, oid) = make_repo();
        let report = scan_blobs(td.path(), None, 5).unwrap();
        assert!(!report.largest.is_empty(), "scan found no blobs");
        let top = &report.largest[0];
        let ok = top.oid == oid && top.size == size;
        assert!(
            emit(
                "scan_largest_is_book_pdf",
                ok,
                &format!(
                    "top oid={} size={} example={}",
                    top.oid, top.size, top.example_path
                )
            ),
            "expected big blob {oid} ({size}B) as largest, got {} ({}B)",
            top.oid,
            top.size
        );
        // The big blob is referenced by commits 2,3,4 → 3 commits.
        assert_eq!(top.commit_count, 3, "book.pdf should be in 3 commits");
        assert!(top.example_path.ends_with("book.pdf"));
    }

    #[test]
    fn scan_path_filter_narrows() {
        let (td, _size, _oid) = make_repo();
        let all = scan_blobs(td.path(), None, 100).unwrap();
        let pdf = scan_blobs(td.path(), Some("docs/book.pdf"), 100).unwrap();
        assert!(
            all.total_blobs > pdf.total_blobs,
            "filter should shrink the set"
        );
        assert_eq!(pdf.total_blobs, 1, "only book.pdf matches");
    }

    #[test]
    fn dry_run_mutates_nothing_and_reports() {
        let (td, size, _oid) = make_repo();
        let before = all_entries(td.path());
        let rep = purge_paths(
            td.path(),
            &PurgeOptions {
                paths: vec!["docs/book.pdf".into()],
                apply: false,
            },
        )
        .unwrap();
        let after = all_entries(td.path());
        assert_eq!(before.0, after.0, "dry-run must not change history");
        assert!(!rep.applied && !rep.no_op);
        assert_eq!(
            rep.bytes_reclaimed, size,
            "dry-run bytes must equal blob size"
        );
        assert_eq!(rep.blobs_dropped, 1);
        assert!(rep.commits_rewritten >= 3, "commits 2..4 hold the blob");
    }

    #[test]
    fn purge_removes_blob_from_all_history() {
        let (td, size, oid) = make_repo();
        let before = snapshot(td.path());
        let commit_count_before = before.len();

        let rep = purge_paths(
            td.path(),
            &PurgeOptions {
                paths: vec!["docs/book.pdf".into()],
                apply: true,
            },
        )
        .unwrap();
        assert!(rep.applied, "apply should perform the rewrite");

        // (a) blob gone from ALL reachable trees; no tree entry named book.pdf.
        let (paths, blobs) = all_entries(td.path());
        let no_pdf_path = !paths.iter().any(|p| p.ends_with("book.pdf"));
        let blob_unreachable = !blobs.contains(&oid);
        assert!(
            emit(
                "blob_purged",
                no_pdf_path && blob_unreachable,
                &format!(
                    "paths_with_pdf={} blob_present={}",
                    !no_pdf_path, !blob_unreachable
                )
            ),
            "book.pdf still reachable: path_gone={no_pdf_path} blob_gone={blob_unreachable}"
        );

        // (b) keeper + src survive byte-identical, commit count + messages preserved.
        let after = snapshot(td.path());
        assert_eq!(
            after.len(),
            commit_count_before,
            "commit count must be preserved"
        );
        let messages_before: Vec<&String> = before.iter().map(|(m, _)| m).collect();
        let messages_after: Vec<&String> = after.iter().map(|(m, _)| m).collect();
        assert_eq!(messages_before, messages_after, "messages/order preserved");
        // README.md + src/main.rs present in every commit that had them; docs/book.pdf gone everywhere.
        let keeper_ok = after
            .iter()
            .all(|(_, ps)| !ps.iter().any(|p| p.ends_with("book.pdf")))
            && after
                .iter()
                .any(|(_, ps)| ps.iter().any(|p| p == "README.md"))
            && after
                .iter()
                .any(|(_, ps)| ps.iter().any(|p| p == "src/main.rs"));
        assert!(
            emit(
                "keeper_survived",
                keeper_ok,
                "README.md + src/main.rs preserved, book.pdf gone"
            ),
            "keeper content missing"
        );

        // Verify the actual keeper blob bytes are byte-identical in the new HEAD.
        let repo = gix::open(td.path()).unwrap();
        let head_tree = repo.head_commit().unwrap().tree().unwrap();
        let readme = head_tree
            .lookup_entry_by_path("README.md")
            .unwrap()
            .unwrap();
        let data = repo.find_object(readme.id()).unwrap().data.clone();
        assert_eq!(data, b"# keeper\nhello world\n", "README bytes changed");

        assert_eq!(rep.bytes_reclaimed, size);
        assert_eq!(rep.blobs_dropped, 1);
        assert!(rep.backup_ref_prefix.is_some(), "apply must write a backup");
        assert!(
            rep.backup_file.as_ref().unwrap().exists(),
            "backup file must exist"
        );
    }

    #[test]
    fn purge_is_idempotent() {
        let (td, _size, _oid) = make_repo();
        purge_paths(
            td.path(),
            &PurgeOptions {
                paths: vec!["docs/book.pdf".into()],
                apply: true,
            },
        )
        .unwrap();
        let after_first = snapshot(td.path());

        let rep2 = purge_paths(
            td.path(),
            &PurgeOptions {
                paths: vec!["docs/book.pdf".into()],
                apply: true,
            },
        )
        .unwrap();
        let after_second = snapshot(td.path());

        let noop = rep2.no_op
            && !rep2.applied
            && rep2.commits_rewritten == 0
            && after_first == after_second;
        assert!(
            emit(
                "idempotent",
                noop,
                &format!("no_op={} rewritten={}", rep2.no_op, rep2.commits_rewritten)
            ),
            "second purge should be a no-op, got {rep2:?}"
        );
    }

    #[test]
    fn glob_match_basics() {
        assert!(glob_match("docs/book.pdf", "docs/book.pdf"));
        assert!(glob_match("*.pdf", "docs/book.pdf"));
        assert!(glob_match("docs/*", "docs/book.pdf"));
        assert!(!glob_match("docs/book.pdf", "src/main.rs"));
        assert!(glob_match("src/?ain.rs", "src/main.rs"));
    }
}