1use std::collections::{HashMap, HashSet};
45use std::path::{Path, PathBuf};
46
47use anyhow::{Context, Result, bail};
48use gix::bstr::{BString, ByteSlice};
49
50#[derive(Debug, Clone)]
54pub struct BlobStat {
55 pub oid: gix::ObjectId,
57 pub size: u64,
59 pub example_path: String,
61 pub commit_count: usize,
63}
64
65#[derive(Debug, Clone)]
67pub struct ScanReport {
68 pub total_blobs: usize,
70 pub total_bytes: u64,
73 pub largest: Vec<BlobStat>,
75 pub commits_scanned: usize,
77 pub refs_scanned: usize,
79}
80
81#[derive(Debug, Clone)]
83pub struct PurgeOptions {
84 pub paths: Vec<String>,
88 pub apply: bool,
91}
92
93#[derive(Debug, Clone)]
95pub struct PurgeReport {
96 pub applied: bool,
99 pub no_op: bool,
102 pub commits_total: usize,
104 pub commits_rewritten: usize,
106 pub blobs_dropped: usize,
109 pub bytes_reclaimed: u64,
112 pub refs_updated: Vec<(String, String, String)>,
115 pub annotated_tags_skipped: Vec<String>,
117 pub backup_ref_prefix: Option<String>,
120 pub backup_file: Option<PathBuf>,
122}
123
124struct RepointRef {
128 name: gix::refs::FullName,
129 commit: gix::ObjectId,
130}
131
132struct RefsResolved {
134 repointable: Vec<RepointRef>,
136 annotated_tags: Vec<String>,
138 tips: Vec<gix::ObjectId>,
140}
141
142fn 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 if matches!(r.target(), gix::refs::TargetRef::Symbolic(_)) {
158 continue;
159 }
160 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, };
171 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 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
195fn 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 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 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
233fn 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 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 }
262 for (dname, doid) in subdirs {
263 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
277pub 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 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 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
342pub 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 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 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 let mut cache: HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>> = HashMap::new();
388 let mut tree_hits: HashMap<gix::ObjectId, bool> = HashMap::new(); let mut target_blobs: HashMap<gix::ObjectId, u64> = HashMap::new(); let mut kept_blobs: HashSet<gix::ObjectId> = HashSet::new(); 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 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 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 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 if no_op {
468 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 let (backup_file, backup_ref_prefix) = write_backup(&repo, &refs.repointable)?;
487
488 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); 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 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 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
543fn 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 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 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 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
607fn 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
629fn 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 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 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 let _ = repo.edit_references(edits);
671 }
672 Ok((file, "refs/original/".to_string()))
673}
674
675fn 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(()); };
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
702fn 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 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#[cfg(test)]
730mod tests {
731 use super::*;
732
733 fn emit(check: &str, ok: bool, detail: &str) -> bool {
737 let _ = (check, detail);
738 ok
739 }
740
741 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 std::fs::write(root.join("README.md"), b"# keeper\nhello\n").unwrap();
751 crate::gitio::commit_all(root, "c1: readme").unwrap();
752
753 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 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 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 let repo = gix::open(root).unwrap();
775 let oid = repo.write_blob(&big).unwrap().detach(); (td, big.len() as u64, oid.to_string())
777 }
778
779 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 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 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 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 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 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 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}