nornir-git 0.1.2

Pure-Rust git helpers over gix — read-side inspection (gitio), history deep-clean / path-removal (gitclean), and an SSH transport bridge (russh → gix blocking I/O). No `git` binary, no libgit2/C; the airgap-clean git leaf lifted out of nornir.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
//! # `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.to_string() == 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"));
    }
}