Merge remote-tracking branch 'origin/pr/1126' into work/v0.8.34

# Conflicts:
#	crates/tui/src/core/turn.rs
This commit is contained in:
Hunter Bown
2026-05-12 23:19:23 -05:00
3 changed files with 127 additions and 6 deletions
+13 -6
View File
@@ -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<String> {
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
+4
View File
@@ -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,
+110
View File
@@ -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<usize> {
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<String> = 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();