Merge remote-tracking branch 'origin/pr/1126' into work/v0.8.34
# Conflicts: # crates/tui/src/core/turn.rs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user