From 34357560c8a355cc1e1649d8f6c5210215a56ca9 Mon Sep 17 00:00:00 2001 From: axobase001 <138223345+axobase001@users.noreply.github.com> Date: Fri, 8 May 2026 01:43:53 +0800 Subject: [PATCH] fix(snapshot): clean stale git pack temps + prune unreachable objects (#1055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two snapshot-store improvements that together address #975 (≈30 GB+ disk consumed by stale snapshot temp files): 1. **Startup cleanup** of `tmp_pack_*` files left by interrupted git pack operations — a side-repo crash or sigkill mid-snapshot can leave hundreds of these orphaned in `.deepseek/snapshots/objects/pack/`. 2. **Prune unreachable objects** during the regular snapshot prune cycle so loose objects from rolled-back snapshots don't accumulate. Closes #975. Thanks to @axobase001 — the 60-minute staleness threshold is the right balance between cleanup eagerness and not interfering with an actively-running git pack. --- crates/tui/src/snapshot/mod.rs | 2 + crates/tui/src/snapshot/prune.rs | 4 +- crates/tui/src/snapshot/repo.rs | 98 ++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/snapshot/mod.rs b/crates/tui/src/snapshot/mod.rs index 82b0ab61..69550cda 100644 --- a/crates/tui/src/snapshot/mod.rs +++ b/crates/tui/src/snapshot/mod.rs @@ -23,6 +23,8 @@ //! - `gc.auto = 0` on the side repo (we don't want background gcs //! firing mid-turn) plus an explicit `git gc --prune=now` after //! prune. +//! - Startup cleanup for stale `tmp_pack_*` files left by interrupted +//! git pack operations. //! //! ## Failure model //! diff --git a/crates/tui/src/snapshot/prune.rs b/crates/tui/src/snapshot/prune.rs index 3ad67893..6953941b 100644 --- a/crates/tui/src/snapshot/prune.rs +++ b/crates/tui/src/snapshot/prune.rs @@ -24,7 +24,9 @@ pub fn prune_older_than(workspace: &Path, max_age: Duration) -> io::Result io::Result<()> { + let prune = run_git(&self.git_dir, &self.work_tree, &["prune", "--expire=now"])?; + if !prune.status.success() { + return Err(io_other(format!( + "git prune failed: {}", + String::from_utf8_lossy(&prune.stderr).trim() + ))); + } + Ok(()) + } + /// Return the side-repo's `.git` directory (for diagnostics). #[allow(dead_code)] pub fn git_dir(&self) -> &Path { @@ -478,6 +499,53 @@ fn write_builtin_excludes(git_dir: &Path) -> io::Result<()> { std::fs::write(info_dir.join("exclude"), BUILTIN_EXCLUDES) } +fn cleanup_stale_pack_temps(git_dir: &Path, stale_age: Duration) -> io::Result { + let pack_dir = git_dir.join("objects").join("pack"); + if !pack_dir.exists() { + return Ok(0); + } + cleanup_stale_pack_temps_in(&pack_dir, stale_age, SystemTime::now()) +} + +fn cleanup_stale_pack_temps_in( + pack_dir: &Path, + stale_age: Duration, + now: SystemTime, +) -> io::Result { + let mut removed = 0; + for entry in std::fs::read_dir(pack_dir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !name.starts_with("tmp_pack_") { + continue; + } + if !entry.file_type()?.is_file() { + continue; + } + + let metadata = entry.metadata()?; + let Ok(modified) = metadata.modified() else { + continue; + }; + let Ok(age) = now.duration_since(modified) else { + continue; + }; + if age < stale_age { + continue; + } + + match std::fs::remove_file(entry.path()) { + Ok(()) => removed += 1, + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + } + Ok(removed) +} + fn run_git(git_dir: &Path, work_tree: &Path, args: &[&str]) -> io::Result { Command::new("git") .arg("--git-dir") @@ -554,6 +622,7 @@ fn is_safe_relative_path(path: &Path) -> bool { mod tests { use super::*; use crate::test_support::lock_test_env; + use std::fs::{File, FileTimes}; use std::sync::MutexGuard; use tempfile::tempdir; @@ -762,6 +831,35 @@ mod tests { assert_eq!(list[0].label, "turn:1"); } + #[test] + fn open_or_init_removes_stale_tmp_pack_files_only() { + let tmp = tempdir().unwrap(); + let (repo, _home) = make_repo(tmp.path()); + let workspace = repo.work_tree().to_path_buf(); + let pack_dir = repo.git_dir().join("objects").join("pack"); + std::fs::create_dir_all(&pack_dir).unwrap(); + + let stale = pack_dir.join("tmp_pack_stale"); + let fresh = pack_dir.join("tmp_pack_fresh"); + let ordinary_pack = pack_dir.join("pack-kept.pack"); + std::fs::write(&stale, b"stale").unwrap(); + std::fs::write(&fresh, b"fresh").unwrap(); + std::fs::write(&ordinary_pack, b"pack").unwrap(); + + let old_time = SystemTime::now() - STALE_TMP_PACK_AGE - Duration::from_secs(60); + { + let file = File::options().write(true).open(&stale).unwrap(); + file.set_times(FileTimes::new().set_modified(old_time)) + .unwrap(); + } + + SnapshotRepo::open_or_init(&workspace).unwrap(); + + assert!(!stale.exists(), "stale tmp_pack file should be removed"); + assert!(fresh.exists(), "fresh tmp_pack file should be kept"); + assert!(ordinary_pack.exists(), "non-temp pack file should be kept"); + } + #[test] fn snapshot_respects_workspace_gitignore() { let tmp = tempdir().unwrap();