Skip to main content

nornir_git/
gitclean.rs

1//! # `gitclean` — pure-Rust git-history deep-clean over gix (gitoxide)
2//!
3//! The pure-Rust analogue of `git filter-repo --invert-paths --path <p>`:
4//! remove one or more paths (e.g. a committed `docs/book.pdf`) from **every
5//! commit across every ref** of a repository, rewriting history in place — no
6//! `git`/`git-filter-repo` subprocess, no Python, no C. Everything runs through
7//! `gix` 0.84 plumbing (`gix::open`, ref iteration, commit/tree reading, the
8//! `tree-editor` for tree rebuild, `write_object` for new commits, and
9//! `edit_references` for the ref repoint).
10//!
11//! Two modes, both exposed as a reusable library fn so they are unit-testable
12//! without the CLI:
13//!
14//! * [`scan_blobs`] — read-only. Walk all refs + their ancestry, enumerate every
15//!   blob and its size, and report the N largest (size, an example path, how many
16//!   commits reference it) + the total. `--path <glob>` narrows the report.
17//! * [`purge_paths`] — the deep clean. Default is a **dry-run** (report only);
18//!   `apply = true` performs the rewrite: back up the original ref SHAs, rewrite
19//!   every commit parents-first with the target path(s) stripped from its tree
20//!   (subtrees that become empty are dropped by the tree editor), remap parents
21//!   through the old→new map, then repoint every branch/lightweight-tag ref to
22//!   the rewritten tip and leave the worktree consistent with the new `HEAD`.
23//!
24//! ## Honest seams
25//! * **Annotated tags** (`refs/tags/*` that point at a *tag object*, not a commit
26//!   directly) are traversed for reachability but **not** repointed — the tag
27//!   object still references the old commit. They are reported in
28//!   [`PurgeReport::annotated_tags_skipped`]. Lightweight tags (direct-to-commit)
29//!   ARE repointed like branches.
30//! * **On-disk GC** — the rewrite makes the old objects unreachable, but the
31//!   loose/packed objects still occupy `.git` until a repack+prune. gix 0.84 has
32//!   no stable high-level repack API, so `git gc --prune=now` (or
33//!   `git reflog expire --expire=now --all && git gc --prune=now`) finalizes the
34//!   on-disk reclamation. The **history is rewritten regardless**; only the byte
35//!   savings on disk wait for GC. [`PurgeReport::bytes_reclaimed`] is the exact
36//!   size of the now-unreachable blobs.
37//! * **Reflogs** — old reflog entries still pin the pre-rewrite commits; expiring
38//!   them (`git reflog expire --expire=now --all`) is part of the same GC seam.
39//! * **Empty commits** are NOT pruned — a commit whose only content was the purged
40//!   path is kept (with an empty/parent-equal tree) so the commit **count is
41//!   preserved**. (Documented rather than defaulted-on to avoid surprising
42//!   history shape changes.)
43
44use std::collections::{HashMap, HashSet};
45use std::path::{Path, PathBuf};
46
47use anyhow::{Context, Result, bail};
48use gix::bstr::{BString, ByteSlice};
49
50// ── Reports ──────────────────────────────────────────────────────────────────
51
52/// One blob's aggregate stats in [`ScanReport`].
53#[derive(Debug, Clone)]
54pub struct BlobStat {
55    /// The blob object id (hex via `Display`).
56    pub oid: gix::ObjectId,
57    /// Uncompressed object size in bytes (from the odb header — no full read).
58    pub size: u64,
59    /// One repo path this blob is stored at (first seen; illustrative).
60    pub example_path: String,
61    /// How many reachable commits contain this blob in their tree.
62    pub commit_count: usize,
63}
64
65/// Read-only scan result from [`scan_blobs`].
66#[derive(Debug, Clone)]
67pub struct ScanReport {
68    /// Distinct blob objects seen across all reachable history (after `--path`).
69    pub total_blobs: usize,
70    /// Sum of the distinct blobs' sizes (what a full clean of the filter could
71    /// reclaim if every matched blob became unreachable).
72    pub total_bytes: u64,
73    /// The `top_n` largest blobs, descending by size.
74    pub largest: Vec<BlobStat>,
75    /// Number of reachable commits walked.
76    pub commits_scanned: usize,
77    /// Number of refs (branch/tag/…) that resolved to a commit.
78    pub refs_scanned: usize,
79}
80
81/// Options for [`purge_paths`].
82#[derive(Debug, Clone)]
83pub struct PurgeOptions {
84    /// Exact repo paths to strip from ALL history, e.g. `["docs/book.pdf"]`.
85    /// Nested paths are supported (the tree editor descends component-by-
86    /// component and drops subtrees that become empty).
87    pub paths: Vec<String>,
88    /// `false` (default) = dry-run: report only, mutate nothing. `true` = perform
89    /// the rewrite (after writing the backup).
90    pub apply: bool,
91}
92
93/// Result of [`purge_paths`] (identical shape for dry-run and apply).
94#[derive(Debug, Clone)]
95pub struct PurgeReport {
96    /// Whether the rewrite was actually performed (`apply` was set and there was
97    /// something to do).
98    pub applied: bool,
99    /// `true` when nothing referenced any target path — a genuine no-op (this is
100    /// what a second, idempotent purge of the same path reports).
101    pub no_op: bool,
102    /// Total reachable commits considered.
103    pub commits_total: usize,
104    /// Commits whose object changed (tree stripped and/or a parent remapped).
105    pub commits_rewritten: usize,
106    /// Distinct blob objects that become unreachable (were referenced ONLY via a
107    /// purged path).
108    pub blobs_dropped: usize,
109    /// Sum of the dropped blobs' sizes — the exact on-disk reclamation a
110    /// subsequent `git gc --prune=now` will realize.
111    pub bytes_reclaimed: u64,
112    /// `(ref_name, old_sha, new_sha)` for every ref repointed (apply) or that
113    /// would be repointed (dry-run).
114    pub refs_updated: Vec<(String, String, String)>,
115    /// Annotated-tag refs left untouched (documented seam), by full ref name.
116    pub annotated_tags_skipped: Vec<String>,
117    /// On apply: the `refs/original/…` backup namespace prefix that now mirrors the
118    /// pre-rewrite ref tips.
119    pub backup_ref_prefix: Option<String>,
120    /// On apply: path to the plain-text backup file recording old ref→sha lines.
121    pub backup_file: Option<PathBuf>,
122}
123
124// ── Internal traversal ───────────────────────────────────────────────────────
125
126/// A repointable ref (branch / lightweight tag / remote) resolved to its commit.
127struct RepointRef {
128    name: gix::refs::FullName,
129    commit: gix::ObjectId,
130}
131
132/// Everything we learn from one pass over the refs.
133struct RefsResolved {
134    /// Refs that point *directly* at a commit → repointable.
135    repointable: Vec<RepointRef>,
136    /// Full names of annotated-tag refs (point at a tag object) — a seam.
137    annotated_tags: Vec<String>,
138    /// The union of commit tips to traverse from (deduped).
139    tips: Vec<gix::ObjectId>,
140}
141
142/// Resolve every ref to a commit, classifying direct-commit refs (repointable)
143/// vs annotated tags (a documented seam). Symbolic refs (e.g. `HEAD`) are skipped
144/// — their branch is handled directly.
145fn resolve_refs(repo: &gix::Repository) -> Result<RefsResolved> {
146    let mut repointable = Vec::new();
147    let mut annotated_tags = Vec::new();
148    let mut tip_set: HashSet<gix::ObjectId> = HashSet::new();
149
150    let platform = repo.references().context("open ref store")?;
151    for r in platform.all().context("iterate refs")? {
152        let mut r = match r {
153            Ok(r) => r,
154            Err(_) => continue,
155        };
156        // Skip symbolic refs (HEAD → refs/heads/x); the branch itself is visited.
157        if matches!(r.target(), gix::refs::TargetRef::Symbolic(_)) {
158            continue;
159        }
160        // Skip our own backup namespace: `refs/original/*` deliberately pins the
161        // PRE-rewrite tips for recovery, so it must not count as live history (else
162        // a re-run would see the blob "still reachable" and never be a no-op).
163        if r.name().as_bstr().starts_with(b"refs/original/") {
164            continue;
165        }
166        let direct = r.id().detach();
167        let peeled = match r.peel_to_id() {
168            Ok(id) => id.detach(),
169            Err(_) => continue, // not peelable to an object we can use
170        };
171        // Only commits are traversable tips; a ref peeling to a non-commit (e.g. a
172        // ref straight to a blob/tree) is ignored for history rewrite.
173        if repo.find_commit(peeled).is_err() {
174            continue;
175        }
176        tip_set.insert(peeled);
177        let name = r.name().to_owned();
178        if direct == peeled {
179            repointable.push(RepointRef {
180                name,
181                commit: peeled,
182            });
183        } else {
184            // direct target is a tag object that peeled to a commit → annotated tag.
185            annotated_tags.push(name.as_bstr().to_str_lossy().into_owned());
186        }
187    }
188    Ok(RefsResolved {
189        repointable,
190        annotated_tags,
191        tips: tip_set.into_iter().collect(),
192    })
193}
194
195/// Collect every commit reachable from `tips` in **parents-first** topological
196/// order (a parent always precedes its children), iteratively (no recursion, so
197/// deep histories don't overflow the stack).
198fn topo_order_parents_first(
199    repo: &gix::Repository,
200    tips: &[gix::ObjectId],
201) -> Result<Vec<gix::ObjectId>> {
202    let mut order: Vec<gix::ObjectId> = Vec::new();
203    let mut visited: HashSet<gix::ObjectId> = HashSet::new();
204    // (oid, children_expanded?) — post-order DFS: emit a node only after all its
205    // parents have been emitted.
206    let mut stack: Vec<(gix::ObjectId, bool)> = Vec::new();
207    for t in tips {
208        stack.push((*t, false));
209    }
210    while let Some((oid, expanded)) = stack.pop() {
211        if expanded {
212            order.push(oid);
213            continue;
214        }
215        if !visited.insert(oid) {
216            continue;
217        }
218        // Re-push self as "expanded" first so it is emitted AFTER its parents.
219        stack.push((oid, true));
220        let commit = repo
221            .find_commit(oid)
222            .with_context(|| format!("find commit {oid}"))?;
223        for pid in commit.parent_ids() {
224            let pid = pid.detach();
225            if !visited.contains(&pid) {
226                stack.push((pid, false));
227            }
228        }
229    }
230    Ok(order)
231}
232
233/// The blobs (as `(path-within-subtree, oid, size)`) at the leaves of the subtree
234/// rooted at `tree_oid`, memoized by tree oid. The paths are relative to this
235/// subtree, so the value is prefix-independent and safe to cache and reuse under
236/// any parent path.
237fn subtree_blobs<'a>(
238    repo: &gix::Repository,
239    tree_oid: gix::ObjectId,
240    cache: &'a mut HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>>,
241) -> Result<&'a Vec<(BString, gix::ObjectId, u64)>> {
242    if !cache.contains_key(&tree_oid) {
243        let mut out: Vec<(BString, gix::ObjectId, u64)> = Vec::new();
244        let tree = repo
245            .find_tree(tree_oid)
246            .with_context(|| format!("find tree {tree_oid}"))?;
247        // Collect entries first so we don't hold the tree borrow across recursion.
248        let mut subdirs: Vec<(BString, gix::ObjectId)> = Vec::new();
249        for entry in tree.iter() {
250            let entry = entry.context("decode tree entry")?;
251            let name = entry.inner.filename.to_owned();
252            let oid = entry.inner.oid.to_owned();
253            let mode = entry.inner.mode;
254            if mode.is_tree() {
255                subdirs.push((name, oid));
256            } else if mode.is_blob() || mode.is_link() {
257                let size = repo.find_header(oid).map(|h| h.size()).unwrap_or(0);
258                out.push((name, oid, size));
259            }
260            // gitlinks (commit entries) reference no blob — skipped.
261        }
262        for (dname, doid) in subdirs {
263            // Recurse (fills cache for the child), then prefix names.
264            let child = subtree_blobs(repo, doid, cache)?.clone();
265            for (cpath, coid, csize) in child {
266                let mut full = dname.clone();
267                full.push(b'/');
268                full.extend_from_slice(&cpath);
269                out.push((full, coid, csize));
270            }
271        }
272        cache.insert(tree_oid, out);
273    }
274    Ok(cache.get(&tree_oid).expect("just inserted"))
275}
276
277// ── scan ─────────────────────────────────────────────────────────────────────
278
279/// Read-only blob scan over ALL reachable history — "what would a clean reclaim".
280///
281/// Walks every ref's ancestry, enumerates blobs and their sizes, and reports the
282/// `top_n` largest (size, an example path, commits referencing it) plus the
283/// total. `path_filter` (a simple glob supporting `*`/`?`, `*` spanning `/`)
284/// narrows the report to matching repo paths; `None` = everything.
285pub fn scan_blobs(repo_root: &Path, path_filter: Option<&str>, top_n: usize) -> Result<ScanReport> {
286    let repo =
287        gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;
288    let refs = resolve_refs(&repo)?;
289    let commits = topo_order_parents_first(&repo, &refs.tips)?;
290
291    let mut cache: HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>> = HashMap::new();
292    // blob oid → (size, example_path, set-of-commit-indices count)
293    let mut agg: HashMap<gix::ObjectId, (u64, String, usize)> = HashMap::new();
294
295    for &c in &commits {
296        let commit = repo
297            .find_commit(c)
298            .with_context(|| format!("find commit {c}"))?;
299        let tree_oid = commit.tree_id().context("commit tree id")?.detach();
300        let blobs = subtree_blobs(&repo, tree_oid, &mut cache)?.clone();
301        // Dedup blob oids within this one commit so commit_count is per-commit.
302        let mut seen_here: HashSet<gix::ObjectId> = HashSet::new();
303        for (path, oid, size) in blobs {
304            let path_str = path.to_str_lossy();
305            if let Some(f) = path_filter {
306                if !glob_match(f, &path_str) {
307                    continue;
308                }
309            }
310            let e = agg
311                .entry(oid)
312                .or_insert_with(|| (size, path_str.clone().into_owned(), 0));
313            if seen_here.insert(oid) {
314                e.2 += 1;
315            }
316        }
317    }
318
319    let total_blobs = agg.len();
320    let total_bytes: u64 = agg.values().map(|(s, _, _)| *s).sum();
321    let mut largest: Vec<BlobStat> = agg
322        .into_iter()
323        .map(|(oid, (size, example_path, commit_count))| BlobStat {
324            oid,
325            size,
326            example_path,
327            commit_count,
328        })
329        .collect();
330    largest.sort_by(|a, b| b.size.cmp(&a.size).then(a.oid.cmp(&b.oid)));
331    largest.truncate(top_n);
332
333    Ok(ScanReport {
334        total_blobs,
335        total_bytes,
336        largest,
337        commits_scanned: commits.len(),
338        refs_scanned: refs.repointable.len() + refs.annotated_tags.len(),
339    })
340}
341
342// ── purge ────────────────────────────────────────────────────────────────────
343
344/// Deep-clean: strip `opts.paths` from every commit across every ref.
345///
346/// Dry-run by default (`opts.apply == false`) — computes and returns the exact
347/// report (commits to rewrite, blobs/bytes reclaimed, refs to repoint) without
348/// mutating anything. With `opts.apply == true` it writes a backup, rewrites the
349/// history parents-first, repoints branch/lightweight-tag refs, and reconciles the
350/// worktree/index with the new `HEAD`.
351///
352/// Idempotent: a second purge of the same path finds nothing to strip and reports
353/// `no_op = true`, changing no ref.
354pub fn purge_paths(repo_root: &Path, opts: &PurgeOptions) -> Result<PurgeReport> {
355    if opts.paths.is_empty() {
356        bail!("purge: no --path given (nothing to remove)");
357    }
358    // Normalize: drop leading "./" and "/", reject empty.
359    let targets: Vec<String> = opts
360        .paths
361        .iter()
362        .map(|p| {
363            p.trim()
364                .trim_start_matches("./")
365                .trim_start_matches('/')
366                .to_string()
367        })
368        .collect();
369    if targets.iter().any(|p| p.is_empty()) {
370        bail!("purge: empty path component in --path");
371    }
372    let target_set: HashSet<&str> = targets.iter().map(|s| s.as_str()).collect();
373
374    let mut repo =
375        gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;
376    // Ref updates below write reflog entries, which need a committer identity. A
377    // freshly-`init`'d repo has none; inject gix's generic in-memory fallback so
378    // the repoint/backup ref edits succeed without ambient git config (mirrors
379    // `gitio::ssh_sync`). No-op when a real identity is already configured.
380    let _ = repo.committer_or_set_generic_fallback();
381    let refs = resolve_refs(&repo)?;
382    let commits = topo_order_parents_first(&repo, &refs.tips)?;
383
384    // Pass 1 (read-only): per-commit "does its tree hold any target path", plus
385    // the blob-reclaim math (a blob is reclaimed iff it appears ONLY at target
386    // paths anywhere in history).
387    let mut cache: HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>> = HashMap::new();
388    let mut tree_hits: HashMap<gix::ObjectId, bool> = HashMap::new(); // commit oid → tree contains a target
389    let mut target_blobs: HashMap<gix::ObjectId, u64> = HashMap::new(); // blob → size (seen at a target path)
390    let mut kept_blobs: HashSet<gix::ObjectId> = HashSet::new(); // blob seen at a NON-target path
391
392    for &c in &commits {
393        let commit = repo
394            .find_commit(c)
395            .with_context(|| format!("find commit {c}"))?;
396        let tree_oid = commit.tree_id().context("commit tree id")?.detach();
397        let blobs = subtree_blobs(&repo, tree_oid, &mut cache)?.clone();
398        let mut hit = false;
399        for (path, oid, size) in blobs {
400            let path_str = path.to_str_lossy();
401            if target_set.contains(path_str.as_ref()) {
402                hit = true;
403                target_blobs.insert(oid, size);
404            } else {
405                kept_blobs.insert(oid);
406            }
407        }
408        tree_hits.insert(c, hit);
409    }
410
411    let reclaimed: Vec<(gix::ObjectId, u64)> = target_blobs
412        .iter()
413        .filter(|(oid, _)| !kept_blobs.contains(*oid))
414        .map(|(o, s)| (*o, *s))
415        .collect();
416    let bytes_reclaimed: u64 = reclaimed.iter().map(|(_, s)| *s).sum();
417
418    // Pass 2: propagate "changed" parents-first. A commit is rewritten if its tree
419    // holds a target OR any parent was rewritten (so its parent link moves).
420    let mut changed: HashMap<gix::ObjectId, bool> = HashMap::new();
421    for &c in &commits {
422        let commit = repo.find_commit(c)?;
423        let tree_hit = *tree_hits.get(&c).unwrap_or(&false);
424        let parent_changed = commit
425            .parent_ids()
426            .any(|p| *changed.get(&p.detach()).unwrap_or(&false));
427        changed.insert(c, tree_hit || parent_changed);
428    }
429    let commits_rewritten = changed.values().filter(|v| **v).count();
430
431    // Which repointable refs would move (tip is a changed commit).
432    let would_move: Vec<&RepointRef> = refs
433        .repointable
434        .iter()
435        .filter(|r| *changed.get(&r.commit).unwrap_or(&false))
436        .collect();
437
438    let no_op = commits_rewritten == 0;
439
440    // ── Dry-run: report and stop. ──
441    if !opts.apply {
442        let refs_updated = would_move
443            .iter()
444            .map(|r| {
445                (
446                    r.name.as_bstr().to_str_lossy().into_owned(),
447                    r.commit.to_string(),
448                    "(dry-run)".to_string(),
449                )
450            })
451            .collect();
452        return Ok(PurgeReport {
453            applied: false,
454            no_op,
455            commits_total: commits.len(),
456            commits_rewritten,
457            blobs_dropped: reclaimed.len(),
458            bytes_reclaimed,
459            refs_updated,
460            annotated_tags_skipped: refs.annotated_tags,
461            backup_ref_prefix: None,
462            backup_file: None,
463        });
464    }
465
466    // ── Apply. ──
467    if no_op {
468        // Nothing references a target path — a true idempotent no-op. Don't write a
469        // backup or touch a single ref.
470        return Ok(PurgeReport {
471            applied: false,
472            no_op: true,
473            commits_total: commits.len(),
474            commits_rewritten: 0,
475            blobs_dropped: 0,
476            bytes_reclaimed: 0,
477            refs_updated: Vec::new(),
478            annotated_tags_skipped: refs.annotated_tags,
479            backup_ref_prefix: None,
480            backup_file: None,
481        });
482    }
483
484    // 1) Backup FIRST: record old ref SHAs to a file AND mirror every repointable
485    //    ref under `refs/original/…` so the pre-rewrite tips stay recoverable.
486    let (backup_file, backup_ref_prefix) = write_backup(&repo, &refs.repointable)?;
487
488    // 2) Rewrite every commit parents-first, threading the old→new map.
489    let mut map: HashMap<gix::ObjectId, gix::ObjectId> = HashMap::new();
490    for &c in &commits {
491        let is_changed = *changed.get(&c).unwrap_or(&false);
492        if !is_changed {
493            map.insert(c, c); // byte-identical → same oid (nothing to write)
494            continue;
495        }
496        let new_oid = rewrite_commit(
497            &repo,
498            c,
499            &targets,
500            &map,
501            *tree_hits.get(&c).unwrap_or(&false),
502        )?;
503        map.insert(c, new_oid);
504    }
505
506    // 3) Repoint refs (branches + lightweight tags). Annotated tags are a seam.
507    let mut refs_updated = Vec::new();
508    let mut edits = Vec::new();
509    for r in &refs.repointable {
510        let new = *map.get(&r.commit).unwrap_or(&r.commit);
511        if new == r.commit {
512            continue;
513        }
514        edits.push(update_ref_edit(r.name.clone(), new));
515        refs_updated.push((
516            r.name.as_bstr().to_str_lossy().into_owned(),
517            r.commit.to_string(),
518            new.to_string(),
519        ));
520    }
521    if !edits.is_empty() {
522        repo.edit_references(edits)
523            .context("repoint refs to rewritten tips")?;
524    }
525
526    // 4) Reconcile the worktree/index with the new HEAD (non-bare repos only).
527    reconcile_worktree(repo_root, &targets)?;
528
529    Ok(PurgeReport {
530        applied: true,
531        no_op: false,
532        commits_total: commits.len(),
533        commits_rewritten,
534        blobs_dropped: reclaimed.len(),
535        bytes_reclaimed,
536        refs_updated,
537        annotated_tags_skipped: refs.annotated_tags,
538        backup_ref_prefix: Some(backup_ref_prefix),
539        backup_file: Some(backup_file),
540    })
541}
542
543/// Rebuild commit `c` with the target paths stripped and parents remapped through
544/// `map`, write the new commit object, and return its oid. The new commit drops
545/// any `gpgsig` (a rewrite invalidates it — "gpg-less"), but preserves author,
546/// committer, message, encoding and all other extra headers.
547fn rewrite_commit(
548    repo: &gix::Repository,
549    c: gix::ObjectId,
550    targets: &[String],
551    map: &HashMap<gix::ObjectId, gix::ObjectId>,
552    tree_hit: bool,
553) -> Result<gix::ObjectId> {
554    let commit = repo
555        .find_commit(c)
556        .with_context(|| format!("find commit {c}"))?;
557    let old_tree = commit.tree_id().context("commit tree id")?.detach();
558
559    // New tree: strip each target path (no-op if absent — idempotent).
560    let new_tree = if tree_hit {
561        let mut editor = repo.edit_tree(old_tree).context("open tree editor")?;
562        for p in targets {
563            editor
564                .remove(p.as_str())
565                .with_context(|| format!("tree remove {p}"))?;
566        }
567        editor.write().context("write rewritten tree")?.detach()
568    } else {
569        old_tree
570    };
571
572    // Remap parents.
573    let new_parents: Vec<gix::ObjectId> = commit
574        .parent_ids()
575        .map(|p| *map.get(&p.detach()).unwrap_or(&p.detach()))
576        .collect();
577
578    // Extract owned author/committer/message/encoding/extra-headers.
579    let decoded = commit.decode().context("decode commit")?;
580    let author: gix::actor::Signature = decoded.author().context("decode author")?.into();
581    let committer: gix::actor::Signature = decoded.committer().context("decode committer")?.into();
582    let message: BString = decoded.message.to_owned();
583    let encoding: Option<BString> = decoded.encoding.map(|e| e.to_owned());
584    let extra_headers: Vec<(BString, BString)> = decoded
585        .extra_headers
586        .iter()
587        .filter(|(k, _)| k.as_bytes() != b"gpgsig")
588        .map(|(k, v)| ((*k).to_owned(), v.as_ref().to_owned()))
589        .collect();
590
591    let new_commit = gix::objs::Commit {
592        tree: new_tree,
593        parents: new_parents.into_iter().collect(),
594        author,
595        committer,
596        encoding,
597        message,
598        extra_headers,
599    };
600    let id = repo
601        .write_object(&new_commit)
602        .context("write rewritten commit")?
603        .detach();
604    Ok(id)
605}
606
607/// Build a force-update `RefEdit` pointing `name` at `new` (with a reflog note).
608fn update_ref_edit(
609    name: gix::refs::FullName,
610    new: gix::ObjectId,
611) -> gix::refs::transaction::RefEdit {
612    use gix::refs::Target;
613    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
614    RefEdit {
615        change: Change::Update {
616            log: LogChange {
617                mode: RefLog::AndReference,
618                force_create_reflog: false,
619                message: "nornir: deep-clean history rewrite".into(),
620            },
621            expected: PreviousValue::Any,
622            new: Target::Object(new),
623        },
624        name,
625        deref: false,
626    }
627}
628
629/// Write the pre-rewrite backup: a plain-text `old ref → sha` file under `.git/`
630/// AND a `refs/original/<full ref>` mirror of every repointable tip. Returns
631/// `(file_path, ref_prefix)`.
632fn write_backup(repo: &gix::Repository, repointable: &[RepointRef]) -> Result<(PathBuf, String)> {
633    use gix::refs::Target;
634    use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
635
636    let git_dir = repo.git_dir();
637    let stamp = std::time::SystemTime::now()
638        .duration_since(std::time::UNIX_EPOCH)
639        .map(|d| d.as_secs())
640        .unwrap_or(0);
641    let file = git_dir.join(format!("nornir-deepclean-backup-{stamp}.txt"));
642    let mut body = String::from("# nornir deep-clean backup — pre-rewrite ref tips\n");
643    let mut edits = Vec::new();
644    for r in repointable {
645        let name = r.name.as_bstr().to_str_lossy();
646        body.push_str(&format!("{} {}\n", r.commit, name));
647        // refs/original/refs/heads/foo (git-filter-repo convention).
648        let backup_name = format!("refs/original/{name}");
649        if let Ok(full) = gix::refs::FullName::try_from(backup_name.as_str()) {
650            edits.push(RefEdit {
651                change: Change::Update {
652                    log: LogChange {
653                        mode: RefLog::AndReference,
654                        force_create_reflog: false,
655                        message: "nornir: deep-clean backup".into(),
656                    },
657                    // Don't clobber a backup from an earlier run.
658                    expected: PreviousValue::MustNotExist,
659                    new: Target::Object(r.commit),
660                },
661                name: full,
662                deref: false,
663            });
664        }
665    }
666    std::fs::write(&file, body).with_context(|| format!("write backup file {}", file.display()))?;
667    if !edits.is_empty() {
668        // A pre-existing backup ref (earlier partial run) is fine — ignore the
669        // clobber error rather than abort the whole clean.
670        let _ = repo.edit_references(edits);
671    }
672    Ok((file, "refs/original/".to_string()))
673}
674
675/// Reconcile the worktree + index with the new `HEAD`: delete any purged files
676/// still on disk and rebuild the index from the new HEAD tree, so `git status` is
677/// clean. Bare repos (no worktree) are a no-op.
678fn reconcile_worktree(repo_root: &Path, targets: &[String]) -> Result<()> {
679    let repo = gix::open(repo_root).context("reopen for worktree reconcile")?;
680    let Some(work_dir) = repo.workdir().map(|p| p.to_path_buf()) else {
681        return Ok(()); // bare repo
682    };
683    for p in targets {
684        let f = work_dir.join(p);
685        if f.exists() {
686            let _ = std::fs::remove_file(&f);
687        }
688    }
689    if let Ok(head) = repo.head_commit() {
690        if let Ok(tree) = head.tree_id() {
691            let tree = tree.detach();
692            if let Ok(mut index) = repo.index_from_tree(&tree) {
693                index
694                    .write(gix::index::write::Options::default())
695                    .context("write reconciled index")?;
696            }
697        }
698    }
699    Ok(())
700}
701
702// ── tiny glob (no new dep) ───────────────────────────────────────────────────
703
704/// Minimal glob match for `--path` filters: `*` matches any run of chars
705/// (including `/`), `?` matches one char, everything else is literal. Enough for
706/// `docs/book.pdf`, `*.pdf`, `docs/*`.
707fn glob_match(pattern: &str, text: &str) -> bool {
708    fn m(p: &[u8], t: &[u8]) -> bool {
709        if p.is_empty() {
710            return t.is_empty();
711        }
712        match p[0] {
713            b'*' => {
714                // Try to consume zero-or-more chars of `t`.
715                if m(&p[1..], t) {
716                    return true;
717                }
718                !t.is_empty() && m(p, &t[1..])
719            }
720            b'?' => !t.is_empty() && m(&p[1..], &t[1..]),
721            c => !t.is_empty() && t[0] == c && m(&p[1..], &t[1..]),
722        }
723    }
724    m(pattern.as_bytes(), text.as_bytes())
725}
726
727// ── tests ────────────────────────────────────────────────────────────────────
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    /// Return the verdict so the caller can `assert!` on it. (In nornir the
734    /// matrix-row emit lives at the call sites that re-export this crate; the
735    /// standalone crate keeps its tests dependency-free.)
736    fn emit(check: &str, ok: bool, detail: &str) -> bool {
737        let _ = (check, detail);
738        ok
739    }
740
741    /// Build a throwaway repo with a keeper file, a big blob committed at
742    /// `docs/book.pdf` in commit 2, and more commits after. Returns (tempdir,
743    /// big-blob-size, big-blob-oid-hex).
744    fn make_repo() -> (tempfile::TempDir, u64, String) {
745        let td = tempfile::tempdir().expect("tempdir");
746        let root = td.path();
747        crate::gitio::init(root).expect("init");
748
749        // Commit 1: keeper only.
750        std::fs::write(root.join("README.md"), b"# keeper\nhello\n").unwrap();
751        crate::gitio::commit_all(root, "c1: readme").unwrap();
752
753        // Commit 2: add a big blob at docs/book.pdf (nested path) + a source file.
754        std::fs::create_dir_all(root.join("docs")).unwrap();
755        let big = vec![0x42u8; 200_000];
756        std::fs::write(root.join("docs/book.pdf"), &big).unwrap();
757        std::fs::create_dir_all(root.join("src")).unwrap();
758        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
759        crate::gitio::commit_all(root, "c2: add book.pdf + src").unwrap();
760
761        // Commit 3: modify keeper, book.pdf still present.
762        std::fs::write(root.join("README.md"), b"# keeper\nhello world\n").unwrap();
763        crate::gitio::commit_all(root, "c3: edit readme").unwrap();
764
765        // Commit 4: modify src, book.pdf still present.
766        std::fs::write(
767            root.join("src/main.rs"),
768            b"fn main() { println!(\"hi\"); }\n",
769        )
770        .unwrap();
771        crate::gitio::commit_all(root, "c4: edit src").unwrap();
772
773        // Compute the big blob oid the way git would (blob hash of the content).
774        let repo = gix::open(root).unwrap();
775        let oid = repo.write_blob(&big).unwrap().detach(); // content-addressed → same as committed
776        (td, big.len() as u64, oid.to_string())
777    }
778
779    /// Walk every ref's ancestry and return the set of (path, blob-oid) tree
780    /// entries + the set of all blob oids reachable.
781    fn all_entries(root: &Path) -> (HashSet<String>, HashSet<String>) {
782        let repo = gix::open(root).unwrap();
783        let refs = resolve_refs(&repo).unwrap();
784        let commits = topo_order_parents_first(&repo, &refs.tips).unwrap();
785        let mut cache = HashMap::new();
786        let mut paths = HashSet::new();
787        let mut blobs = HashSet::new();
788        for c in commits {
789            let commit = repo.find_commit(c).unwrap();
790            let tree = commit.tree_id().unwrap().detach();
791            for (p, oid, _) in super::subtree_blobs(&repo, tree, &mut cache)
792                .unwrap()
793                .clone()
794            {
795                paths.insert(p.to_str_lossy().into_owned());
796                blobs.insert(oid.to_string());
797            }
798        }
799        (paths, blobs)
800    }
801
802    /// Map: commit message → tree entry paths, to assert content survives.
803    fn snapshot(root: &Path) -> Vec<(String, Vec<String>)> {
804        let repo = gix::open(root).unwrap();
805        let refs = resolve_refs(&repo).unwrap();
806        let commits = topo_order_parents_first(&repo, &refs.tips).unwrap();
807        let mut cache = HashMap::new();
808        let mut out = Vec::new();
809        for c in commits {
810            let commit = repo.find_commit(c).unwrap();
811            let msg = commit
812                .message_raw()
813                .unwrap()
814                .to_str_lossy()
815                .trim()
816                .to_string();
817            let tree = commit.tree_id().unwrap().detach();
818            let mut paths: Vec<String> = super::subtree_blobs(&repo, tree, &mut cache)
819                .unwrap()
820                .iter()
821                .map(|(p, _, _)| p.to_str_lossy().into_owned())
822                .collect();
823            paths.sort();
824            out.push((msg, paths));
825        }
826        out
827    }
828
829    #[test]
830    fn scan_reports_big_blob_as_largest() {
831        let (td, size, oid) = make_repo();
832        let report = scan_blobs(td.path(), None, 5).unwrap();
833        assert!(!report.largest.is_empty(), "scan found no blobs");
834        let top = &report.largest[0];
835        let ok = top.oid.to_string() == oid && top.size == size;
836        assert!(
837            emit(
838                "scan_largest_is_book_pdf",
839                ok,
840                &format!(
841                    "top oid={} size={} example={}",
842                    top.oid, top.size, top.example_path
843                )
844            ),
845            "expected big blob {oid} ({size}B) as largest, got {} ({}B)",
846            top.oid,
847            top.size
848        );
849        // The big blob is referenced by commits 2,3,4 → 3 commits.
850        assert_eq!(top.commit_count, 3, "book.pdf should be in 3 commits");
851        assert!(top.example_path.ends_with("book.pdf"));
852    }
853
854    #[test]
855    fn scan_path_filter_narrows() {
856        let (td, _size, _oid) = make_repo();
857        let all = scan_blobs(td.path(), None, 100).unwrap();
858        let pdf = scan_blobs(td.path(), Some("docs/book.pdf"), 100).unwrap();
859        assert!(
860            all.total_blobs > pdf.total_blobs,
861            "filter should shrink the set"
862        );
863        assert_eq!(pdf.total_blobs, 1, "only book.pdf matches");
864    }
865
866    #[test]
867    fn dry_run_mutates_nothing_and_reports() {
868        let (td, size, _oid) = make_repo();
869        let before = all_entries(td.path());
870        let rep = purge_paths(
871            td.path(),
872            &PurgeOptions {
873                paths: vec!["docs/book.pdf".into()],
874                apply: false,
875            },
876        )
877        .unwrap();
878        let after = all_entries(td.path());
879        assert_eq!(before.0, after.0, "dry-run must not change history");
880        assert!(!rep.applied && !rep.no_op);
881        assert_eq!(
882            rep.bytes_reclaimed, size,
883            "dry-run bytes must equal blob size"
884        );
885        assert_eq!(rep.blobs_dropped, 1);
886        assert!(rep.commits_rewritten >= 3, "commits 2..4 hold the blob");
887    }
888
889    #[test]
890    fn purge_removes_blob_from_all_history() {
891        let (td, size, oid) = make_repo();
892        let before = snapshot(td.path());
893        let commit_count_before = before.len();
894
895        let rep = purge_paths(
896            td.path(),
897            &PurgeOptions {
898                paths: vec!["docs/book.pdf".into()],
899                apply: true,
900            },
901        )
902        .unwrap();
903        assert!(rep.applied, "apply should perform the rewrite");
904
905        // (a) blob gone from ALL reachable trees; no tree entry named book.pdf.
906        let (paths, blobs) = all_entries(td.path());
907        let no_pdf_path = !paths.iter().any(|p| p.ends_with("book.pdf"));
908        let blob_unreachable = !blobs.contains(&oid);
909        assert!(
910            emit(
911                "blob_purged",
912                no_pdf_path && blob_unreachable,
913                &format!(
914                    "paths_with_pdf={} blob_present={}",
915                    !no_pdf_path, !blob_unreachable
916                )
917            ),
918            "book.pdf still reachable: path_gone={no_pdf_path} blob_gone={blob_unreachable}"
919        );
920
921        // (b) keeper + src survive byte-identical, commit count + messages preserved.
922        let after = snapshot(td.path());
923        assert_eq!(
924            after.len(),
925            commit_count_before,
926            "commit count must be preserved"
927        );
928        let messages_before: Vec<&String> = before.iter().map(|(m, _)| m).collect();
929        let messages_after: Vec<&String> = after.iter().map(|(m, _)| m).collect();
930        assert_eq!(messages_before, messages_after, "messages/order preserved");
931        // README.md + src/main.rs present in every commit that had them; docs/book.pdf gone everywhere.
932        let keeper_ok = after
933            .iter()
934            .all(|(_, ps)| !ps.iter().any(|p| p.ends_with("book.pdf")))
935            && after
936                .iter()
937                .any(|(_, ps)| ps.iter().any(|p| p == "README.md"))
938            && after
939                .iter()
940                .any(|(_, ps)| ps.iter().any(|p| p == "src/main.rs"));
941        assert!(
942            emit(
943                "keeper_survived",
944                keeper_ok,
945                "README.md + src/main.rs preserved, book.pdf gone"
946            ),
947            "keeper content missing"
948        );
949
950        // Verify the actual keeper blob bytes are byte-identical in the new HEAD.
951        let repo = gix::open(td.path()).unwrap();
952        let head_tree = repo.head_commit().unwrap().tree().unwrap();
953        let readme = head_tree
954            .lookup_entry_by_path("README.md")
955            .unwrap()
956            .unwrap();
957        let data = repo.find_object(readme.id()).unwrap().data.clone();
958        assert_eq!(data, b"# keeper\nhello world\n", "README bytes changed");
959
960        assert_eq!(rep.bytes_reclaimed, size);
961        assert_eq!(rep.blobs_dropped, 1);
962        assert!(rep.backup_ref_prefix.is_some(), "apply must write a backup");
963        assert!(
964            rep.backup_file.as_ref().unwrap().exists(),
965            "backup file must exist"
966        );
967    }
968
969    #[test]
970    fn purge_is_idempotent() {
971        let (td, _size, _oid) = make_repo();
972        purge_paths(
973            td.path(),
974            &PurgeOptions {
975                paths: vec!["docs/book.pdf".into()],
976                apply: true,
977            },
978        )
979        .unwrap();
980        let after_first = snapshot(td.path());
981
982        let rep2 = purge_paths(
983            td.path(),
984            &PurgeOptions {
985                paths: vec!["docs/book.pdf".into()],
986                apply: true,
987            },
988        )
989        .unwrap();
990        let after_second = snapshot(td.path());
991
992        let noop = rep2.no_op
993            && !rep2.applied
994            && rep2.commits_rewritten == 0
995            && after_first == after_second;
996        assert!(
997            emit(
998                "idempotent",
999                noop,
1000                &format!("no_op={} rewritten={}", rep2.no_op, rep2.commits_rewritten)
1001            ),
1002            "second purge should be a no-op, got {rep2:?}"
1003        );
1004    }
1005
1006    #[test]
1007    fn glob_match_basics() {
1008        assert!(glob_match("docs/book.pdf", "docs/book.pdf"));
1009        assert!(glob_match("*.pdf", "docs/book.pdf"));
1010        assert!(glob_match("docs/*", "docs/book.pdf"));
1011        assert!(!glob_match("docs/book.pdf", "src/main.rs"));
1012        assert!(glob_match("src/?ain.rs", "src/main.rs"));
1013    }
1014}