返回 CodeWhale
repo.rs
根目录 / crates / tui / src / snapshot / repo.rs
1 //! Side-git repository wrapper for workspace snapshots.
2 //!
3 //! `SnapshotRepo` shells out to the system `git` binary (we deliberately
4 //! avoid `git2` to dodge its LGPL surface). The two paths that matter:
5 //!
6 //! - `git_dir` → `~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.git`
7 //! - `work_tree` → the user's actual workspace
8 //!
9 //! Every git invocation passes both `--git-dir` AND `--work-tree`. That is
10 //! the single biggest safety mechanism: it guarantees we never accidentally
11 //! mutate the user's own `.git` directory. If git can't find the side
12 //! repo, the command fails fast instead of falling back to "current
13 //! directory".
14
15 use std::collections::HashSet;
16 use std::io;
17 use std::path::{Component, Path, PathBuf};
18 use std::process::Output;
19 use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21 use crate::dependencies::ExternalTool;
22
23 use super::paths::{ensure_snapshot_dir, snapshot_git_dir};
24
25 /// Identifier for a snapshot — currently the underlying git commit SHA.
26 #[derive(Debug, Clone, PartialEq, Eq)]
27 pub struct SnapshotId(pub String);
28
29 impl SnapshotId {
30 /// Borrow the SHA as a string slice.
31 pub fn as_str(&self) -> &str {
32 &self.0
33 }
34 }
35
36 /// A single snapshot record (one row in `git log`).
37 #[derive(Debug, Clone)]
38 pub struct Snapshot {
39 /// Commit SHA inside the side repo.
40 pub id: SnapshotId,
41 /// Subject line — the label passed to [`SnapshotRepo::snapshot`].
42 pub label: String,
43 /// Author timestamp (Unix seconds).
44 pub timestamp: i64,
45 /// Session this snapshot belongs to, when recorded (encoded as a
46 /// `[sid=...] ` label prefix). `None` for legacy snapshots taken
47 /// before session tagging existed.
48 pub session_id: Option<String>,
49 }
50
51 /// Wrapper around the per-workspace side-git repo.
52 pub struct SnapshotRepo {
53 git_dir: PathBuf,
54 work_tree: PathBuf,
55 }
56
57 const STALE_TMP_PACK_AGE: Duration = Duration::from_secs(60 * 60);
58
59 /// Maximum total snapshot storage in megabytes before pruning kicks in at
60 /// snapshot time. Keeps the side repo from blowing up the user's disk during
61 /// long-running or high-churn sessions (#1112).
62 const MAX_SNAPSHOT_SIZE_MB: u64 = 500;
63
64 const BYTES_PER_MB: u64 = 1024 * 1024;
65
66 /// Grace margin below `MAX_SNAPSHOT_SIZE_MB` used as the prune target
67 /// so the repo doesn't hit the limit again one snapshot later.
68 const PRUNE_TARGET_MB: u64 = 400;
69
70 /// Default workspace-size ceiling above which snapshots self-disable
71 /// on first use (2 GB of non-excluded content). Reports from users with
72 /// multi-hundred-GB project directories — datasets, model weights,
73 /// docker image dumps that fall outside the built-in excludes —
74 /// surfaced that `git add -A` on first init would hang the TUI for
75 /// minutes-to-hours while indexing the workspace. Snapshots are a
76 /// rollback safety net, not a backup tool; bailing out on workspaces
77 /// that big is the right tradeoff. Users with legitimate large
78 /// monorepos can raise `[snapshots] max_workspace_gb` (or set it to
79 /// `0` to disable the cap entirely).
80 pub const DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT: u64 = 2 * 1024 * 1024 * 1024;
81
82 /// Hard cap on the number of file entries the bounded size estimator
83 /// will inspect before declaring the workspace "too large". Protects
84 /// against a workspace with millions of tiny files (no individual
85 /// file is large, but `git add -A` would still take forever).
86 const SIZE_WALK_MAX_ENTRIES: usize = 200_000;
87
88 /// Top-level directory and extension patterns that the snapshot path
89 /// already excludes via `BUILTIN_EXCLUDES`. The estimator skips these
90 /// up front so the size walk reflects what would actually land in the
91 /// snapshot commit. Kept narrow to common build-output dirs — anything
92 /// else falls back to the `.gitignore` filter.
93 const SIZE_WALK_SKIP_DIRS: &[&str] = &[
94 "node_modules",
95 "target",
96 "dist",
97 "build",
98 ".build",
99 ".next",
100 ".nuxt",
101 ".svelte-kit",
102 ".turbo",
103 ".parcel-cache",
104 "vendor",
105 ".cargo",
106 ".rustup",
107 ".npm",
108 ".bun",
109 ".yarn",
110 ".pnpm-store",
111 ".cache",
112 ".venv",
113 "venv",
114 ".tox",
115 "__pycache__",
116 ".mypy_cache",
117 ".pytest_cache",
118 ".ruff_cache",
119 ".gradle",
120 ".m2",
121 ".local",
122 ".git",
123 ];
124
125 const BUILTIN_EXCLUDES: &str = "\
126 # CodeWhale built-in snapshot exclusions
127 node_modules/
128 target/
129 dist/
130 build/
131 .build/
132 .next/
133 .nuxt/
134 .svelte-kit/
135 .turbo/
136 .parcel-cache/
137 vendor/
138 .cargo/
139 .rustup/
140 .npm/
141 .bun/
142 .yarn/
143 .pnpm-store/
144 .cache/
145 .venv/
146 venv/
147 .tox/
148 __pycache__/
149 *.pyc
150 .mypy_cache/
151 .pytest_cache/
152 .ruff_cache/
153 .gradle/
154 .m2/
155 .local/
156 .DS_Store
157
158 # Binary and generated artifacts. Snapshots are source rollback checkpoints,
159 # not a full binary backup; keeping these out avoids side-repo bloat.
160 *.exe
161 *.dll
162 *.so
163 *.dylib
164 *.wasm
165 *.o
166 *.obj
167 *.class
168 *.pdb
169 *.dSYM
170 *.zip
171 *.tar
172 *.tar.gz
173 *.tgz
174 *.tar.bz2
175 *.tar.xz
176 *.7z
177 *.rar
178 *.iso
179 *.dmg
180 *.bin
181 *.mp4
182 *.mov
183 *.mkv
184 *.avi
185 *.webm
186 *.mp3
187 *.wav
188 *.flac
189 *.aac
190 ";
191
192 impl SnapshotRepo {
193 /// Open an existing snapshot repo for `workspace` without creating or
194 /// initializing anything on disk.
195 ///
196 /// This is useful for read-only UI surfaces that want to report checkpoint
197 /// availability without paying the first-init size walk or surprising the
198 /// user by creating a side repo from a view action.
199 pub fn open_existing(workspace: &Path) -> io::Result<Option<Self>> {
200 let work_tree = workspace
201 .canonicalize()
202 .unwrap_or_else(|_| workspace.to_path_buf());
203 let git_dir = snapshot_git_dir(&work_tree);
204 if !git_dir.exists() || !git_dir.join("HEAD").exists() {
205 return Ok(None);
206 }
207 Ok(Some(Self { git_dir, work_tree }))
208 }
209
210 /// Open or initialize the snapshot repo for `workspace`.
211 ///
212 /// On first use this:
213 /// 1. Creates the `~/.deepseek/snapshots/<…>/.git` dir.
214 /// 2. Runs `git init --bare=false --quiet`.
215 /// 3. Sets a fixed `user.name` / `user.email` so commits don't pick up
216 /// the user's global git identity (we don't want our snapshots to
217 /// look like they came from the user).
218 pub fn open_or_init(workspace: &Path) -> io::Result<Self> {
219 Self::open_or_init_with_cap(workspace, DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT)
220 }
221
222 /// Variant of [`Self::open_or_init`] that accepts an explicit
223 /// workspace-size cap. `cap_bytes = 0` disables the cap entirely
224 /// (always snapshot, regardless of size).
225 ///
226 /// When the workspace exceeds the cap and the side repo hasn't
227 /// been initialized yet, returns `Err(InvalidInput)` with a
228 /// "workspace too large" reason. Subsequent calls (after the user
229 /// shrinks the workspace or raises the cap via config) succeed.
230 pub fn open_or_init_with_cap(workspace: &Path, cap_bytes: u64) -> io::Result<Self> {
231 let work_tree = workspace
232 .canonicalize()
233 .unwrap_or_else(|_| workspace.to_path_buf());
234 if let Some(reason) = unsafe_workspace_snapshot_reason(
235 &work_tree,
236 crate::config::effective_home_dir().as_deref(),
237 ) {
238 return Err(io::Error::new(
239 io::ErrorKind::InvalidInput,
240 format!(
241 "workspace snapshots are disabled for {reason}: {}",
242 work_tree.display()
243 ),
244 ));
245 }
246
247 let _ = ensure_snapshot_dir(&work_tree)?;
248 let git_dir = snapshot_git_dir(&work_tree);
249
250 let needs_init = !git_dir.exists();
251 if needs_init {
252 // First-init size guard. Skipping this on subsequent opens
253 // is intentional: paying a workspace walk on every snapshot
254 // would defeat the purpose of the cap, and a workspace
255 // that fit on first init is allowed to grow within the
256 // existing repo's `MAX_SNAPSHOT_SIZE_MB` budget. Users on
257 // workspaces that grew past the cap mid-session get the
258 // existing aggressive-pruning path in `snapshot()`.
259 if estimate_workspace_size_bounded(&work_tree, cap_bytes).is_none() {
260 return Err(io::Error::new(
261 io::ErrorKind::InvalidInput,
262 format!(
263 "workspace too large for snapshots (over {} GB of non-excluded content or > {} entries): {}\n raise `[snapshots] max_workspace_gb` in config.toml (or set it to 0 to disable the cap) if you want snapshots on this workspace.",
264 cap_bytes / (1024 * 1024 * 1024),
265 SIZE_WALK_MAX_ENTRIES,
266 work_tree.display()
267 ),
268 ));
269 }
270 let parent = git_dir.parent().ok_or_else(|| {
271 io::Error::new(io::ErrorKind::InvalidInput, "snapshot dir has no parent")
272 })?;
273 std::fs::create_dir_all(parent)?;
274 // `git init` here uses the parent directory as the work tree
275 // and stores metadata in `.git`. We then continue to use
276 // explicit `--git-dir` / `--work-tree` flags for every other
277 // command so behaviour is invariant of cwd.
278 let init = crate::dependencies::Git::command()
279 .ok_or_else(|| io_other("git not found on PATH"))?
280 .arg("init")
281 .arg("--quiet")
282 .arg(parent)
283 .output()
284 .map_err(|e| io_other(format!("failed to spawn git init: {e}")))?;
285 if !init.status.success() {
286 return Err(io_other(format!(
287 "git init failed: {}",
288 String::from_utf8_lossy(&init.stderr).trim()
289 )));
290 }
291
292 // Pin a stable identity so snapshot commits are recognisable
293 // and don't bleed into the user's git config.
294 let _ = run_git(
295 &git_dir,
296 &work_tree,
297 &["config", "user.name", "deepseek-snapshots"],
298 );
299 let _ = run_git(
300 &git_dir,
301 &work_tree,
302 &["config", "user.email", "snapshots@codewhale.local"],
303 );
304 // Don't auto-gc on every commit; we manage pruning ourselves.
305 let _ = run_git(&git_dir, &work_tree, &["config", "gc.auto", "0"]);
306 // Ignore CRLF rewriting — we want byte-for-byte fidelity.
307 let _ = run_git(&git_dir, &work_tree, &["config", "core.autocrlf", "false"]);
308 }
309
310 write_builtin_excludes(&git_dir)?;
311 if let Err(err) = cleanup_stale_pack_temps(&git_dir, STALE_TMP_PACK_AGE) {
312 tracing::debug!(
313 target: "snapshot",
314 "failed to clean stale snapshot tmp_pack files: {err}"
315 );
316 }
317 Ok(Self { git_dir, work_tree })
318 }
319
320 /// Take a snapshot of the current working tree.
321 ///
322 /// Internally: `git add -A`, `git write-tree`, `git commit-tree`, then
323 /// `git update-ref HEAD <commit>`.
324 /// `git add -A` honours the user's workspace ignore rules while staging
325 /// into the side repo's index.
326 ///
327 /// Before committing, checks whether the snapshot directory exceeds
328 /// [`MAX_SNAPSHOT_SIZE_MB`] and prunes the oldest snapshots if it does.
329 ///
330 /// Returns the snapshot's commit SHA.
331 #[allow(dead_code)] // convenience entry kept for tests and legacy callers; production writes go through snapshot_with_session
332 pub fn snapshot(&self, label: &str) -> io::Result<SnapshotId> {
333 self.snapshot_with_session(label, None)
334 }
335
336 /// Take a snapshot, tagging it with the owning session id.
337 ///
338 /// The session id is encoded into the commit message as a `[sid=...] `
339 /// label prefix. [`Self::list`] decodes it back into
340 /// [`Snapshot::session_id`] and strips the prefix from the visible
341 /// label, so existing listing surfaces keep showing the plain label.
342 /// Legacy snapshots taken through [`Self::snapshot`] carry no prefix
343 /// and decode with `session_id == None`.
344 pub fn snapshot_with_session(
345 &self,
346 label: &str,
347 session_id: Option<&str>,
348 ) -> io::Result<SnapshotId> {
349 // Guard against disk blowup (#1112): if the snapshot directory has
350 // grown beyond the limit, prune aggressively before adding more.
351 // When the prune actually destroys restore points the user is told
352 // once per workspace — losing undo history to a log line is the S5
353 // failure mode (2026-08-04 snapshot hunt).
354 if let Ok(removed) = self.prune_size_pressure(
355 MAX_SNAPSHOT_SIZE_MB * BYTES_PER_MB,
356 PRUNE_TARGET_MB * BYTES_PER_MB,
357 ) && removed > 0
358 {
359 notify_snapshot_history_pruned_once(&self.work_tree, removed);
360 }
361 // Stage every tracked + untracked path the workspace exposes.
362 // `--all` here means `add` + `update` + `remove` — the same set
363 // `git status` would show.
364 let add = run_git(&self.git_dir, &self.work_tree, &["add", "-A"])?;
365 if !add.status.success() {
366 return Err(io_other(format!(
367 "git add -A failed: {}",
368 String::from_utf8_lossy(&add.stderr).trim()
369 )));
370 }
371
372 let tree = run_git(&self.git_dir, &self.work_tree, &["write-tree"])?;
373 if !tree.status.success() {
374 return Err(io_other(format!(
375 "git write-tree failed: {}",
376 String::from_utf8_lossy(&tree.stderr).trim()
377 )));
378 }
379 let tree = String::from_utf8_lossy(&tree.stdout).trim().to_string();
380
381 let parent = run_git(
382 &self.git_dir,
383 &self.work_tree,
384 &["rev-parse", "--verify", "HEAD"],
385 )?;
386 let parent = parent
387 .status
388 .success()
389 .then(|| String::from_utf8_lossy(&parent.stdout).trim().to_string())
390 .filter(|s| !s.is_empty());
391
392 let mut args = vec!["commit-tree".to_string(), tree];
393 if let Some(parent) = parent {
394 args.push("-p".to_string());
395 args.push(parent);
396 }
397 args.push("-m".to_string());
398 args.push(Self::encode_session_label(label, session_id));
399 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
400
401 // `commit-tree` creates marker commits even when the tree matches its
402 // parent, and it does not run user/global commit hooks.
403 let commit = run_git(&self.git_dir, &self.work_tree, &arg_refs)?;
404 if !commit.status.success() {
405 return Err(io_other(format!(
406 "git commit-tree failed: {}",
407 String::from_utf8_lossy(&commit.stderr).trim()
408 )));
409 }
410 let sha = String::from_utf8_lossy(&commit.stdout).trim().to_string();
411
412 let update = run_git(
413 &self.git_dir,
414 &self.work_tree,
415 &["update-ref", "HEAD", &sha],
416 )?;
417 if !update.status.success() {
418 return Err(io_other(format!(
419 "git update-ref HEAD failed: {}",
420 String::from_utf8_lossy(&update.stderr).trim()
421 )));
422 }
423
424 Ok(SnapshotId(sha))
425 }
426
427 /// Prefix a snapshot label with its owning session id, if any.
428 fn encode_session_label(label: &str, session_id: Option<&str>) -> String {
429 match session_id {
430 Some(sid) if !sid.is_empty() => format!("[sid={sid}] {label}"),
431 _ => label.to_string(),
432 }
433 }
434
435 /// Split a possibly session-tagged label back into `(session_id, label)`.
436 ///
437 /// Returns `(None, label)` for untagged labels. The decoded label is
438 /// the original one without the `[sid=...] ` prefix, so consumers that
439 /// match on `pre-turn:`/`tool:`/`redo:` prefixes keep working unchanged.
440 fn decode_session_label(label: &str) -> (Option<String>, String) {
441 let Some(rest) = label.strip_prefix("[sid=") else {
442 return (None, label.to_string());
443 };
444 let Some(end) = rest.find("] ") else {
445 return (None, label.to_string());
446 };
447 let sid = &rest[..end];
448 let plain = &rest[end + 2..];
449 if sid.is_empty() || plain.is_empty() {
450 return (None, label.to_string());
451 }
452 (Some(sid.to_string()), plain.to_string())
453 }
454 /// Size-pressure prune (#1112): if the side repo exceeds `max_bytes`,
455 /// walk backward from a 1-second retention toward zero until the store is
456 /// at or under `target_bytes`, escalating to a full wipe when nothing
457 /// else helps. Returns the total number of snapshots destroyed, so the
458 /// caller can tell the user their undo history shrank (S5 — the wipe was
459 /// previously announced only by a `tracing::warn`).
460 fn prune_size_pressure(&self, max_bytes: u64, target_bytes: u64) -> io::Result<usize> {
461 let current_bytes = dir_size_bytes(&self.git_dir)?;
462 if current_bytes <= max_bytes {
463 return Ok(0);
464 }
465 tracing::warn!(
466 target: "snapshot",
467 current_mb = current_bytes / BYTES_PER_MB,
468 limit_mb = max_bytes / BYTES_PER_MB,
469 "snapshot storage approaching limit — pruning aggressively"
470 );
471 let mut removed_total: usize = 0;
472 // Walk backward from a 1-second retention to zero until
473 // we're under the target, or until there's nothing left.
474 let mut age = Duration::from_secs(1);
475 for _ in 0..10 {
476 if let Ok(removed) = self.prune_older_than(age) {
477 removed_total = removed_total.saturating_add(removed);
478 }
479 if let Ok(new_size) = dir_size_bytes(&self.git_dir)
480 && new_size <= target_bytes
481 {
482 tracing::info!(
483 target: "snapshot",
484 new_size_mb = new_size / BYTES_PER_MB,
485 "pruned snapshot storage back under limit"
486 );
487 break;
488 }
489 age = age.saturating_sub(Duration::from_millis(100));
490 }
491 // Fallback: if even 0-second pruning didn't help (shouldn't
492 // happen but belt-and-suspenders), nuke the refs so the next
493 // snapshot starts a fresh history.
494 if let Ok(final_size) = dir_size_bytes(&self.git_dir)
495 && final_size > max_bytes
496 {
497 tracing::warn!(
498 target: "snapshot",
499 "snapshot storage still over limit after pruning; wiping history"
500 );
501 if let Ok(removed) = self.prune_older_than(Duration::ZERO) {
502 removed_total = removed_total.saturating_add(removed);
503 }
504 let _ = self.prune_unreachable_objects();
505 }
506 Ok(removed_total)
507 }
508
509 /// Restore the workspace to the state at `id`.
510 ///
511 /// Uses `git checkout <sha> -- :/` which checks out every path in the
512 /// snapshot tree relative to the workspace root. We do NOT touch the
513 /// user's own `.git` — snapshots only contain working-tree files.
514 pub fn restore(&self, id: &SnapshotId) -> io::Result<()> {
515 // Restore is the one destructive operation with no undo of its own.
516 // Capture the pre-restore state first so the restore itself can be
517 // reversed (2026-08-04 snapshot hunt: makes several other findings
518 // recoverable instead of final). The `pre-restore:` prefix is
519 // deliberately not a `/undo` or `revert_turn` candidate label, so the
520 // safety net never changes snapshot selection. Best-effort: a failed
521 // safety snapshot must never block the restore the user asked for.
522 let target_short = &id.as_str()[..id.as_str().len().min(12)];
523 if let Err(e) = self.snapshot_with_session(&format!("pre-restore:{target_short}"), None) {
524 tracing::warn!(
525 target: "snapshot",
526 "pre-restore safety snapshot failed (restore will proceed): {e}"
527 );
528 }
529 let current_paths = self.tree_paths("HEAD")?;
530 let target_paths = self.tree_paths(id.as_str())?;
531 let checkout = run_git(
532 &self.git_dir,
533 &self.work_tree,
534 &["checkout", id.as_str(), "--", ":/"],
535 )?;
536 if !checkout.status.success() {
537 return Err(io_other(format!(
538 "git checkout failed: {}",
539 String::from_utf8_lossy(&checkout.stderr).trim()
540 )));
541 }
542 self.remove_paths_missing_from_target(&current_paths, &target_paths)?;
543 Ok(())
544 }
545
546 /// Return whether the current workspace matches the given snapshot's
547 /// tracked file content.
548 ///
549 /// This is intentionally narrower than a full "workspace identical"
550 /// claim: it compares the current working tree against the snapshot's
551 /// tracked paths via git's diff machinery. That is sufficient for
552 /// `/undo` cursoring — if the diff is empty, restoring this snapshot
553 /// again would be a no-op, so the caller should continue scanning
554 /// older snapshots.
555 pub fn work_tree_matches_snapshot(&self, id: &SnapshotId) -> io::Result<bool> {
556 let diff = run_git(
557 &self.git_dir,
558 &self.work_tree,
559 &["diff", "--quiet", id.as_str(), "--", ":/"],
560 )?;
561 Ok(diff.status.success())
562 }
563
564 fn tree_paths(&self, treeish: &str) -> io::Result<HashSet<PathBuf>> {
565 let ls = run_git(
566 &self.git_dir,
567 &self.work_tree,
568 &["ls-tree", "-r", "-z", "--name-only", treeish],
569 )?;
570 if !ls.status.success() {
571 return Err(io_other(format!(
572 "git ls-tree failed: {}",
573 String::from_utf8_lossy(&ls.stderr).trim()
574 )));
575 }
576 Ok(parse_nul_paths(&ls.stdout))
577 }
578
579 fn remove_paths_missing_from_target(
580 &self,
581 current_paths: &HashSet<PathBuf>,
582 target_paths: &HashSet<PathBuf>,
583 ) -> io::Result<()> {
584 for rel in current_paths.difference(target_paths) {
585 if !is_safe_relative_path(rel) {
586 continue;
587 }
588 let path = self.work_tree.join(rel);
589 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
590 continue;
591 };
592 if metadata.file_type().is_dir() {
593 let _ = std::fs::remove_dir(&path);
594 } else {
595 std::fs::remove_file(&path)?;
596 }
597 self.prune_empty_parent_dirs(path.parent());
598 }
599 Ok(())
600 }
601
602 fn prune_empty_parent_dirs(&self, mut dir: Option<&Path>) {
603 while let Some(path) = dir {
604 if path == self.work_tree {
605 break;
606 }
607 if std::fs::remove_dir(path).is_err() {
608 break;
609 }
610 dir = path.parent();
611 }
612 }
613
614 /// List up to `limit` most-recent snapshots, newest first.
615 pub fn list(&self, limit: usize) -> io::Result<Vec<Snapshot>> {
616 // `git log -<n>` is the short form of `--max-count=<n>`; if `limit`
617 // is `usize::MAX` (caller asked for "everything") we pass an empty
618 // count so git defaults to no upper bound.
619 let mut args: Vec<String> = vec!["log".to_string()];
620 if limit < usize::MAX {
621 args.push(format!("--max-count={limit}"));
622 }
623 args.push("--pretty=format:%H%x09%at%x09%s".to_string());
624 args.push("--no-color".to_string());
625 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
626 let log = run_git(&self.git_dir, &self.work_tree, &arg_refs)?;
627 if !log.status.success() {
628 // No commits yet → empty list.
629 return Ok(Vec::new());
630 }
631 let stdout = String::from_utf8_lossy(&log.stdout);
632 let mut out = Vec::new();
633 for line in stdout.lines() {
634 let mut parts = line.splitn(3, '\t');
635 let sha = parts.next().unwrap_or("").to_string();
636 let ts = parts
637 .next()
638 .and_then(|s| s.parse::<i64>().ok())
639 .unwrap_or(0);
640 let subject = parts.next().unwrap_or("").to_string();
641 if sha.is_empty() {
642 continue;
643 }
644 let (session_id, label) = Self::decode_session_label(&subject);
645 out.push(Snapshot {
646 id: SnapshotId(sha),
647 label,
648 timestamp: ts,
649 session_id,
650 });
651 }
652 Ok(out)
653 }
654
655 /// Drop snapshots older than `max_age`, returning the count removed.
656 ///
657 /// Strategy: identify keepable commits (younger than the cutoff),
658 /// reset HEAD to the oldest survivor, then `git reflog expire` +
659 /// `git gc --prune=now` to actually reclaim space. Cheap and avoids
660 /// rewriting history when nothing has aged out.
661 pub fn prune_older_than(&self, max_age: Duration) -> io::Result<usize> {
662 let now = SystemTime::now()
663 .duration_since(UNIX_EPOCH)
664 .map_err(|e| io_other(format!("clock error: {e}")))?
665 .as_secs() as i64;
666 let cutoff = now - max_age.as_secs() as i64;
667
668 let snapshots = self.list(usize::MAX)?;
669 if snapshots.is_empty() {
670 return Ok(0);
671 }
672
673 // Snapshots are newest-first. Find the index of the first one
674 // at-or-older than the cutoff — every entry from that index
675 // onward is a candidate for removal. We use `<=` so a 0-second
676 // retention drops same-second commits (otherwise tests calling
677 // `prune_older_than(Duration::ZERO)` immediately after creating
678 // a snapshot would never prune anything).
679 let cut_index = snapshots.iter().position(|s| s.timestamp <= cutoff);
680 let Some(cut) = cut_index else {
681 return Ok(0);
682 };
683 let removed = snapshots.len() - cut;
684 if removed == 0 {
685 return Ok(0);
686 }
687
688 if cut == 0 {
689 // Every snapshot is older than the cutoff — wipe the repo
690 // entirely so the next snapshot starts a fresh history.
691 // Removing `.git/refs/heads/*` is enough to orphan the old
692 // commits, then gc reclaims them.
693 let refs_dir = self.git_dir.join("refs").join("heads");
694 if refs_dir.exists() {
695 for entry in std::fs::read_dir(&refs_dir)? {
696 let path = entry?.path();
697 if path.is_file() {
698 let _ = std::fs::remove_file(&path);
699 }
700 }
701 }
702 // Also drop HEAD's packed refs so `git log` returns nothing.
703 let packed = self.git_dir.join("packed-refs");
704 if packed.exists() {
705 let _ = std::fs::remove_file(&packed);
706 }
707 } else {
708 // Keep the newest `cut` snapshots (indices [0..cut], newest-first)
709 // and drop the older tail. This MUST rebuild the survivors as a
710 // fresh orphan chain, not `update-ref HEAD <oldest survivor>`:
711 // the snapshots are a parent-linked commit chain with the newest
712 // at HEAD, so pointing HEAD at the oldest survivor orphaned every
713 // NEWER snapshot (gc then destroyed them) while keeping the very
714 // snapshots we meant to remove as its ancestors — the exact
715 // inverse of the intent (2026-08-04 review, reproduced).
716 self.rebuild_survivor_chain(&snapshots[..cut])?;
717 }
718
719 // Reclaim space.
720 let _ = run_git(
721 &self.git_dir,
722 &self.work_tree,
723 &["reflog", "expire", "--expire=now", "--all"],
724 );
725 let _ = run_git(
726 &self.git_dir,
727 &self.work_tree,
728 &["gc", "--prune=now", "--quiet"],
729 );
730
731 Ok(removed)
732 }
733
734 /// Rebuild `survivors` (newest-first) as a fresh orphan commit chain and
735 /// point HEAD at its tip, so every snapshot NOT in `survivors` becomes
736 /// unreachable for gc to reclaim. Each survivor's tree, label, session
737 /// id, and author/committer timestamp are preserved, so ages do not lie
738 /// after a prune (finding: `prune_keep_last_n` previously reset them to
739 /// "now"). Assumes `survivors` is non-empty.
740 fn rebuild_survivor_chain(&self, survivors: &[Snapshot]) -> io::Result<()> {
741 let mut prev_sha: Option<String> = None;
742 for s in survivors.iter().rev() {
743 let tree = run_git(
744 &self.git_dir,
745 &self.work_tree,
746 &["rev-parse", &format!("{}^{{tree}}", s.id.as_str())],
747 )?;
748 if !tree.status.success() {
749 return Err(io_other(format!(
750 "rev-parse {}^{{tree}} failed: {}",
751 s.id.as_str(),
752 String::from_utf8_lossy(&tree.stderr).trim()
753 )));
754 }
755 let tree_hash = String::from_utf8_lossy(&tree.stdout).trim().to_string();
756
757 let mut args = vec![
758 "commit-tree".to_string(),
759 "-m".to_string(),
760 Self::encode_session_label(&s.label, s.session_id.as_deref()),
761 tree_hash,
762 ];
763 if let Some(ref p) = prev_sha {
764 args.push("-p".to_string());
765 args.push(p.clone());
766 }
767 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
768 let new_sha = self.commit_tree_preserving_date(&arg_refs, s.timestamp)?;
769 prev_sha = Some(new_sha);
770 }
771
772 if let Some(final_sha) = prev_sha {
773 let up = run_git(
774 &self.git_dir,
775 &self.work_tree,
776 &["update-ref", "HEAD", &final_sha],
777 )?;
778 if !up.status.success() {
779 return Err(io_other(format!(
780 "update-ref HEAD failed: {}",
781 String::from_utf8_lossy(&up.stderr).trim()
782 )));
783 }
784 }
785 Ok(())
786 }
787
788 /// Run a `commit-tree` invocation with the author/committer dates pinned
789 /// to `timestamp` (Unix seconds), so a rebuilt survivor keeps its real
790 /// age instead of stamping "now".
791 fn commit_tree_preserving_date(&self, args: &[&str], timestamp: i64) -> io::Result<String> {
792 let date = format!("{timestamp} +0000");
793 let out = crate::dependencies::Git::command()
794 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "git not found on PATH"))?
795 .arg("--git-dir")
796 .arg(&self.git_dir)
797 .arg("--work-tree")
798 .arg(&self.work_tree)
799 .env("GIT_AUTHOR_DATE", &date)
800 .env("GIT_COMMITTER_DATE", &date)
801 .args(args)
802 .output()?;
803 if !out.status.success() {
804 return Err(io_other(format!(
805 "commit-tree failed: {}",
806 String::from_utf8_lossy(&out.stderr).trim()
807 )));
808 }
809 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
810 }
811
812 /// Keep only the latest `max_count` snapshots, dropping older ones.
813 ///
814 /// Uses `commit-tree` with no `-p` to create a true orphan commit at
815 /// the eldest survivor's tree, preserving its label. The old chain
816 /// has zero refs after gc and is physically reclaimed.
817 /// Keep only the latest `max_count` snapshots by rebuilding the
818 /// survivor chain as orphan commits. Each survivor's tree and label
819 /// are preserved — only the parent chain to older snapshots is cut.
820 /// Old objects become unreachable and gc reclaims them.
821 pub fn prune_keep_last_n(&self, max_count: usize) -> io::Result<usize> {
822 let snapshots = self.list(usize::MAX)?;
823 if snapshots.len() <= max_count {
824 return Ok(0);
825 }
826 let keep = max_count;
827 let removed = snapshots.len() - keep;
828 // snapshots are newest-first: [0..keep] are the survivors. Rebuild
829 // them as an orphan chain so the older tail is reclaimed.
830 self.rebuild_survivor_chain(&snapshots[..keep])?;
831 let _ = run_git(
832 &self.git_dir,
833 &self.work_tree,
834 &["reflog", "expire", "--expire=now", "--all"],
835 );
836 let _ = run_git(
837 &self.git_dir,
838 &self.work_tree,
839 &["gc", "--prune=now", "--quiet"],
840 );
841 Ok(removed)
842 }
843
844 /// Drop unreachable loose objects left behind by interrupted or
845 /// orphaned side-repo operations.
846 pub fn prune_unreachable_objects(&self) -> io::Result<()> {
847 let prune = run_git(&self.git_dir, &self.work_tree, &["prune", "--expire=now"])?;
848 if !prune.status.success() {
849 return Err(io_other(format!(
850 "git prune failed: {}",
851 String::from_utf8_lossy(&prune.stderr).trim()
852 )));
853 }
854 Ok(())
855 }
856
857 /// Return the side-repo's `.git` directory (for diagnostics).
858 #[allow(dead_code)]
859 pub fn git_dir(&self) -> &Path {
860 &self.git_dir
861 }
862
863 /// Return the work tree path (for diagnostics).
864 #[allow(dead_code)]
865 pub fn work_tree(&self) -> &Path {
866 &self.work_tree
867 }
868 }
869
870 fn write_builtin_excludes(git_dir: &Path) -> io::Result<()> {
871 let info_dir = git_dir.join("info");
872 std::fs::create_dir_all(&info_dir)?;
873 std::fs::write(info_dir.join("exclude"), BUILTIN_EXCLUDES)
874 }
875
876 /// Recursively compute the total size of a directory in bytes.
877 fn dir_size_bytes(root: &Path) -> io::Result<u64> {
878 fn walk(dir: &Path, total: &mut u64) -> io::Result<()> {
879 if !dir.is_dir() {
880 return Ok(());
881 }
882 for entry in std::fs::read_dir(dir)? {
883 let entry = entry?;
884 let path = entry.path();
885 let ft = entry.file_type()?;
886 if ft.is_symlink() {
887 continue;
888 }
889 if ft.is_dir() {
890 walk(&path, total)?;
891 } else if ft.is_file() {
892 *total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0));
893 }
894 }
895 Ok(())
896 }
897 let mut total: u64 = 0;
898 walk(root, &mut total)?;
899 Ok(total)
900 }
901
902 /// One prominent notice per workspace per process when the size-pressure
903 /// prune destroys restore points — silent loss of undo history is the S5
904 /// failure mode (2026-08-04 snapshot hunt). The stderr print is deliberate:
905 /// headless/CLI stderr is the user surface for once-per-workspace snapshot
906 /// warnings, matching `maybe_notify_snapshots_disabled_once` in
907 /// `core/turn.rs`.
908 #[allow(clippy::print_stderr)]
909 fn notify_snapshot_history_pruned_once(workspace: &Path, removed: usize) {
910 use std::collections::HashSet;
911 use std::sync::{Mutex, OnceLock};
912 static NOTIFIED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
913 let key = workspace.to_string_lossy().into_owned();
914 let set = NOTIFIED.get_or_init(|| Mutex::new(HashSet::new()));
915 let Ok(mut guard) = set.lock() else {
916 return;
917 };
918 if !guard.insert(key) {
919 return;
920 }
921 drop(guard);
922 eprint!("{}", snapshot_history_pruned_message(workspace, removed));
923 }
924
925 /// Build the user-visible notice for a size-pressure prune. Kept pure and
926 /// separate from the emit/dedup shell so the content is unit-testable.
927 fn snapshot_history_pruned_message(workspace: &Path, removed: usize) -> String {
928 format!(
929 "warning: snapshot/undo history for {} was pruned to stay under the {} MB snapshot storage cap.
930 {} snapshot(s) were removed and can no longer be restored.
931 The cap bounds the undo side-repo's disk use; high-churn or large workspaces hit it sooner.
932 ",
933 workspace.display(),
934 MAX_SNAPSHOT_SIZE_MB,
935 removed
936 )
937 }
938
939 fn cleanup_stale_pack_temps(git_dir: &Path, stale_age: Duration) -> io::Result<usize> {
940 let pack_dir = git_dir.join("objects").join("pack");
941 if !pack_dir.exists() {
942 return Ok(0);
943 }
944 cleanup_stale_pack_temps_in(&pack_dir, stale_age, SystemTime::now())
945 }
946
947 fn cleanup_stale_pack_temps_in(
948 pack_dir: &Path,
949 stale_age: Duration,
950 now: SystemTime,
951 ) -> io::Result<usize> {
952 let mut removed = 0;
953 for entry in std::fs::read_dir(pack_dir)? {
954 let entry = entry?;
955 let name = entry.file_name();
956 let Some(name) = name.to_str() else {
957 continue;
958 };
959 if !name.starts_with("tmp_pack_") {
960 continue;
961 }
962 if !entry.file_type()?.is_file() {
963 continue;
964 }
965
966 let metadata = entry.metadata()?;
967 let Ok(modified) = metadata.modified() else {
968 continue;
969 };
970 let Ok(age) = now.duration_since(modified) else {
971 continue;
972 };
973 if age < stale_age {
974 continue;
975 }
976
977 match std::fs::remove_file(entry.path()) {
978 Ok(()) => removed += 1,
979 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
980 Err(err) => return Err(err),
981 }
982 }
983 Ok(removed)
984 }
985
986 fn run_git(git_dir: &Path, work_tree: &Path, args: &[&str]) -> io::Result<Output> {
987 crate::dependencies::Git::command()
988 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "git not found on PATH"))?
989 .arg("--git-dir")
990 .arg(git_dir)
991 .arg("--work-tree")
992 .arg(work_tree)
993 .args(args)
994 .output()
995 }
996
997 fn io_other(msg: impl Into<String>) -> io::Error {
998 io::Error::other(msg.into())
999 }
1000
1001 /// Walk `workspace` and accumulate file sizes, returning `Some(total)`
1002 /// when the workspace fits under `cap_bytes` and `None` when the walk
1003 /// exceeds the cap. Honors `.gitignore` (via the `ignore` crate's
1004 /// `WalkBuilder` defaults) and the snapshot-specific skip list above,
1005 /// so the measured size reflects what would actually land in a
1006 /// snapshot commit rather than the raw `du -sh` total.
1007 ///
1008 /// The walk is bounded by both `cap_bytes` and
1009 /// [`SIZE_WALK_MAX_ENTRIES`] — either trip returns `None`. A
1010 /// `cap_bytes` of `0` disables the cap entirely (returns `Some(total)`
1011 /// no matter how large), so config can opt out.
1012 pub fn estimate_workspace_size_bounded(workspace: &Path, cap_bytes: u64) -> Option<u64> {
1013 use ignore::WalkBuilder;
1014 let mut total: u64 = 0;
1015 let mut entries: usize = 0;
1016 let skip: HashSet<&'static str> = SIZE_WALK_SKIP_DIRS.iter().copied().collect();
1017 let walker = WalkBuilder::new(workspace)
1018 .hidden(false)
1019 .follow_links(false)
1020 .filter_entry(move |entry| {
1021 // Skip the well-known build-output directories at any depth.
1022 // The `ignore` crate calls `filter_entry` once per dir/file;
1023 // returning `false` here prunes the whole subtree.
1024 entry
1025 .file_name()
1026 .to_str()
1027 .is_none_or(|name| !skip.contains(name))
1028 })
1029 .build();
1030 for entry in walker.flatten() {
1031 entries += 1;
1032 if entries > SIZE_WALK_MAX_ENTRIES {
1033 return None;
1034 }
1035 if let Ok(meta) = entry.metadata()
1036 && meta.is_file()
1037 {
1038 total = total.saturating_add(meta.len());
1039 if cap_bytes > 0 && total > cap_bytes {
1040 return None;
1041 }
1042 }
1043 }
1044 Some(total)
1045 }
1046
1047 fn unsafe_workspace_snapshot_reason(workspace: &Path, home: Option<&Path>) -> Option<&'static str> {
1048 let workspace = normalize_path_for_safety(workspace);
1049 if is_filesystem_root(&workspace) {
1050 return Some("filesystem root");
1051 }
1052
1053 if is_home_directory(&workspace, home) {
1054 return Some("home directory");
1055 }
1056
1057 let home = home.map(normalize_path_for_safety)?;
1058 if workspace.parent() == Some(home.as_path()) {
1059 let name = workspace.file_name().and_then(|name| name.to_str());
1060 if matches!(
1061 name,
1062 Some(
1063 "Desktop" | "Documents" | "Downloads" | "Library" | "Movies" | "Music" | "Pictures"
1064 )
1065 ) {
1066 return Some("home collection directory");
1067 }
1068 }
1069
1070 None
1071 }
1072
1073 fn normalize_path_for_safety(path: &Path) -> PathBuf {
1074 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
1075 }
1076
1077 fn is_filesystem_root(path: &Path) -> bool {
1078 path.parent().is_none()
1079 }
1080
1081 fn is_home_directory(work_tree: &Path, home: Option<&Path>) -> bool {
1082 let Some(home) = home else {
1083 return false;
1084 };
1085
1086 let home_canonical = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
1087 work_tree == home_canonical
1088 }
1089
1090 fn parse_nul_paths(bytes: &[u8]) -> HashSet<PathBuf> {
1091 bytes
1092 .split(|b| *b == 0)
1093 .filter(|chunk| !chunk.is_empty())
1094 .map(|chunk| PathBuf::from(String::from_utf8_lossy(chunk).into_owned()))
1095 .collect()
1096 }
1097
1098 fn is_safe_relative_path(path: &Path) -> bool {
1099 !path.as_os_str().is_empty()
1100 && path
1101 .components()
1102 .all(|component| matches!(component, Component::Normal(_)))
1103 }
1104
1105 #[cfg(test)]
1106 mod tests {
1107 use super::*;
1108 use crate::test_support::lock_test_env;
1109 use std::fs::{File, FileTimes};
1110 use tempfile::tempdir;
1111
1112 /// Holds the home directory pinned to a tempdir for the lifetime of a test. Also
1113 /// owns the process-wide env-var mutex so tests across modules
1114 /// don't trample each other's home env vars.
1115 pub(super) struct ScopedHome {
1116 prev_vars: Vec<(&'static str, Option<std::ffi::OsString>)>,
1117 _guard: crate::test_support::TestEnvLock,
1118 }
1119 impl Drop for ScopedHome {
1120 fn drop(&mut self) {
1121 // SAFETY: process-wide lock still held.
1122 unsafe {
1123 for (key, prev) in self.prev_vars.drain(..) {
1124 match prev {
1125 Some(value) => std::env::set_var(key, value),
1126 None => std::env::remove_var(key),
1127 }
1128 }
1129 }
1130 }
1131 }
1132 pub(super) fn scoped_home(home: &Path) -> ScopedHome {
1133 let guard = lock_test_env();
1134 let prev_vars = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"]
1135 .into_iter()
1136 .map(|key| (key, std::env::var_os(key)))
1137 .collect();
1138 // SAFETY: serialised by the global env lock.
1139 unsafe {
1140 std::env::set_var("HOME", home);
1141 std::env::set_var("USERPROFILE", home);
1142 std::env::remove_var("HOMEDRIVE");
1143 std::env::remove_var("HOMEPATH");
1144 }
1145 ScopedHome {
1146 prev_vars,
1147 _guard: guard,
1148 }
1149 }
1150
1151 /// Build a side-repo whose snapshot dir lives under the same
1152 /// tempdir we're using for `HOME` — so the inner `crate::config::effective_home_dir()`
1153 /// lookup stays inside our sandbox. Returns the guard alongside so
1154 /// the caller can keep HOME pinned for the rest of the test.
1155 fn make_repo(tmp: &Path) -> (SnapshotRepo, ScopedHome) {
1156 let workspace = tmp.join("workspace");
1157 std::fs::create_dir_all(&workspace).unwrap();
1158 let guard = scoped_home(tmp);
1159 let repo = SnapshotRepo::open_or_init(&workspace).expect("open_or_init");
1160 (repo, guard)
1161 }
1162
1163 #[test]
1164 fn snapshot_creates_commit_in_side_repo_only() {
1165 let tmp = tempdir().unwrap();
1166 let (repo, _home) = make_repo(tmp.path());
1167 std::fs::write(repo.work_tree().join("a.txt"), b"alpha").unwrap();
1168
1169 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1170 assert_eq!(id.as_str().len(), 40);
1171
1172 let list = repo.list(10).expect("list");
1173 assert_eq!(list.len(), 1);
1174 assert_eq!(list[0].label, "pre-turn:1");
1175
1176 // The user's workspace must NOT have a real `.git` because we
1177 // never created one in their workspace — only in the side dir.
1178 assert!(!repo.work_tree().join(".git").exists());
1179 }
1180
1181 #[test]
1182 fn open_existing_is_read_only_and_does_not_initialize() {
1183 let tmp = tempdir().unwrap();
1184 let workspace = tmp.path().join("workspace");
1185 std::fs::create_dir_all(&workspace).unwrap();
1186 let _home = scoped_home(tmp.path());
1187
1188 let before = SnapshotRepo::open_existing(&workspace).expect("open existing");
1189 assert!(before.is_none());
1190 assert!(
1191 !snapshot_git_dir(&workspace).exists(),
1192 "read-only open must not create the side repo"
1193 );
1194
1195 let repo = SnapshotRepo::open_or_init(&workspace).expect("open_or_init");
1196 std::fs::write(repo.work_tree().join("a.txt"), b"alpha").unwrap();
1197 repo.snapshot("pre-turn:1").expect("snapshot");
1198
1199 let after = SnapshotRepo::open_existing(&workspace).expect("open existing");
1200 assert!(after.is_some());
1201 }
1202
1203 #[test]
1204 fn restore_reverts_workspace_files() {
1205 let tmp = tempdir().unwrap();
1206 let (repo, _home) = make_repo(tmp.path());
1207 let f = repo.work_tree().join("file.txt");
1208
1209 std::fs::write(&f, b"original").unwrap();
1210 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1211
1212 std::fs::write(&f, b"clobbered").unwrap();
1213 repo.snapshot("post-turn:1").expect("snapshot 2");
1214
1215 repo.restore(&id).expect("restore");
1216 let after = std::fs::read_to_string(&f).unwrap();
1217 assert_eq!(after, "original");
1218 }
1219
1220 #[test]
1221 fn restore_removes_files_added_after_target_snapshot() {
1222 let tmp = tempdir().unwrap();
1223 let (repo, _home) = make_repo(tmp.path());
1224 let original = repo.work_tree().join("original.txt");
1225 let added = repo.work_tree().join("added.txt");
1226
1227 std::fs::write(&original, b"original").unwrap();
1228 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1229
1230 std::fs::write(&added, b"new file").unwrap();
1231 repo.snapshot("post-turn:1").expect("snapshot 2");
1232
1233 repo.restore(&id).expect("restore");
1234 assert!(original.exists());
1235 assert!(!added.exists(), "restore must remove tracked added files");
1236 }
1237
1238 #[test]
1239 fn restore_takes_a_pre_restore_safety_snapshot_that_round_trips() {
1240 let tmp = tempdir().unwrap();
1241 let (repo, _home) = make_repo(tmp.path());
1242 let f = repo.work_tree().join("file.txt");
1243
1244 std::fs::write(&f, b"v1").unwrap();
1245 let id1 = repo.snapshot("pre-turn:1").expect("snapshot v1");
1246
1247 std::fs::write(&f, b"v2").unwrap();
1248 repo.snapshot("post-turn:1").expect("snapshot v2");
1249
1250 repo.restore(&id1).expect("restore to v1");
1251 assert_eq!(std::fs::read_to_string(&f).unwrap(), "v1");
1252
1253 // The restore must have captured the pre-restore state (v2) under a
1254 // `pre-restore:` label naming its target, so the destructive op is
1255 // itself reversible (2026-08-04 snapshot hunt).
1256 let snapshots = repo.list(usize::MAX).expect("list");
1257 let safety = snapshots
1258 .iter()
1259 .find(|s| s.label.starts_with("pre-restore:"))
1260 .expect("a pre-restore safety snapshot must exist");
1261 assert!(
1262 safety.label.ends_with(&id1.as_str()[..12]),
1263 "safety label should name the restore target: {}",
1264 safety.label
1265 );
1266
1267 repo.restore(&safety.id)
1268 .expect("restore the safety snapshot");
1269 assert_eq!(
1270 std::fs::read_to_string(&f).unwrap(),
1271 "v2",
1272 "the safety snapshot must bring back the pre-restore state"
1273 );
1274 }
1275
1276 #[test]
1277 fn snapshot_and_restore_do_not_move_user_git_head() {
1278 let tmp = tempdir().unwrap();
1279 let workspace = tmp.path().join("workspace");
1280 std::fs::create_dir_all(&workspace).unwrap();
1281 crate::dependencies::Git::command()
1282 .expect("git not found")
1283 .arg("-C")
1284 .arg(&workspace)
1285 .arg("init")
1286 .arg("--quiet")
1287 .status()
1288 .unwrap();
1289 std::fs::write(workspace.join("tracked.txt"), b"committed").unwrap();
1290 crate::dependencies::Git::command()
1291 .expect("git not found")
1292 .arg("-C")
1293 .arg(&workspace)
1294 .arg("add")
1295 .arg("tracked.txt")
1296 .status()
1297 .unwrap();
1298 crate::dependencies::Git::command()
1299 .expect("git not found")
1300 .arg("-C")
1301 .arg(&workspace)
1302 .arg("-c")
1303 .arg("user.name=user")
1304 .arg("-c")
1305 .arg("user.email=user@example.test")
1306 .arg("commit")
1307 .arg("--quiet")
1308 .arg("-m")
1309 .arg("init")
1310 .status()
1311 .unwrap();
1312 let user_head_before = crate::dependencies::Git::command()
1313 .expect("git not found")
1314 .arg("-C")
1315 .arg(&workspace)
1316 .args(["rev-parse", "HEAD"])
1317 .output()
1318 .unwrap()
1319 .stdout;
1320
1321 let _home = scoped_home(tmp.path());
1322 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
1323 std::fs::write(workspace.join("tracked.txt"), b"dirty-before").unwrap();
1324 let id = repo.snapshot("pre-turn:1").unwrap();
1325 std::fs::write(workspace.join("tracked.txt"), b"dirty-after").unwrap();
1326 repo.snapshot("post-turn:1").unwrap();
1327 repo.restore(&id).unwrap();
1328
1329 let user_head_after = crate::dependencies::Git::command()
1330 .expect("git not found")
1331 .arg("-C")
1332 .arg(&workspace)
1333 .args(["rev-parse", "HEAD"])
1334 .output()
1335 .unwrap()
1336 .stdout;
1337 assert_eq!(user_head_after, user_head_before);
1338 assert_eq!(
1339 std::fs::read_to_string(workspace.join("tracked.txt")).unwrap(),
1340 "dirty-before"
1341 );
1342 }
1343
1344 #[test]
1345 fn list_respects_limit() {
1346 let tmp = tempdir().unwrap();
1347 let (repo, _home) = make_repo(tmp.path());
1348 for i in 0..5 {
1349 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
1350 repo.snapshot(&format!("turn:{i}")).unwrap();
1351 }
1352 let three = repo.list(3).unwrap();
1353 assert_eq!(three.len(), 3);
1354 // Newest first.
1355 assert_eq!(three[0].label, "turn:4");
1356 }
1357
1358 #[test]
1359 fn prune_drops_snapshots_older_than_threshold() {
1360 let tmp = tempdir().unwrap();
1361 let (repo, _home) = make_repo(tmp.path());
1362 std::fs::write(repo.work_tree().join("f.txt"), "v0").unwrap();
1363 repo.snapshot("turn:0").unwrap();
1364
1365 // Wait one second so the snapshot's commit timestamp is strictly
1366 // in the past relative to the prune call's "now" — otherwise
1367 // same-second comparisons make the assertion flaky.
1368 std::thread::sleep(Duration::from_millis(1100));
1369
1370 let removed = repo.prune_older_than(Duration::from_secs(0)).unwrap();
1371 assert!(removed >= 1, "expected at least 1 pruned, got {removed}");
1372
1373 // After pruning everything, the next snapshot should start a
1374 // fresh history.
1375 std::fs::write(repo.work_tree().join("f.txt"), "v1").unwrap();
1376 repo.snapshot("turn:1").unwrap();
1377 let list = repo.list(10).unwrap();
1378 assert_eq!(list.len(), 1);
1379 assert_eq!(list[0].label, "turn:1");
1380 }
1381
1382 /// The 2026-08-04 regression: with a cut in the MIDDLE of history,
1383 /// `prune_older_than` used to `update-ref HEAD <oldest survivor>`, which
1384 /// orphaned (and gc destroyed) the NEWEST snapshots while keeping the
1385 /// old ones as ancestors — the inverse of the intent, firing on every
1386 /// boot. This pins the correct partial-cut behavior.
1387 #[test]
1388 fn prune_older_than_keeps_the_newest_and_drops_only_the_old_tail() {
1389 let tmp = tempdir().unwrap();
1390 let (repo, _home) = make_repo(tmp.path());
1391
1392 // Two "old" snapshots, then a pause, then two "new" ones.
1393 for i in 0..2 {
1394 std::fs::write(repo.work_tree().join("f.txt"), format!("old{i}")).unwrap();
1395 repo.snapshot(&format!("old:{i}")).unwrap();
1396 std::thread::sleep(Duration::from_millis(1100));
1397 }
1398 // A wide gap so git's whole-second commit timestamps land the cut
1399 // unambiguously between the old and new pairs. The margins are
1400 // deliberately generous: this test runs under full-suite parallelism
1401 // where a sleep can overrun, and the cut is wall-clock. At prune time
1402 // the newest pair is ~0-1.2s old against a 6s cutoff, and the old
1403 // pair is ~9s old — ~5s of slack in both directions.
1404 std::thread::sleep(Duration::from_secs(8));
1405 for i in 0..2 {
1406 std::fs::write(repo.work_tree().join("f.txt"), format!("new{i}")).unwrap();
1407 repo.snapshot(&format!("new:{i}")).unwrap();
1408 if i == 0 {
1409 std::thread::sleep(Duration::from_millis(1100));
1410 }
1411 }
1412 let before = repo.list(usize::MAX).unwrap();
1413 assert_eq!(before.len(), 4);
1414 // Guard the fixture itself: if load skewed the timestamps so the cut
1415 // would not fall between the pairs, say so instead of failing later
1416 // with a confusing count mismatch.
1417 let now = std::time::SystemTime::now()
1418 .duration_since(std::time::UNIX_EPOCH)
1419 .unwrap()
1420 .as_secs() as i64;
1421 assert!(
1422 now - before[0].timestamp < 6 && now - before[2].timestamp > 6,
1423 "fixture ages unusable for a 6s cut (newest {}s, oldest-surviving-pair {}s)",
1424 now - before[0].timestamp,
1425 now - before[2].timestamp
1426 );
1427
1428 // Cut 6s back: the two old snapshots drop, the two new ones survive.
1429 let removed = repo.prune_older_than(Duration::from_secs(6)).unwrap();
1430 assert_eq!(removed, 2, "only the old tail should be removed");
1431
1432 let remaining = repo.list(usize::MAX).unwrap();
1433 assert_eq!(remaining.len(), 2, "the two newest must survive");
1434 assert_eq!(
1435 remaining[0].label, "new:1",
1436 "newest survives (was destroyed before)"
1437 );
1438 assert_eq!(remaining[1].label, "new:0");
1439 assert!(
1440 !remaining.iter().any(|s| s.label.starts_with("old:")),
1441 "old snapshots must be gone, not kept as ancestors: {:?}",
1442 remaining.iter().map(|s| &s.label).collect::<Vec<_>>()
1443 );
1444
1445 // The survivors' contents are intact and restorable.
1446 repo.restore(&remaining[0].id).unwrap();
1447 assert_eq!(
1448 std::fs::read_to_string(repo.work_tree().join("f.txt")).unwrap(),
1449 "new1"
1450 );
1451 }
1452
1453 #[test]
1454 fn prune_keep_last_n_keeps_latest_and_gc_reclaims_rest() {
1455 let tmp = tempdir().unwrap();
1456 let (repo, _home) = make_repo(tmp.path());
1457
1458 for i in 0..3 {
1459 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
1460 repo.snapshot(&format!("turn:{i}")).unwrap();
1461 std::thread::sleep(Duration::from_millis(1100));
1462 }
1463
1464 assert_eq!(repo.list(usize::MAX).unwrap().len(), 3);
1465
1466 let removed = repo.prune_keep_last_n(1).unwrap();
1467 assert_eq!(removed, 2);
1468
1469 let remaining = repo.list(usize::MAX).unwrap();
1470 assert_eq!(remaining.len(), 1);
1471 assert_eq!(remaining[0].label, "turn:2");
1472
1473 // New snapshot starts a clean chain (not appending to old).
1474 std::fs::write(repo.work_tree().join("f.txt"), "fresh").unwrap();
1475 repo.snapshot("turn:new").unwrap();
1476 assert_eq!(repo.list(usize::MAX).unwrap().len(), 2);
1477 }
1478
1479 #[test]
1480 fn prune_keep_last_n_preserves_multiple_snapshots_in_order() {
1481 let tmp = tempdir().unwrap();
1482 let (repo, _home) = make_repo(tmp.path());
1483
1484 for i in 0..4 {
1485 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
1486 repo.snapshot(&format!("turn:{i}")).unwrap();
1487 std::thread::sleep(Duration::from_millis(1100));
1488 }
1489
1490 assert_eq!(repo.list(usize::MAX).unwrap().len(), 4);
1491
1492 let removed = repo.prune_keep_last_n(2).unwrap();
1493 assert_eq!(removed, 2);
1494
1495 let remaining = repo.list(usize::MAX).unwrap();
1496 assert_eq!(remaining.len(), 2);
1497 // Should be newest-first: turn:3 (newest), turn:2 (second newest)
1498 assert_eq!(remaining[0].label, "turn:3");
1499 assert_eq!(remaining[1].label, "turn:2");
1500
1501 // New snapshot continues the chain.
1502 std::fs::write(repo.work_tree().join("f.txt"), "fresh").unwrap();
1503 repo.snapshot("turn:new").unwrap();
1504 let after = repo.list(usize::MAX).unwrap();
1505 assert_eq!(after.len(), 3);
1506 assert_eq!(after[0].label, "turn:new");
1507 }
1508
1509 #[test]
1510 fn open_or_init_removes_stale_tmp_pack_files_only() {
1511 let tmp = tempdir().unwrap();
1512 let (repo, _home) = make_repo(tmp.path());
1513 let workspace = repo.work_tree().to_path_buf();
1514 let pack_dir = repo.git_dir().join("objects").join("pack");
1515 std::fs::create_dir_all(&pack_dir).unwrap();
1516
1517 let stale = pack_dir.join("tmp_pack_stale");
1518 let fresh = pack_dir.join("tmp_pack_fresh");
1519 let ordinary_pack = pack_dir.join("pack-kept.pack");
1520 std::fs::write(&stale, b"stale").unwrap();
1521 std::fs::write(&fresh, b"fresh").unwrap();
1522 std::fs::write(&ordinary_pack, b"pack").unwrap();
1523
1524 let old_time = SystemTime::now() - STALE_TMP_PACK_AGE - Duration::from_secs(60);
1525 {
1526 let file = File::options().write(true).open(&stale).unwrap();
1527 file.set_times(FileTimes::new().set_modified(old_time))
1528 .unwrap();
1529 }
1530
1531 SnapshotRepo::open_or_init(&workspace).unwrap();
1532
1533 assert!(!stale.exists(), "stale tmp_pack file should be removed");
1534 assert!(fresh.exists(), "fresh tmp_pack file should be kept");
1535 assert!(ordinary_pack.exists(), "non-temp pack file should be kept");
1536 }
1537
1538 #[test]
1539 fn snapshot_respects_workspace_gitignore() {
1540 let tmp = tempdir().unwrap();
1541 let (repo, _home) = make_repo(tmp.path());
1542 std::fs::write(repo.work_tree().join(".gitignore"), "ignored.txt\n").unwrap();
1543 std::fs::write(repo.work_tree().join("ignored.txt"), b"secret").unwrap();
1544 std::fs::write(repo.work_tree().join("kept.txt"), b"public").unwrap();
1545
1546 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1547
1548 // `git ls-tree` against the snapshot's commit shouldn't list ignored.txt.
1549 let ls = run_git(
1550 repo.git_dir(),
1551 repo.work_tree(),
1552 &["ls-tree", "-r", "--name-only", id.as_str()],
1553 )
1554 .expect("ls-tree");
1555 let names = String::from_utf8_lossy(&ls.stdout);
1556 assert!(names.contains("kept.txt"), "kept.txt missing: {names}");
1557 assert!(
1558 !names.contains("ignored.txt"),
1559 "ignored.txt should not be in snapshot: {names}",
1560 );
1561 }
1562
1563 #[test]
1564 fn unsafe_workspace_rejects_home_directory_workspace() {
1565 let tmp = tempdir().unwrap();
1566 let home = tmp.path();
1567
1568 assert_eq!(
1569 unsafe_workspace_snapshot_reason(home, Some(home)),
1570 Some("home directory")
1571 );
1572 }
1573
1574 #[test]
1575 fn unsafe_workspace_rejects_home_collection_directories() {
1576 let tmp = tempdir().unwrap();
1577 let home = tmp.path();
1578 let desktop = tmp.path().join("Desktop");
1579 std::fs::create_dir_all(&desktop).unwrap();
1580
1581 assert_eq!(
1582 unsafe_workspace_snapshot_reason(&desktop, Some(home)),
1583 Some("home collection directory")
1584 );
1585 }
1586
1587 #[test]
1588 fn unsafe_workspace_allows_project_directories_under_home() {
1589 let tmp = tempdir().unwrap();
1590 let home = tmp.path();
1591 let workspace = tmp.path().join("code").join("project");
1592 std::fs::create_dir_all(&workspace).unwrap();
1593
1594 assert_eq!(
1595 unsafe_workspace_snapshot_reason(&workspace, Some(home)),
1596 None
1597 );
1598 }
1599
1600 #[test]
1601 fn snapshot_respects_builtin_excludes() {
1602 let tmp = tempdir().unwrap();
1603 let (repo, _home) = make_repo(tmp.path());
1604 std::fs::create_dir_all(repo.work_tree().join("node_modules/pkg")).unwrap();
1605 std::fs::create_dir_all(repo.work_tree().join(".next/cache")).unwrap();
1606 std::fs::create_dir_all(repo.work_tree().join("src")).unwrap();
1607 std::fs::write(
1608 repo.work_tree().join("node_modules/pkg/index.js"),
1609 b"generated",
1610 )
1611 .unwrap();
1612 std::fs::write(repo.work_tree().join(".next/cache/chunk.bin"), b"generated").unwrap();
1613 std::fs::write(repo.work_tree().join("debug.wasm"), b"binary").unwrap();
1614 std::fs::write(repo.work_tree().join("src/main.rs"), b"fn main() {}").unwrap();
1615
1616 let excludes = std::fs::read_to_string(repo.git_dir().join("info/exclude")).unwrap();
1617 assert!(excludes.contains("node_modules/"));
1618 assert!(excludes.contains(".next/"));
1619 assert!(excludes.contains("*.wasm"));
1620
1621 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1622 let ls = run_git(
1623 repo.git_dir(),
1624 repo.work_tree(),
1625 &["ls-tree", "-r", "--name-only", id.as_str()],
1626 )
1627 .expect("ls-tree");
1628 let names = String::from_utf8_lossy(&ls.stdout);
1629 assert!(
1630 names.contains("src/main.rs"),
1631 "src/main.rs missing: {names}"
1632 );
1633 assert!(
1634 !names.contains("node_modules"),
1635 "node_modules should not be in snapshot: {names}",
1636 );
1637 assert!(
1638 !names.contains(".next"),
1639 ".next should not be in snapshot: {names}",
1640 );
1641 assert!(
1642 !names.contains("debug.wasm"),
1643 "binary artifacts should not be in snapshot: {names}",
1644 );
1645 }
1646
1647 #[test]
1648 fn open_or_init_is_idempotent() {
1649 let tmp = tempdir().unwrap();
1650 let (_r, _h) = make_repo(tmp.path());
1651 // Second open should not panic and should reuse the existing
1652 // `.git`. We re-open via the public API rather than make_repo to
1653 // avoid double-acquiring HOME (the guard would deadlock).
1654 drop((_r, _h));
1655 let (_r2, _h2) = make_repo(tmp.path());
1656 }
1657
1658 #[test]
1659 fn home_directory_guard_matches_canonical_paths() {
1660 let tmp = tempdir().unwrap();
1661 let home = tmp.path();
1662 let home_canonical = home.canonicalize().unwrap();
1663 let workspace = home.join("workspace");
1664 std::fs::create_dir_all(&workspace).unwrap();
1665 let workspace_canonical = workspace.canonicalize().unwrap();
1666
1667 assert!(is_home_directory(&home_canonical, Some(home)));
1668 assert!(!is_home_directory(&workspace_canonical, Some(home)));
1669 assert!(!is_home_directory(&home_canonical, None));
1670 }
1671
1672 #[test]
1673 fn dir_size_bytes_measures_directory_bytes() {
1674 let tmp = tempdir().unwrap();
1675 let dir = tmp.path().join("sizedir");
1676 std::fs::create_dir_all(dir.join("sub")).unwrap();
1677 // 3 bytes per file.
1678 std::fs::write(dir.join("a.txt"), b"abc").unwrap();
1679 std::fs::write(dir.join("sub/b.txt"), b"xyz").unwrap();
1680
1681 let size = dir_size_bytes(&dir).expect("dir_size_bytes");
1682 assert_eq!(size, 6, "two 3-byte files should measure 6 bytes");
1683
1684 // Write 2 MB of data.
1685 let big = dir.join("big.bin");
1686 std::fs::write(&big, vec![0u8; 2 * 1024 * 1024]).unwrap();
1687 let size = dir_size_bytes(&dir).expect("dir_size_bytes after big write");
1688 assert_eq!(
1689 size,
1690 2 * 1024 * 1024 + 6,
1691 "expected 2 MB + 6 bytes after writing a 2 MB file"
1692 );
1693 }
1694
1695 /// Regression: snapshot size cap (#1112). When the snapshot dir grows,
1696 /// `snapshot()` must prune old snapshots to stay under the limit.
1697 /// This test uses the real size constants, which are 500/400 MB —
1698 /// we can't easily blow up a temp dir to 500 MB in a unit test.
1699 /// Instead we verify the guard logic doesn't panic or error on a
1700 /// small repo (well under the cap), and that `snapshot()` still works.
1701 #[test]
1702 fn snapshot_succeeds_when_under_size_cap() {
1703 let tmp = tempdir().unwrap();
1704 let (repo, _home) = make_repo(tmp.path());
1705 // The side repo is tiny — well under 500 MB. Snapshot should work.
1706 std::fs::write(repo.work_tree().join("f.txt"), b"hello").unwrap();
1707 let id = repo.snapshot("pre-turn:1").expect("snapshot under cap");
1708 assert_eq!(id.as_str().len(), 40);
1709 }
1710
1711 #[test]
1712 fn prune_size_pressure_counts_and_removes_history_when_over_limit() {
1713 let tmp = tempdir().unwrap();
1714 let (repo, _home) = make_repo(tmp.path());
1715 for i in 0..3 {
1716 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
1717 repo.snapshot(&format!("pre-turn:{i}")).expect("snapshot");
1718 }
1719 assert_eq!(repo.list(usize::MAX).unwrap().len(), 3);
1720 // A zero byte limit makes any non-empty side repo "over limit", so the
1721 // prune must run and report exactly what it destroyed. This is the S5
1722 // wipe path; the count is what the user-visible notice is built from.
1723 let removed = repo.prune_size_pressure(0, 0).expect("prune_size_pressure");
1724 assert_eq!(removed, 3, "every snapshot must be reported as removed");
1725 assert!(
1726 repo.list(usize::MAX).unwrap().is_empty(),
1727 "history should be empty after the forced wipe"
1728 );
1729 }
1730
1731 #[test]
1732 fn prune_size_pressure_is_a_noop_under_the_limit() {
1733 let tmp = tempdir().unwrap();
1734 let (repo, _home) = make_repo(tmp.path());
1735 std::fs::write(repo.work_tree().join("f.txt"), b"v0").unwrap();
1736 repo.snapshot("pre-turn:0").expect("snapshot");
1737 let removed = repo
1738 .prune_size_pressure(u64::MAX, u64::MAX)
1739 .expect("prune_size_pressure");
1740 assert_eq!(removed, 0, "under the limit nothing may be removed");
1741 assert_eq!(repo.list(usize::MAX).unwrap().len(), 1);
1742 }
1743
1744 #[test]
1745 fn snapshot_history_pruned_message_names_workspace_count_and_cap() {
1746 let msg = snapshot_history_pruned_message(Path::new("/tmp/ws"), 7);
1747 assert!(msg.contains("/tmp/ws"), "message must name the workspace");
1748 assert!(msg.contains("7"), "message must state the removed count");
1749 assert!(
1750 msg.contains(&MAX_SNAPSHOT_SIZE_MB.to_string()),
1751 "message must state the storage cap"
1752 );
1753 }
1754
1755 #[test]
1756 fn estimate_workspace_size_bounded_returns_total_when_under_cap() {
1757 let tmp = tempdir().unwrap();
1758 let workspace = tmp.path().join("workspace");
1759 std::fs::create_dir_all(&workspace).unwrap();
1760 std::fs::write(workspace.join("a.txt"), vec![b'a'; 100]).unwrap();
1761 std::fs::write(workspace.join("b.txt"), vec![b'b'; 50]).unwrap();
1762 let total = estimate_workspace_size_bounded(&workspace, 10_000)
1763 .expect("under-cap walk must return Some");
1764 assert!(
1765 total >= 150,
1766 "total ({total}) must include both files (≥150 bytes)"
1767 );
1768 }
1769
1770 #[test]
1771 fn estimate_workspace_size_bounded_returns_none_when_over_cap() {
1772 let tmp = tempdir().unwrap();
1773 let workspace = tmp.path().join("workspace");
1774 std::fs::create_dir_all(&workspace).unwrap();
1775 // Two 1 KB files, cap at 1 KB — second file should trip the cap.
1776 std::fs::write(workspace.join("a.bin"), vec![b'a'; 1024]).unwrap();
1777 std::fs::write(workspace.join("b.bin"), vec![b'b'; 1024]).unwrap();
1778 assert!(
1779 estimate_workspace_size_bounded(&workspace, 1024).is_none(),
1780 "over-cap walk must return None for early bailout"
1781 );
1782 }
1783
1784 #[test]
1785 fn estimate_workspace_size_bounded_skips_builtin_excluded_dirs() {
1786 let tmp = tempdir().unwrap();
1787 let workspace = tmp.path().join("workspace");
1788 std::fs::create_dir_all(workspace.join("node_modules")).unwrap();
1789 std::fs::create_dir_all(workspace.join("target")).unwrap();
1790 std::fs::create_dir_all(workspace.join("src")).unwrap();
1791 // 2 MB of "build output" in excluded dirs — must not count toward
1792 // the cap.
1793 std::fs::write(workspace.join("node_modules/big.bin"), vec![0u8; 1_000_000]).unwrap();
1794 std::fs::write(workspace.join("target/big.bin"), vec![0u8; 1_000_000]).unwrap();
1795 std::fs::write(workspace.join("src/lib.rs"), b"// real source").unwrap();
1796 let total = estimate_workspace_size_bounded(&workspace, 500_000)
1797 .expect("walk must succeed since real source is tiny");
1798 assert!(
1799 total < 1_000,
1800 "total ({total}) must reflect only src/, not node_modules/ or target/"
1801 );
1802 }
1803
1804 #[test]
1805 fn estimate_workspace_size_bounded_cap_zero_disables_cap() {
1806 let tmp = tempdir().unwrap();
1807 let workspace = tmp.path().join("workspace");
1808 std::fs::create_dir_all(&workspace).unwrap();
1809 // 10 KB file — would trip a 1 KB cap, but cap=0 means no cap.
1810 std::fs::write(workspace.join("big.bin"), vec![0u8; 10 * 1024]).unwrap();
1811 let total =
1812 estimate_workspace_size_bounded(&workspace, 0).expect("cap=0 must always return Some");
1813 assert!(
1814 total >= 10 * 1024,
1815 "total ({total}) must include the 10 KB file when cap is disabled"
1816 );
1817 }
1818
1819 #[test]
1820 fn open_or_init_with_cap_rejects_oversized_workspace() {
1821 let tmp = tempdir().unwrap();
1822 let workspace = tmp.path().join("workspace");
1823 std::fs::create_dir_all(&workspace).unwrap();
1824 let _home = scoped_home(tmp.path());
1825 // Drop a 4 KB file under a 1 KB cap.
1826 std::fs::write(workspace.join("big.bin"), vec![0u8; 4096]).unwrap();
1827 let outcome = SnapshotRepo::open_or_init_with_cap(&workspace, 1024);
1828 let err = match outcome {
1829 Ok(_) => panic!("oversized workspace must fail open_or_init_with_cap"),
1830 Err(e) => e,
1831 };
1832 let msg = err.to_string();
1833 assert!(
1834 msg.contains("workspace too large for snapshots"),
1835 "error must call out the size cap; got: {msg}"
1836 );
1837 assert!(
1838 msg.contains("max_workspace_gb"),
1839 "error must reference the config knob users can raise; got: {msg}"
1840 );
1841 }
1842
1843 #[test]
1844 fn open_or_init_with_cap_zero_disables_size_check() {
1845 let tmp = tempdir().unwrap();
1846 let workspace = tmp.path().join("workspace");
1847 std::fs::create_dir_all(&workspace).unwrap();
1848 let _home = scoped_home(tmp.path());
1849 // 4 KB file but cap=0 → should still succeed.
1850 std::fs::write(workspace.join("big.bin"), vec![0u8; 4096]).unwrap();
1851 let repo = SnapshotRepo::open_or_init_with_cap(&workspace, 0)
1852 .expect("cap=0 must skip the size check");
1853 let id = repo
1854 .snapshot("pre-turn:1")
1855 .expect("snapshot under disabled cap");
1856 assert_eq!(id.as_str().len(), 40);
1857 }
1858
1859 #[test]
1860 fn session_tagged_snapshot_round_trips_through_list() {
1861 let tmp = tempdir().unwrap();
1862 let (repo, _home) = make_repo(tmp.path());
1863 std::fs::write(repo.work_tree().join("a.txt"), b"x").unwrap();
1864
1865 repo.snapshot_with_session("pre-turn:1", Some("sess-42"))
1866 .expect("snapshot with session");
1867
1868 let list = repo.list(10).expect("list");
1869 assert_eq!(list.len(), 1);
1870 // The visible label stays clean; the session id is decoded separately.
1871 assert_eq!(list[0].label, "pre-turn:1");
1872 assert_eq!(list[0].session_id.as_deref(), Some("sess-42"));
1873 }
1874
1875 #[test]
1876 fn untagged_snapshot_decodes_without_session() {
1877 let tmp = tempdir().unwrap();
1878 let (repo, _home) = make_repo(tmp.path());
1879 std::fs::write(repo.work_tree().join("a.txt"), b"x").unwrap();
1880
1881 repo.snapshot("pre-turn:1").expect("snapshot");
1882
1883 let list = repo.list(10).expect("list");
1884 assert_eq!(list.len(), 1);
1885 assert_eq!(list[0].label, "pre-turn:1");
1886 assert_eq!(list[0].session_id, None);
1887 }
1888
1889 #[test]
1890 fn prune_keep_last_n_preserves_session_tags() {
1891 let tmp = tempdir().unwrap();
1892 let (repo, _home) = make_repo(tmp.path());
1893 let file = repo.work_tree().join("a.txt");
1894
1895 // More snapshots than DEFAULT_MAX_SNAPSHOTS (50) so the survivor
1896 // chain is rebuilt as orphan commits — the path that previously
1897 // dropped the [sid=...] label prefix and turned every surviving
1898 // snapshot into a "legacy" (untagged) one.
1899 for i in 0..55 {
1900 std::fs::write(&file, format!("v{i}")).unwrap();
1901 repo.snapshot_with_session(&format!("pre-turn:{i}"), Some("sess-p"))
1902 .expect("tagged snapshot");
1903 }
1904
1905 let removed = repo.prune_keep_last_n(50).expect("prune");
1906 assert!(removed > 0, "expected prune to drop older snapshots");
1907
1908 let list = repo.list(usize::MAX).expect("list");
1909 assert_eq!(list.len(), 50);
1910 assert!(
1911 list.iter()
1912 .all(|s| s.session_id.as_deref() == Some("sess-p")),
1913 "prune must preserve [sid=...] prefixes; got untagged survivors"
1914 );
1915 }
1916
1917 #[test]
1918 fn tagged_and_untagged_snapshots_coexist_in_one_chain() {
1919 let tmp = tempdir().unwrap();
1920 let (repo, _home) = make_repo(tmp.path());
1921 std::fs::write(repo.work_tree().join("a.txt"), b"v1").unwrap();
1922
1923 // Legacy untagged snapshot, then a session-tagged one.
1924 repo.snapshot("pre-turn:1").expect("legacy snapshot");
1925 std::fs::write(repo.work_tree().join("a.txt"), b"v2").unwrap();
1926 repo.snapshot_with_session("pre-turn:1", Some("sess-a"))
1927 .expect("tagged snapshot");
1928
1929 let list = repo.list(10).expect("list");
1930 assert_eq!(list.len(), 2);
1931 // Newest first.
1932 assert_eq!(list[0].session_id.as_deref(), Some("sess-a"));
1933 assert_eq!(list[1].session_id, None);
1934 assert_eq!(list[1].label, "pre-turn:1");
1935 }
1936 }
1937
1937 lines RUST