crates/nornir-git/src/gitclean.rs
//! # `gitclean` — pure-Rust git-history deep-clean over gix (gitoxide)
//!
//! The pure-Rust analogue of `git filter-repo --invert-paths --path <p>`:
//! remove one or more paths (e.g. a committed `docs/book.pdf`) from **every
//! commit across every ref** of a repository, rewriting history in place — no
//! `git`/`git-filter-repo` subprocess, no Python, no C. Everything runs through
//! `gix` 0.84 plumbing (`gix::open`, ref iteration, commit/tree reading, the
//! `tree-editor` for tree rebuild, `write_object` for new commits, and
//! `edit_references` for the ref repoint).
//!
//! Two modes, both exposed as a reusable library fn so they are unit-testable
//! without the CLI:
//!
//! * [`scan_blobs`] — read-only. Walk all refs + their ancestry, enumerate every
//! blob and its size, and report the N largest (size, an example path, how many
//! commits reference it) + the total. `--path <glob>` narrows the report.
//! * [`purge_paths`] — the deep clean. Default is a **dry-run** (report only);
//! `apply = true` performs the rewrite: back up the original ref SHAs, rewrite
//! every commit parents-first with the target path(s) stripped from its tree
//! (subtrees that become empty are dropped by the tree editor), remap parents
//! through the old→new map, then repoint every branch/lightweight-tag ref to
//! the rewritten tip and leave the worktree consistent with the new `HEAD`.
//!
//! ## Honest seams
//! * **Annotated tags** (`refs/tags/*` that point at a *tag object*, not a commit
//! directly) are traversed for reachability but **not** repointed — the tag
//! object still references the old commit. They are reported in
//! [`PurgeReport::annotated_tags_skipped`]. Lightweight tags (direct-to-commit)
//! ARE repointed like branches.
//! * **On-disk GC** — the rewrite makes the old objects unreachable, but the
//! loose/packed objects still occupy `.git` until a repack+prune. gix 0.84 has
//! no stable high-level repack API, so `git gc --prune=now` (or
//! `git reflog expire --expire=now --all && git gc --prune=now`) finalizes the
//! on-disk reclamation. The **history is rewritten regardless**; only the byte
//! savings on disk wait for GC. [`PurgeReport::bytes_reclaimed`] is the exact
//! size of the now-unreachable blobs.
//! * **Reflogs** — old reflog entries still pin the pre-rewrite commits; expiring
//! them (`git reflog expire --expire=now --all`) is part of the same GC seam.
//! * **Empty commits** are NOT pruned — a commit whose only content was the purged
//! path is kept (with an empty/parent-equal tree) so the commit **count is
//! preserved**. (Documented rather than defaulted-on to avoid surprising
//! history shape changes.)
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use gix::bstr::{BString, ByteSlice};
// ── Reports ──────────────────────────────────────────────────────────────────
/// One blob's aggregate stats in [`ScanReport`].
#[derive(Debug, Clone)]
pub struct BlobStat {
/// The blob object id (hex via `Display`).
pub oid: gix::ObjectId,
/// Uncompressed object size in bytes (from the odb header — no full read).
pub size: u64,
/// One repo path this blob is stored at (first seen; illustrative).
pub example_path: String,
/// How many reachable commits contain this blob in their tree.
pub commit_count: usize,
}
/// Read-only scan result from [`scan_blobs`].
#[derive(Debug, Clone)]
pub struct ScanReport {
/// Distinct blob objects seen across all reachable history (after `--path`).
pub total_blobs: usize,
/// Sum of the distinct blobs' sizes (what a full clean of the filter could
/// reclaim if every matched blob became unreachable).
pub total_bytes: u64,
/// The `top_n` largest blobs, descending by size.
pub largest: Vec<BlobStat>,
/// Number of reachable commits walked.
pub commits_scanned: usize,
/// Number of refs (branch/tag/…) that resolved to a commit.
pub refs_scanned: usize,
}
/// Options for [`purge_paths`].
#[derive(Debug, Clone)]
pub struct PurgeOptions {
/// Exact repo paths to strip from ALL history, e.g. `["docs/book.pdf"]`.
/// Nested paths are supported (the tree editor descends component-by-
/// component and drops subtrees that become empty).
pub paths: Vec<String>,
/// `false` (default) = dry-run: report only, mutate nothing. `true` = perform
/// the rewrite (after writing the backup).
pub apply: bool,
}
/// Result of [`purge_paths`] (identical shape for dry-run and apply).
#[derive(Debug, Clone)]
pub struct PurgeReport {
/// Whether the rewrite was actually performed (`apply` was set and there was
/// something to do).
pub applied: bool,
/// `true` when nothing referenced any target path — a genuine no-op (this is
/// what a second, idempotent purge of the same path reports).
pub no_op: bool,
/// Total reachable commits considered.
pub commits_total: usize,
/// Commits whose object changed (tree stripped and/or a parent remapped).
pub commits_rewritten: usize,
/// Distinct blob objects that become unreachable (were referenced ONLY via a
/// purged path).
pub blobs_dropped: usize,
/// Sum of the dropped blobs' sizes — the exact on-disk reclamation a
/// subsequent `git gc --prune=now` will realize.
pub bytes_reclaimed: u64,
/// `(ref_name, old_sha, new_sha)` for every ref repointed (apply) or that
/// would be repointed (dry-run).
pub refs_updated: Vec<(String, String, String)>,
/// Annotated-tag refs left untouched (documented seam), by full ref name.
pub annotated_tags_skipped: Vec<String>,
/// On apply: the `refs/original/…` backup namespace prefix that now mirrors the
/// pre-rewrite ref tips.
pub backup_ref_prefix: Option<String>,
/// On apply: path to the plain-text backup file recording old ref→sha lines.
pub backup_file: Option<PathBuf>,
}
// ── Internal traversal ───────────────────────────────────────────────────────
/// A repointable ref (branch / lightweight tag / remote) resolved to its commit.
struct RepointRef {
name: gix::refs::FullName,
commit: gix::ObjectId,
}
/// Everything we learn from one pass over the refs.
struct RefsResolved {
/// Refs that point *directly* at a commit → repointable.
repointable: Vec<RepointRef>,
/// Full names of annotated-tag refs (point at a tag object) — a seam.
annotated_tags: Vec<String>,
/// The union of commit tips to traverse from (deduped).
tips: Vec<gix::ObjectId>,
}
/// Resolve every ref to a commit, classifying direct-commit refs (repointable)
/// vs annotated tags (a documented seam). Symbolic refs (e.g. `HEAD`) are skipped
/// — their branch is handled directly.
fn resolve_refs(repo: &gix::Repository) -> Result<RefsResolved> {
let mut repointable = Vec::new();
let mut annotated_tags = Vec::new();
let mut tip_set: HashSet<gix::ObjectId> = HashSet::new();
let platform = repo.references().context("open ref store")?;
for r in platform.all().context("iterate refs")? {
let mut r = match r {
Ok(r) => r,
Err(_) => continue,
};
// Skip symbolic refs (HEAD → refs/heads/x); the branch itself is visited.
if matches!(r.target(), gix::refs::TargetRef::Symbolic(_)) {
continue;
}
// Skip our own backup namespace: `refs/original/*` deliberately pins the
// PRE-rewrite tips for recovery, so it must not count as live history (else
// a re-run would see the blob "still reachable" and never be a no-op).
if r.name().as_bstr().starts_with(b"refs/original/") {
continue;
}
let direct = r.id().detach();
let peeled = match r.peel_to_id() {
Ok(id) => id.detach(),
Err(_) => continue, // not peelable to an object we can use
};
// Only commits are traversable tips; a ref peeling to a non-commit (e.g. a
// ref straight to a blob/tree) is ignored for history rewrite.
if repo.find_commit(peeled).is_err() {
continue;
}
tip_set.insert(peeled);
let name = r.name().to_owned();
if direct == peeled {
repointable.push(RepointRef {
name,
commit: peeled,
});
} else {
// direct target is a tag object that peeled to a commit → annotated tag.
annotated_tags.push(name.as_bstr().to_str_lossy().into_owned());
}
}
Ok(RefsResolved {
repointable,
annotated_tags,
tips: tip_set.into_iter().collect(),
})
}
/// Collect every commit reachable from `tips` in **parents-first** topological
/// order (a parent always precedes its children), iteratively (no recursion, so
/// deep histories don't overflow the stack).
fn topo_order_parents_first(
repo: &gix::Repository,
tips: &[gix::ObjectId],
) -> Result<Vec<gix::ObjectId>> {
let mut order: Vec<gix::ObjectId> = Vec::new();
let mut visited: HashSet<gix::ObjectId> = HashSet::new();
// (oid, children_expanded?) — post-order DFS: emit a node only after all its
// parents have been emitted.
let mut stack: Vec<(gix::ObjectId, bool)> = Vec::new();
for t in tips {
stack.push((*t, false));
}
while let Some((oid, expanded)) = stack.pop() {
if expanded {
order.push(oid);
continue;
}
if !visited.insert(oid) {
continue;
}
// Re-push self as "expanded" first so it is emitted AFTER its parents.
stack.push((oid, true));
let commit = repo
.find_commit(oid)
.with_context(|| format!("find commit {oid}"))?;
for pid in commit.parent_ids() {
let pid = pid.detach();
if !visited.contains(&pid) {
stack.push((pid, false));
}
}
}
Ok(order)
}
/// The blobs (as `(path-within-subtree, oid, size)`) at the leaves of the subtree
/// rooted at `tree_oid`, memoized by tree oid. The paths are relative to this
/// subtree, so the value is prefix-independent and safe to cache and reuse under
/// any parent path.
fn subtree_blobs<'a>(
repo: &gix::Repository,
tree_oid: gix::ObjectId,
cache: &'a mut HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>>,
) -> Result<&'a Vec<(BString, gix::ObjectId, u64)>> {
if !cache.contains_key(&tree_oid) {
let mut out: Vec<(BString, gix::ObjectId, u64)> = Vec::new();
let tree = repo
.find_tree(tree_oid)
.with_context(|| format!("find tree {tree_oid}"))?;
// Collect entries first so we don't hold the tree borrow across recursion.
let mut subdirs: Vec<(BString, gix::ObjectId)> = Vec::new();
for entry in tree.iter() {
let entry = entry.context("decode tree entry")?;
let name = entry.inner.filename.to_owned();
let oid = entry.inner.oid.to_owned();
let mode = entry.inner.mode;
if mode.is_tree() {
subdirs.push((name, oid));
} else if mode.is_blob() || mode.is_link() {
let size = repo.find_header(oid).map(|h| h.size()).unwrap_or(0);
out.push((name, oid, size));
}
// gitlinks (commit entries) reference no blob — skipped.
}
for (dname, doid) in subdirs {
// Recurse (fills cache for the child), then prefix names.
let child = subtree_blobs(repo, doid, cache)?.clone();
for (cpath, coid, csize) in child {
let mut full = dname.clone();
full.push(b'/');
full.extend_from_slice(&cpath);
out.push((full, coid, csize));
}
}
cache.insert(tree_oid, out);
}
Ok(cache.get(&tree_oid).expect("just inserted"))
}
// ── scan ─────────────────────────────────────────────────────────────────────
/// Read-only blob scan over ALL reachable history — "what would a clean reclaim".
///
/// Walks every ref's ancestry, enumerates blobs and their sizes, and reports the
/// `top_n` largest (size, an example path, commits referencing it) plus the
/// total. `path_filter` (a simple glob supporting `*`/`?`, `*` spanning `/`)
/// narrows the report to matching repo paths; `None` = everything.
pub fn scan_blobs(repo_root: &Path, path_filter: Option<&str>, top_n: usize) -> Result<ScanReport> {
let repo =
gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;
let refs = resolve_refs(&repo)?;
let commits = topo_order_parents_first(&repo, &refs.tips)?;
let mut cache: HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>> = HashMap::new();
// blob oid → (size, example_path, set-of-commit-indices count)
let mut agg: HashMap<gix::ObjectId, (u64, String, usize)> = HashMap::new();
for &c in &commits {
let commit = repo
.find_commit(c)
.with_context(|| format!("find commit {c}"))?;
let tree_oid = commit.tree_id().context("commit tree id")?.detach();
let blobs = subtree_blobs(&repo, tree_oid, &mut cache)?.clone();
// Dedup blob oids within this one commit so commit_count is per-commit.
let mut seen_here: HashSet<gix::ObjectId> = HashSet::new();
for (path, oid, size) in blobs {
let path_str = path.to_str_lossy();
if let Some(f) = path_filter {
if !glob_match(f, &path_str) {
continue;
}
}
let e = agg
.entry(oid)
.or_insert_with(|| (size, path_str.clone().into_owned(), 0));
if seen_here.insert(oid) {
e.2 += 1;
}
}
}
let total_blobs = agg.len();
let total_bytes: u64 = agg.values().map(|(s, _, _)| *s).sum();
let mut largest: Vec<BlobStat> = agg
.into_iter()
.map(|(oid, (size, example_path, commit_count))| BlobStat {
oid,
size,
example_path,
commit_count,
})
.collect();
largest.sort_by(|a, b| b.size.cmp(&a.size).then(a.oid.cmp(&b.oid)));
largest.truncate(top_n);
Ok(ScanReport {
total_blobs,
total_bytes,
largest,
commits_scanned: commits.len(),
refs_scanned: refs.repointable.len() + refs.annotated_tags.len(),
})
}
// ── purge ────────────────────────────────────────────────────────────────────
/// Deep-clean: strip `opts.paths` from every commit across every ref.
///
/// Dry-run by default (`opts.apply == false`) — computes and returns the exact
/// report (commits to rewrite, blobs/bytes reclaimed, refs to repoint) without
/// mutating anything. With `opts.apply == true` it writes a backup, rewrites the
/// history parents-first, repoints branch/lightweight-tag refs, and reconciles the
/// worktree/index with the new `HEAD`.
///
/// Idempotent: a second purge of the same path finds nothing to strip and reports
/// `no_op = true`, changing no ref.
pub fn purge_paths(repo_root: &Path, opts: &PurgeOptions) -> Result<PurgeReport> {
if opts.paths.is_empty() {
bail!("purge: no --path given (nothing to remove)");
}
// Normalize: drop leading "./" and "/", reject empty.
let targets: Vec<String> = opts
.paths
.iter()
.map(|p| {
p.trim()
.trim_start_matches("./")
.trim_start_matches('/')
.to_string()
})
.collect();
if targets.iter().any(|p| p.is_empty()) {
bail!("purge: empty path component in --path");
}
let target_set: HashSet<&str> = targets.iter().map(|s| s.as_str()).collect();
let mut repo =
gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;
// Ref updates below write reflog entries, which need a committer identity. A
// freshly-`init`'d repo has none; inject gix's generic in-memory fallback so
// the repoint/backup ref edits succeed without ambient git config (mirrors
// `gitio::ssh_sync`). No-op when a real identity is already configured.
let _ = repo.committer_or_set_generic_fallback();
let refs = resolve_refs(&repo)?;
let commits = topo_order_parents_first(&repo, &refs.tips)?;
// Pass 1 (read-only): per-commit "does its tree hold any target path", plus
// the blob-reclaim math (a blob is reclaimed iff it appears ONLY at target
// paths anywhere in history).
let mut cache: HashMap<gix::ObjectId, Vec<(BString, gix::ObjectId, u64)>> = HashMap::new();
let mut tree_hits: HashMap<gix::ObjectId, bool> = HashMap::new(); // commit oid → tree contains a target
let mut target_blobs: HashMap<gix::ObjectId, u64> = HashMap::new(); // blob → size (seen at a target path)
let mut kept_blobs: HashSet<gix::ObjectId> = HashSet::new(); // blob seen at a NON-target path
for &c in &commits {
let commit = repo
.find_commit(c)
.with_context(|| format!("find commit {c}"))?;
let tree_oid = commit.tree_id().context("commit tree id")?.detach();
let blobs = subtree_blobs(&repo, tree_oid, &mut cache)?.clone();
let mut hit = false;
for (path, oid, size) in blobs {
let path_str = path.to_str_lossy();
if target_set.contains(path_str.as_ref()) {
hit = true;
target_blobs.insert(oid, size);
} else {
kept_blobs.insert(oid);
}
}
tree_hits.insert(c, hit);
}
let reclaimed: Vec<(gix::ObjectId, u64)> = target_blobs
.iter()
.filter(|(oid, _)| !kept_blobs.contains(*oid))
.map(|(o, s)| (*o, *s))
.collect();
let bytes_reclaimed: u64 = reclaimed.iter().map(|(_, s)| *s).sum();
// Pass 2: propagate "changed" parents-first. A commit is rewritten if its tree
// holds a target OR any parent was rewritten (so its parent link moves).
let mut changed: HashMap<gix::ObjectId, bool> = HashMap::new();
for &c in &commits {
let commit = repo.find_commit(c)?;
let tree_hit = *tree_hits.get(&c).unwrap_or(&false);
let parent_changed = commit
.parent_ids()
.any(|p| *changed.get(&p.detach()).unwrap_or(&false));
changed.insert(c, tree_hit || parent_changed);
}
let commits_rewritten = changed.values().filter(|v| **v).count();
// Which repointable refs would move (tip is a changed commit).
let would_move: Vec<&RepointRef> = refs
.repointable
.iter()
.filter(|r| *changed.get(&r.commit).unwrap_or(&false))
.collect();
let no_op = commits_rewritten == 0;
// ── Dry-run: report and stop. ──
if !opts.apply {
let refs_updated = would_move
.iter()
.map(|r| {
(
r.name.as_bstr().to_str_lossy().into_owned(),
r.commit.to_string(),
"(dry-run)".to_string(),
)
})
.collect();
return Ok(PurgeReport {
applied: false,
no_op,
commits_total: commits.len(),
commits_rewritten,
blobs_dropped: reclaimed.len(),
bytes_reclaimed,
refs_updated,
annotated_tags_skipped: refs.annotated_tags,
backup_ref_prefix: None,
backup_file: None,
});
}
// ── Apply. ──
if no_op {
// Nothing references a target path — a true idempotent no-op. Don't write a
// backup or touch a single ref.
return Ok(PurgeReport {
applied: false,
no_op: true,
commits_total: commits.len(),
commits_rewritten: 0,
blobs_dropped: 0,
bytes_reclaimed: 0,
refs_updated: Vec::new(),
annotated_tags_skipped: refs.annotated_tags,
backup_ref_prefix: None,
backup_file: None,
});
}
// 1) Backup FIRST: record old ref SHAs to a file AND mirror every repointable
// ref under `refs/original/…` so the pre-rewrite tips stay recoverable.
let (backup_file, backup_ref_prefix) = write_backup(&repo, &refs.repointable)?;
// 2) Rewrite every commit parents-first, threading the old→new map.
let mut map: HashMap<gix::ObjectId, gix::ObjectId> = HashMap::new();
for &c in &commits {
let is_changed = *changed.get(&c).unwrap_or(&false);
if !is_changed {
map.insert(c, c); // byte-identical → same oid (nothing to write)
continue;
}
let new_oid = rewrite_commit(
&repo,
c,
&targets,
&map,
*tree_hits.get(&c).unwrap_or(&false),
)?;
map.insert(c, new_oid);
}
// 3) Repoint refs (branches + lightweight tags). Annotated tags are a seam.
let mut refs_updated = Vec::new();
let mut edits = Vec::new();
for r in &refs.repointable {
let new = *map.get(&r.commit).unwrap_or(&r.commit);
if new == r.commit {
continue;
}
edits.push(update_ref_edit(r.name.clone(), new));
refs_updated.push((
r.name.as_bstr().to_str_lossy().into_owned(),
r.commit.to_string(),
new.to_string(),
));
}
if !edits.is_empty() {
repo.edit_references(edits)
.context("repoint refs to rewritten tips")?;
}
// 4) Reconcile the worktree/index with the new HEAD (non-bare repos only).
reconcile_worktree(repo_root, &targets)?;
Ok(PurgeReport {
applied: true,
no_op: false,
commits_total: commits.len(),
commits_rewritten,
blobs_dropped: reclaimed.len(),
bytes_reclaimed,
refs_updated,
annotated_tags_skipped: refs.annotated_tags,
backup_ref_prefix: Some(backup_ref_prefix),
backup_file: Some(backup_file),
})
}
/// Rebuild commit `c` with the target paths stripped and parents remapped through
/// `map`, write the new commit object, and return its oid. The new commit drops
/// any `gpgsig` (a rewrite invalidates it — "gpg-less"), but preserves author,
/// committer, message, encoding and all other extra headers.
fn rewrite_commit(
repo: &gix::Repository,
c: gix::ObjectId,
targets: &[String],
map: &HashMap<gix::ObjectId, gix::ObjectId>,
tree_hit: bool,
) -> Result<gix::ObjectId> {
let commit = repo
.find_commit(c)
.with_context(|| format!("find commit {c}"))?;
let old_tree = commit.tree_id().context("commit tree id")?.detach();
// New tree: strip each target path (no-op if absent — idempotent).
let new_tree = if tree_hit {
let mut editor = repo.edit_tree(old_tree).context("open tree editor")?;
for p in targets {
editor
.remove(p.as_str())
.with_context(|| format!("tree remove {p}"))?;
}
editor.write().context("write rewritten tree")?.detach()
} else {
old_tree
};
// Remap parents.
let new_parents: Vec<gix::ObjectId> = commit
.parent_ids()
.map(|p| *map.get(&p.detach()).unwrap_or(&p.detach()))
.collect();
// Extract owned author/committer/message/encoding/extra-headers.
let decoded = commit.decode().context("decode commit")?;
let author: gix::actor::Signature = decoded.author().context("decode author")?.into();
let committer: gix::actor::Signature = decoded.committer().context("decode committer")?.into();
let message: BString = decoded.message.to_owned();
let encoding: Option<BString> = decoded.encoding.map(|e| e.to_owned());
let extra_headers: Vec<(BString, BString)> = decoded
.extra_headers
.iter()
.filter(|(k, _)| k.as_bytes() != b"gpgsig")
.map(|(k, v)| ((*k).to_owned(), v.as_ref().to_owned()))
.collect();
let new_commit = gix::objs::Commit {
tree: new_tree,
parents: new_parents.into_iter().collect(),
author,
committer,
encoding,
message,
extra_headers,
};
let id = repo
.write_object(&new_commit)
.context("write rewritten commit")?
.detach();
Ok(id)
}
/// Build a force-update `RefEdit` pointing `name` at `new` (with a reflog note).
fn update_ref_edit(
name: gix::refs::FullName,
new: gix::ObjectId,
) -> gix::refs::transaction::RefEdit {
use gix::refs::Target;
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "nornir: deep-clean history rewrite".into(),
},
expected: PreviousValue::Any,
new: Target::Object(new),
},
name,
deref: false,
}
}
/// Write the pre-rewrite backup: a plain-text `old ref → sha` file under `.git/`
/// AND a `refs/original/<full ref>` mirror of every repointable tip. Returns
/// `(file_path, ref_prefix)`.
fn write_backup(repo: &gix::Repository, repointable: &[RepointRef]) -> Result<(PathBuf, String)> {
use gix::refs::Target;
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
let git_dir = repo.git_dir();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let file = git_dir.join(format!("nornir-deepclean-backup-{stamp}.txt"));
let mut body = String::from("# nornir deep-clean backup — pre-rewrite ref tips\n");
let mut edits = Vec::new();
for r in repointable {
let name = r.name.as_bstr().to_str_lossy();
body.push_str(&format!("{} {}\n", r.commit, name));
// refs/original/refs/heads/foo (git-filter-repo convention).
let backup_name = format!("refs/original/{name}");
if let Ok(full) = gix::refs::FullName::try_from(backup_name.as_str()) {
edits.push(RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "nornir: deep-clean backup".into(),
},
// Don't clobber a backup from an earlier run.
expected: PreviousValue::MustNotExist,
new: Target::Object(r.commit),
},
name: full,
deref: false,
});
}
}
std::fs::write(&file, body).with_context(|| format!("write backup file {}", file.display()))?;
if !edits.is_empty() {
// A pre-existing backup ref (earlier partial run) is fine — ignore the
// clobber error rather than abort the whole clean.
let _ = repo.edit_references(edits);
}
Ok((file, "refs/original/".to_string()))
}
/// Reconcile the worktree + index with the new `HEAD`: delete any purged files
/// still on disk and rebuild the index from the new HEAD tree, so `git status` is
/// clean. Bare repos (no worktree) are a no-op.
fn reconcile_worktree(repo_root: &Path, targets: &[String]) -> Result<()> {
let repo = gix::open(repo_root).context("reopen for worktree reconcile")?;
let Some(work_dir) = repo.workdir().map(|p| p.to_path_buf()) else {
return Ok(()); // bare repo
};
for p in targets {
let f = work_dir.join(p);
if f.exists() {
let _ = std::fs::remove_file(&f);
}
}
if let Ok(head) = repo.head_commit() {
if let Ok(tree) = head.tree_id() {
let tree = tree.detach();
if let Ok(mut index) = repo.index_from_tree(&tree) {
index
.write(gix::index::write::Options::default())
.context("write reconciled index")?;
}
}
}
Ok(())
}
// ── tiny glob (no new dep) ───────────────────────────────────────────────────
/// Minimal glob match for `--path` filters: `*` matches any run of chars
/// (including `/`), `?` matches one char, everything else is literal. Enough for
/// `docs/book.pdf`, `*.pdf`, `docs/*`.
fn glob_match(pattern: &str, text: &str) -> bool {
fn m(p: &[u8], t: &[u8]) -> bool {
if p.is_empty() {
return t.is_empty();
}
match p[0] {
b'*' => {
// Try to consume zero-or-more chars of `t`.
if m(&p[1..], t) {
return true;
}
!t.is_empty() && m(p, &t[1..])
}
b'?' => !t.is_empty() && m(&p[1..], &t[1..]),
c => !t.is_empty() && t[0] == c && m(&p[1..], &t[1..]),
}
}
m(pattern.as_bytes(), text.as_bytes())
}
// ── tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Return the verdict so the caller can `assert!` on it. (In nornir the
/// matrix-row emit lives at the call sites that re-export this crate; the
/// standalone crate keeps its tests dependency-free.)
fn emit(check: &str, ok: bool, detail: &str) -> bool {
let _ = (check, detail);
ok
}
/// Build a throwaway repo with a keeper file, a big blob committed at
/// `docs/book.pdf` in commit 2, and more commits after. Returns (tempdir,
/// big-blob-size, big-blob-oid-hex).
fn make_repo() -> (tempfile::TempDir, u64, String) {
let td = tempfile::tempdir().expect("tempdir");
let root = td.path();
crate::gitio::init(root).expect("init");
// Commit 1: keeper only.
std::fs::write(root.join("README.md"), b"# keeper\nhello\n").unwrap();
crate::gitio::commit_all(root, "c1: readme").unwrap();
// Commit 2: add a big blob at docs/book.pdf (nested path) + a source file.
std::fs::create_dir_all(root.join("docs")).unwrap();
let big = vec![0x42u8; 200_000];
std::fs::write(root.join("docs/book.pdf"), &big).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
crate::gitio::commit_all(root, "c2: add book.pdf + src").unwrap();
// Commit 3: modify keeper, book.pdf still present.
std::fs::write(root.join("README.md"), b"# keeper\nhello world\n").unwrap();
crate::gitio::commit_all(root, "c3: edit readme").unwrap();
// Commit 4: modify src, book.pdf still present.
std::fs::write(
root.join("src/main.rs"),
b"fn main() { println!(\"hi\"); }\n",
)
.unwrap();
crate::gitio::commit_all(root, "c4: edit src").unwrap();
// Compute the big blob oid the way git would (blob hash of the content).
let repo = gix::open(root).unwrap();
let oid = repo.write_blob(&big).unwrap().detach(); // content-addressed → same as committed
(td, big.len() as u64, oid.to_string())
}
/// Walk every ref's ancestry and return the set of (path, blob-oid) tree
/// entries + the set of all blob oids reachable.
fn all_entries(root: &Path) -> (HashSet<String>, HashSet<String>) {
let repo = gix::open(root).unwrap();
let refs = resolve_refs(&repo).unwrap();
let commits = topo_order_parents_first(&repo, &refs.tips).unwrap();
let mut cache = HashMap::new();
let mut paths = HashSet::new();
let mut blobs = HashSet::new();
for c in commits {
let commit = repo.find_commit(c).unwrap();
let tree = commit.tree_id().unwrap().detach();
for (p, oid, _) in super::subtree_blobs(&repo, tree, &mut cache)
.unwrap()
.clone()
{
paths.insert(p.to_str_lossy().into_owned());
blobs.insert(oid.to_string());
}
}
(paths, blobs)
}
/// Map: commit message → tree entry paths, to assert content survives.
fn snapshot(root: &Path) -> Vec<(String, Vec<String>)> {
let repo = gix::open(root).unwrap();
let refs = resolve_refs(&repo).unwrap();
let commits = topo_order_parents_first(&repo, &refs.tips).unwrap();
let mut cache = HashMap::new();
let mut out = Vec::new();
for c in commits {
let commit = repo.find_commit(c).unwrap();
let msg = commit
.message_raw()
.unwrap()
.to_str_lossy()
.trim()
.to_string();
let tree = commit.tree_id().unwrap().detach();
let mut paths: Vec<String> = super::subtree_blobs(&repo, tree, &mut cache)
.unwrap()
.iter()
.map(|(p, _, _)| p.to_str_lossy().into_owned())
.collect();
paths.sort();
out.push((msg, paths));
}
out
}
#[test]
fn scan_reports_big_blob_as_largest() {
let (td, size, oid) = make_repo();
let report = scan_blobs(td.path(), None, 5).unwrap();
assert!(!report.largest.is_empty(), "scan found no blobs");
let top = &report.largest[0];
let ok = top.oid == oid && top.size == size;
assert!(
emit(
"scan_largest_is_book_pdf",
ok,
&format!(
"top oid={} size={} example={}",
top.oid, top.size, top.example_path
)
),
"expected big blob {oid} ({size}B) as largest, got {} ({}B)",
top.oid,
top.size
);
// The big blob is referenced by commits 2,3,4 → 3 commits.
assert_eq!(top.commit_count, 3, "book.pdf should be in 3 commits");
assert!(top.example_path.ends_with("book.pdf"));
}
#[test]
fn scan_path_filter_narrows() {
let (td, _size, _oid) = make_repo();
let all = scan_blobs(td.path(), None, 100).unwrap();
let pdf = scan_blobs(td.path(), Some("docs/book.pdf"), 100).unwrap();
assert!(
all.total_blobs > pdf.total_blobs,
"filter should shrink the set"
);
assert_eq!(pdf.total_blobs, 1, "only book.pdf matches");
}
#[test]
fn dry_run_mutates_nothing_and_reports() {
let (td, size, _oid) = make_repo();
let before = all_entries(td.path());
let rep = purge_paths(
td.path(),
&PurgeOptions {
paths: vec!["docs/book.pdf".into()],
apply: false,
},
)
.unwrap();
let after = all_entries(td.path());
assert_eq!(before.0, after.0, "dry-run must not change history");
assert!(!rep.applied && !rep.no_op);
assert_eq!(
rep.bytes_reclaimed, size,
"dry-run bytes must equal blob size"
);
assert_eq!(rep.blobs_dropped, 1);
assert!(rep.commits_rewritten >= 3, "commits 2..4 hold the blob");
}
#[test]
fn purge_removes_blob_from_all_history() {
let (td, size, oid) = make_repo();
let before = snapshot(td.path());
let commit_count_before = before.len();
let rep = purge_paths(
td.path(),
&PurgeOptions {
paths: vec!["docs/book.pdf".into()],
apply: true,
},
)
.unwrap();
assert!(rep.applied, "apply should perform the rewrite");
// (a) blob gone from ALL reachable trees; no tree entry named book.pdf.
let (paths, blobs) = all_entries(td.path());
let no_pdf_path = !paths.iter().any(|p| p.ends_with("book.pdf"));
let blob_unreachable = !blobs.contains(&oid);
assert!(
emit(
"blob_purged",
no_pdf_path && blob_unreachable,
&format!(
"paths_with_pdf={} blob_present={}",
!no_pdf_path, !blob_unreachable
)
),
"book.pdf still reachable: path_gone={no_pdf_path} blob_gone={blob_unreachable}"
);
// (b) keeper + src survive byte-identical, commit count + messages preserved.
let after = snapshot(td.path());
assert_eq!(
after.len(),
commit_count_before,
"commit count must be preserved"
);
let messages_before: Vec<&String> = before.iter().map(|(m, _)| m).collect();
let messages_after: Vec<&String> = after.iter().map(|(m, _)| m).collect();
assert_eq!(messages_before, messages_after, "messages/order preserved");
// README.md + src/main.rs present in every commit that had them; docs/book.pdf gone everywhere.
let keeper_ok = after
.iter()
.all(|(_, ps)| !ps.iter().any(|p| p.ends_with("book.pdf")))
&& after
.iter()
.any(|(_, ps)| ps.iter().any(|p| p == "README.md"))
&& after
.iter()
.any(|(_, ps)| ps.iter().any(|p| p == "src/main.rs"));
assert!(
emit(
"keeper_survived",
keeper_ok,
"README.md + src/main.rs preserved, book.pdf gone"
),
"keeper content missing"
);
// Verify the actual keeper blob bytes are byte-identical in the new HEAD.
let repo = gix::open(td.path()).unwrap();
let head_tree = repo.head_commit().unwrap().tree().unwrap();
let readme = head_tree
.lookup_entry_by_path("README.md")
.unwrap()
.unwrap();
let data = repo.find_object(readme.id()).unwrap().data.clone();
assert_eq!(data, b"# keeper\nhello world\n", "README bytes changed");
assert_eq!(rep.bytes_reclaimed, size);
assert_eq!(rep.blobs_dropped, 1);
assert!(rep.backup_ref_prefix.is_some(), "apply must write a backup");
assert!(
rep.backup_file.as_ref().unwrap().exists(),
"backup file must exist"
);
}
#[test]
fn purge_is_idempotent() {
let (td, _size, _oid) = make_repo();
purge_paths(
td.path(),
&PurgeOptions {
paths: vec!["docs/book.pdf".into()],
apply: true,
},
)
.unwrap();
let after_first = snapshot(td.path());
let rep2 = purge_paths(
td.path(),
&PurgeOptions {
paths: vec!["docs/book.pdf".into()],
apply: true,
},
)
.unwrap();
let after_second = snapshot(td.path());
let noop = rep2.no_op
&& !rep2.applied
&& rep2.commits_rewritten == 0
&& after_first == after_second;
assert!(
emit(
"idempotent",
noop,
&format!("no_op={} rewritten={}", rep2.no_op, rep2.commits_rewritten)
),
"second purge should be a no-op, got {rep2:?}"
);
}
#[test]
fn glob_match_basics() {
assert!(glob_match("docs/book.pdf", "docs/book.pdf"));
assert!(glob_match("*.pdf", "docs/book.pdf"));
assert!(glob_match("docs/*", "docs/book.pdf"));
assert!(!glob_match("docs/book.pdf", "src/main.rs"));
assert!(glob_match("src/?ain.rs", "src/main.rs"));
}
}