diff --git a/crates/tui/src/core/turn.rs b/crates/tui/src/core/turn.rs index c845adb3..049bc44a 100644 --- a/crates/tui/src/core/turn.rs +++ b/crates/tui/src/core/turn.rs @@ -160,13 +160,20 @@ pub fn post_turn_snapshot(workspace: &Path, turn_seq: u64, cap_bytes: u64) -> Op fn snapshot_with_label(workspace: &Path, label: &str, cap_bytes: u64) -> Option { match SnapshotRepo::open_or_init_with_cap(workspace, cap_bytes) { - Ok(repo) => match repo.snapshot(label) { - Ok(id) => Some(id.0), - Err(e) => { - tracing::warn!(target: "snapshot", "snapshot '{label}' failed: {e}"); - None + Ok(repo) => { + let id = match repo.snapshot(label) { + Ok(id) => Some(id.0), + Err(e) => { + tracing::warn!(target: "snapshot", "snapshot '{label}' failed: {e}"); + return None; + } + }; + // Prune oldest snapshots to cap disk usage (#1112). + if let Err(e) = repo.prune_keep_last_n(crate::snapshot::DEFAULT_MAX_SNAPSHOTS) { + tracing::warn!(target: "snapshot", "snapshot prune failed: {e}"); } - }, + id + } Err(e) => { tracing::warn!(target: "snapshot", "snapshot repo init failed: {e}"); None diff --git a/crates/tui/src/snapshot/mod.rs b/crates/tui/src/snapshot/mod.rs index 51ea6419..08cf3e09 100644 --- a/crates/tui/src/snapshot/mod.rs +++ b/crates/tui/src/snapshot/mod.rs @@ -40,6 +40,10 @@ pub mod repo; #[allow(unused_imports)] pub use paths::{snapshot_dir_for, snapshot_git_dir}; pub use prune::{DEFAULT_MAX_AGE, prune_older_than}; + +/// Maximum snapshots kept per workspace side-repo. Oldest are pruned +/// after each new snapshot to cap disk usage (#1112). +pub const DEFAULT_MAX_SNAPSHOTS: usize = 50; #[allow(unused_imports)] pub use repo::{ DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT, Snapshot, SnapshotId, SnapshotRepo, diff --git a/crates/tui/src/snapshot/repo.rs b/crates/tui/src/snapshot/repo.rs index d1c2cfae..8a555bec 100644 --- a/crates/tui/src/snapshot/repo.rs +++ b/crates/tui/src/snapshot/repo.rs @@ -624,6 +624,60 @@ impl SnapshotRepo { Ok(removed) } + /// Keep only the latest `max_count` snapshots, dropping older ones. + /// + /// Uses `commit-tree` with no `-p` to create a true orphan commit at + /// the eldest survivor's tree, preserving its label. The old chain + /// has zero refs after gc and is physically reclaimed. + /// Keep only the latest `max_count` snapshots by rebuilding the + /// survivor chain as orphan commits. Each survivor's tree and label + /// are preserved — only the parent chain to older snapshots is cut. + /// Old objects become unreachable and gc reclaims them. + pub fn prune_keep_last_n(&self, max_count: usize) -> io::Result { + let snapshots = self.list(usize::MAX)?; + if snapshots.len() <= max_count { + return Ok(0); + } + let keep = max_count; + let removed = snapshots.len() - keep; + // snapshots are newest-first: [0..keep-1] are the survivors. + // Rebuild the chain from oldest survivor → newest, each as a + // commit-tree with the original tree but no link to the old chain. + let mut prev_sha: Option = None; + + for i in (0..keep).rev() { + let s = &snapshots[i]; + let tree = run_git(&self.git_dir, &self.work_tree, &["rev-parse", &format!("{}^{{tree}}", s.id.as_str())])?; + if !tree.status.success() { + return Err(io_other(format!("rev-parse {}^{{tree}} failed: {}", s.id.as_str(), String::from_utf8_lossy(&tree.stderr).trim()))); + } + let tree_hash = String::from_utf8_lossy(&tree.stdout).trim().to_string(); + + let mut args = vec!["commit-tree".to_string(), "-m".to_string(), s.label.clone(), tree_hash]; + if let Some(ref p) = prev_sha { + args.push("-p".to_string()); + args.push(p.clone()); + } + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let newc = run_git(&self.git_dir, &self.work_tree, &arg_refs)?; + if !newc.status.success() { + return Err(io_other(format!("commit-tree failed: {}", String::from_utf8_lossy(&newc.stderr).trim()))); + } + let new_sha = String::from_utf8_lossy(&newc.stdout).trim().to_string(); + prev_sha = Some(new_sha); + } + + if let Some(final_sha) = prev_sha { + let up = run_git(&self.git_dir, &self.work_tree, &["update-ref", "HEAD", &final_sha])?; + if !up.status.success() { + return Err(io_other(format!("update-ref HEAD failed: {}", String::from_utf8_lossy(&up.stderr).trim()))); + } + } + let _ = run_git(&self.git_dir, &self.work_tree, &["reflog", "expire", "--expire=now", "--all"]); + let _ = run_git(&self.git_dir, &self.work_tree, &["gc", "--prune=now", "--quiet"]); + Ok(removed) + } + /// Drop unreachable loose objects left behind by interrupted or /// orphaned side-repo operations. pub fn prune_unreachable_objects(&self) -> io::Result<()> { @@ -1060,6 +1114,62 @@ mod tests { assert_eq!(list[0].label, "turn:1"); } + #[test] + fn prune_keep_last_n_keeps_latest_and_gc_reclaims_rest() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + + for i in 0..3 { + std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + std::thread::sleep(Duration::from_millis(1100)); + } + + assert_eq!(repo.list(usize::MAX).unwrap().len(), 3); + + let removed = repo.prune_keep_last_n(1).unwrap(); + assert_eq!(removed, 2); + + let remaining = repo.list(usize::MAX).unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].label, "turn:2"); + + // New snapshot starts a clean chain (not appending to old). + std::fs::write(repo.work_tree().join("f.txt"), "fresh").unwrap(); + repo.snapshot("turn:new").unwrap(); + assert_eq!(repo.list(usize::MAX).unwrap().len(), 2); + } + + #[test] + fn prune_keep_last_n_preserves_multiple_snapshots_in_order() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + + for i in 0..4 { + std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + std::thread::sleep(Duration::from_millis(1100)); + } + + assert_eq!(repo.list(usize::MAX).unwrap().len(), 4); + + let removed = repo.prune_keep_last_n(2).unwrap(); + assert_eq!(removed, 2); + + let remaining = repo.list(usize::MAX).unwrap(); + assert_eq!(remaining.len(), 2); + // Should be newest-first: turn:3 (newest), turn:2 (second newest) + assert_eq!(remaining[0].label, "turn:3"); + assert_eq!(remaining[1].label, "turn:2"); + + // New snapshot continues the chain. + std::fs::write(repo.work_tree().join("f.txt"), "fresh").unwrap(); + repo.snapshot("turn:new").unwrap(); + let after = repo.list(usize::MAX).unwrap(); + assert_eq!(after.len(), 3); + assert_eq!(after[0].label, "turn:new"); + } + #[test] fn open_or_init_removes_stale_tmp_pack_files_only() { let tmp = tempdir().unwrap();