返回 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 /// What a file-scoped restore did to one path, relative to the working tree
52 /// it was applied to.
53 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 pub enum PathRestoreAction {
55 /// Both the snapshot and the working tree had the path; its content came
56 /// back from the snapshot.
57 Modified,
58 /// The snapshot had the path and the working tree no longer did, so the
59 /// restore recreated the file.
60 Recreated,
61 /// The working tree had the path and the snapshot did not, so the restore
62 /// removed the file.
63 Removed,
64 }
65
66 impl PathRestoreAction {
67 /// Stable wire name, also used by the runtime API response.
68 pub fn as_str(self) -> &'static str {
69 match self {
70 Self::Modified => "modified",
71 Self::Recreated => "recreated",
72 Self::Removed => "removed",
73 }
74 }
75 }
76
77 /// Report of what [`SnapshotRepo::restore_paths`] did to one path.
78 #[derive(Debug, Clone)]
79 pub struct PathRestoreOutcome {
80 /// Workspace-relative path that was restored.
81 pub path: PathBuf,
82 /// How the working tree changed.
83 pub action: PathRestoreAction,
84 }
85
86 /// Wrapper around the per-workspace side-git repo.
87 pub struct SnapshotRepo {
88 git_dir: PathBuf,
89 work_tree: PathBuf,
90 }
91
92 const STALE_TMP_PACK_AGE: Duration = Duration::from_secs(60 * 60);
93
94 /// Maximum total snapshot storage in megabytes before pruning kicks in at
95 /// snapshot time. Keeps the side repo from blowing up the user's disk during
96 /// long-running or high-churn sessions (#1112).
97 const MAX_SNAPSHOT_SIZE_MB: u64 = 500;
98
99 const BYTES_PER_MB: u64 = 1024 * 1024;
100
101 /// Grace margin below `MAX_SNAPSHOT_SIZE_MB` used as the prune target
102 /// so the repo doesn't hit the limit again one snapshot later.
103 const PRUNE_TARGET_MB: u64 = 400;
104
105 /// Default workspace-size ceiling above which snapshots self-disable
106 /// on first use (2 GB of non-excluded content). Reports from users with
107 /// multi-hundred-GB project directories — datasets, model weights,
108 /// docker image dumps that fall outside the built-in excludes —
109 /// surfaced that `git add -A` on first init would hang the TUI for
110 /// minutes-to-hours while indexing the workspace. Snapshots are a
111 /// rollback safety net, not a backup tool; bailing out on workspaces
112 /// that big is the right tradeoff. Users with legitimate large
113 /// monorepos can raise `[snapshots] max_workspace_gb` (or set it to
114 /// `0` to disable the cap entirely).
115 pub const DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT: u64 = 2 * 1024 * 1024 * 1024;
116
117 /// Hard cap on the number of file entries the bounded size estimator
118 /// will inspect before declaring the workspace "too large". Protects
119 /// against a workspace with millions of tiny files (no individual
120 /// file is large, but `git add -A` would still take forever).
121 pub const SIZE_WALK_MAX_ENTRIES: usize = 200_000;
122
123 /// Which snapshot gate refused a workspace. The recovery differs per gate —
124 /// raising `[snapshots] max_workspace_gb` lifts only [`WorkspaceGate::TooLarge`]
125 /// — so callers must not offer one gate's remedy for another's failure.
126 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
127 pub enum WorkspaceGate {
128 /// Snapshot-eligible content exceeds the configured byte cap.
129 TooLarge,
130 /// The bounded walk hit [`SIZE_WALK_MAX_ENTRIES`]. This bound is
131 /// independent of the byte cap: `max_workspace_gb = 0` does not lift it.
132 TooManyEntries,
133 }
134
135 /// Leading text of the `io::Error` each gate produces. `core::turn` matches on
136 /// these to pick the right consequence/recovery notice, so they are one
137 /// declaration shared by producer and matcher rather than two literals.
138 pub const GATE_TOO_LARGE_MARKER: &str = "workspace too large for snapshots";
139 pub const GATE_TOO_MANY_ENTRIES_MARKER: &str = "workspace has too many files for snapshots";
140 pub const GATE_UNSAFE_LOCATION_MARKER: &str = "workspace snapshots are disabled";
141
142 /// Display a workspace path in gate diagnostics. The diagnostic names the
143 /// path the caller passed in — canonicalization is for filesystem and
144 /// security logic, not for the message. On Windows `Path::canonicalize`
145 /// rewrites more than the verbatim (`\\?\`) prefix (case, 8.3 names), so a
146 /// canonical spelling can never be relied on to match what users name; the
147 /// verbatim prefix is still stripped when present for readability.
148 fn display_workspace_for_gate(workspace: &Path) -> String {
149 let raw = workspace.display().to_string();
150 raw.strip_prefix(r"\\?\")
151 .or_else(|| raw.strip_prefix("//?/"))
152 .unwrap_or(&raw)
153 .to_string()
154 }
155
156 impl WorkspaceGate {
157 /// One-line English diagnostic for logs, `/undo`, and the gate matcher.
158 /// The user-facing consequence and recovery are localized by the notice
159 /// surfaces; this string must not restate them.
160 fn describe(self, cap_bytes: u64, workspace: &Path) -> String {
161 let workspace = display_workspace_for_gate(workspace);
162 match self {
163 Self::TooLarge => format!(
164 "{GATE_TOO_LARGE_MARKER}: over {} bytes of snapshot-eligible content in {workspace}",
165 cap_bytes,
166 ),
167 Self::TooManyEntries => format!(
168 "{GATE_TOO_MANY_ENTRIES_MARKER}: over {SIZE_WALK_MAX_ENTRIES} snapshot-eligible entries in {workspace}"
169 ),
170 }
171 }
172 }
173
174 /// Top-level directory and extension patterns that the snapshot path
175 /// already excludes via `BUILTIN_EXCLUDES`. The estimator skips these
176 /// up front so the size walk reflects what would actually land in the
177 /// snapshot commit. Kept narrow to common build-output dirs — anything
178 /// else falls back to the `.gitignore` filter.
179 const SIZE_WALK_SKIP_DIRS: &[&str] = &[
180 "node_modules",
181 "target",
182 "dist",
183 "build",
184 ".build",
185 ".next",
186 ".nuxt",
187 ".svelte-kit",
188 ".turbo",
189 ".parcel-cache",
190 "vendor",
191 ".cargo",
192 ".rustup",
193 ".npm",
194 ".bun",
195 ".yarn",
196 ".pnpm-store",
197 ".cache",
198 ".venv",
199 "venv",
200 ".tox",
201 "__pycache__",
202 ".mypy_cache",
203 ".pytest_cache",
204 ".ruff_cache",
205 ".gradle",
206 ".m2",
207 ".local",
208 ".git",
209 ];
210
211 const BUILTIN_EXCLUDES: &str = "\
212 # CodeWhale built-in snapshot exclusions
213 node_modules/
214 target/
215 dist/
216 build/
217 .build/
218 .next/
219 .nuxt/
220 .svelte-kit/
221 .turbo/
222 .parcel-cache/
223 vendor/
224 .cargo/
225 .rustup/
226 .npm/
227 .bun/
228 .yarn/
229 .pnpm-store/
230 .cache/
231 .venv/
232 venv/
233 .tox/
234 __pycache__/
235 *.pyc
236 .mypy_cache/
237 .pytest_cache/
238 .ruff_cache/
239 .gradle/
240 .m2/
241 .local/
242 .DS_Store
243
244 # Binary and generated artifacts. Snapshots are source rollback checkpoints,
245 # not a full binary backup; keeping these out avoids side-repo bloat.
246 *.exe
247 *.dll
248 *.so
249 *.dylib
250 *.wasm
251 *.o
252 *.obj
253 *.class
254 *.pdb
255 *.dSYM
256 *.zip
257 *.tar
258 *.tar.gz
259 *.tgz
260 *.tar.bz2
261 *.tar.xz
262 *.7z
263 *.rar
264 *.iso
265 *.dmg
266 *.bin
267 *.mp4
268 *.mov
269 *.mkv
270 *.avi
271 *.webm
272 *.mp3
273 *.wav
274 *.flac
275 *.aac
276 ";
277
278 impl SnapshotRepo {
279 /// Open an existing snapshot repo for `workspace` without creating or
280 /// initializing anything on disk.
281 ///
282 /// This is useful for read-only UI surfaces that want to report checkpoint
283 /// availability without paying the first-init size walk or surprising the
284 /// user by creating a side repo from a view action.
285 pub fn open_existing(workspace: &Path) -> io::Result<Option<Self>> {
286 let work_tree = workspace
287 .canonicalize()
288 .unwrap_or_else(|_| workspace.to_path_buf());
289 let git_dir = snapshot_git_dir(&work_tree);
290 if !git_dir.exists() || !git_dir.join("HEAD").exists() {
291 return Ok(None);
292 }
293 Ok(Some(Self { git_dir, work_tree }))
294 }
295
296 /// Open or initialize the snapshot repo for `workspace`.
297 ///
298 /// On first use this:
299 /// 1. Creates the `~/.deepseek/snapshots/<…>/.git` dir.
300 /// 2. Runs `git init --bare=false --quiet`.
301 /// 3. Sets a fixed `user.name` / `user.email` so commits don't pick up
302 /// the user's global git identity (we don't want our snapshots to
303 /// look like they came from the user).
304 pub fn open_or_init(workspace: &Path) -> io::Result<Self> {
305 Self::open_or_init_with_cap(workspace, DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT)
306 }
307
308 /// Variant of [`Self::open_or_init`] that accepts an explicit
309 /// workspace-size cap. `cap_bytes = 0` disables the cap entirely
310 /// (always snapshot, regardless of size).
311 ///
312 /// When the workspace exceeds the cap and the side repo hasn't
313 /// been initialized yet, returns `Err(InvalidInput)` with a
314 /// "workspace too large" reason. Subsequent calls (after the user
315 /// shrinks the workspace or raises the cap via config) succeed.
316 pub fn open_or_init_with_cap(workspace: &Path, cap_bytes: u64) -> io::Result<Self> {
317 let work_tree = workspace
318 .canonicalize()
319 .unwrap_or_else(|_| workspace.to_path_buf());
320 if let Some(reason) = unsafe_workspace_snapshot_reason(
321 &work_tree,
322 crate::config::effective_home_dir().as_deref(),
323 ) {
324 return Err(io::Error::new(
325 io::ErrorKind::InvalidInput,
326 format!(
327 "{GATE_UNSAFE_LOCATION_MARKER} for {reason}: {}",
328 display_workspace_for_gate(workspace)
329 ),
330 ));
331 }
332
333 let _ = ensure_snapshot_dir(&work_tree)?;
334 let git_dir = snapshot_git_dir(&work_tree);
335
336 let needs_init = !git_dir.exists();
337 if needs_init {
338 // First-init size guard. Skipping this on subsequent opens
339 // is intentional: paying a workspace walk on every snapshot
340 // would defeat the purpose of the cap, and a workspace
341 // that fit on first init is allowed to grow within the
342 // existing repo's `MAX_SNAPSHOT_SIZE_MB` budget. Users on
343 // workspaces that grew past the cap mid-session get the
344 // existing aggressive-pruning path in `snapshot()`.
345 if let Err(gate) =
346 estimate_workspace_size_bounded(&work_tree, cap_bytes, SIZE_WALK_MAX_ENTRIES)
347 {
348 return Err(io::Error::new(
349 io::ErrorKind::InvalidInput,
350 gate.describe(cap_bytes, workspace),
351 ));
352 }
353 let parent = git_dir.parent().ok_or_else(|| {
354 io::Error::new(io::ErrorKind::InvalidInput, "snapshot dir has no parent")
355 })?;
356 std::fs::create_dir_all(parent)?;
357 // `git init` here uses the parent directory as the work tree
358 // and stores metadata in `.git`. We then continue to use
359 // explicit `--git-dir` / `--work-tree` flags for every other
360 // command so behaviour is invariant of cwd.
361 let init = crate::dependencies::Git::command()
362 .ok_or_else(|| io_other("git not found on PATH"))?
363 .arg("init")
364 .arg("--quiet")
365 .arg(parent)
366 .output()
367 .map_err(|e| io_other(format!("failed to spawn git init: {e}")))?;
368 if !init.status.success() {
369 return Err(io_other(format!(
370 "git init failed: {}",
371 String::from_utf8_lossy(&init.stderr).trim()
372 )));
373 }
374
375 // Pin a stable identity so snapshot commits are recognisable
376 // and don't bleed into the user's git config.
377 let _ = run_git(
378 &git_dir,
379 &work_tree,
380 &["config", "user.name", "deepseek-snapshots"],
381 );
382 let _ = run_git(
383 &git_dir,
384 &work_tree,
385 &["config", "user.email", "snapshots@codewhale.local"],
386 );
387 // Don't auto-gc on every commit; we manage pruning ourselves.
388 let _ = run_git(&git_dir, &work_tree, &["config", "gc.auto", "0"]);
389 // Ignore CRLF rewriting — we want byte-for-byte fidelity.
390 let _ = run_git(&git_dir, &work_tree, &["config", "core.autocrlf", "false"]);
391 }
392
393 write_builtin_excludes(&git_dir)?;
394 if let Err(err) = cleanup_stale_pack_temps(&git_dir, STALE_TMP_PACK_AGE) {
395 tracing::debug!(
396 target: "snapshot",
397 "failed to clean stale snapshot tmp_pack files: {err}"
398 );
399 }
400 Ok(Self { git_dir, work_tree })
401 }
402
403 /// Take a snapshot of the current working tree.
404 ///
405 /// Internally: `git add -A`, `git write-tree`, `git commit-tree`, then
406 /// `git update-ref HEAD <commit>`.
407 /// `git add -A` honours the user's workspace ignore rules while staging
408 /// into the side repo's index.
409 ///
410 /// Before committing, checks whether the snapshot directory exceeds
411 /// [`MAX_SNAPSHOT_SIZE_MB`] and prunes the oldest snapshots if it does.
412 ///
413 /// Returns the snapshot's commit SHA.
414 #[allow(dead_code)] // convenience entry kept for tests and legacy callers; production writes go through snapshot_with_session
415 pub fn snapshot(&self, label: &str) -> io::Result<SnapshotId> {
416 self.snapshot_with_session(label, None)
417 }
418
419 /// Take a snapshot, tagging it with the owning session id.
420 ///
421 /// The session id is encoded into the commit message as a `[sid=...] `
422 /// label prefix. [`Self::list`] decodes it back into
423 /// [`Snapshot::session_id`] and strips the prefix from the visible
424 /// label, so existing listing surfaces keep showing the plain label.
425 /// Legacy snapshots taken through [`Self::snapshot`] carry no prefix
426 /// and decode with `session_id == None`.
427 pub fn snapshot_with_session(
428 &self,
429 label: &str,
430 session_id: Option<&str>,
431 ) -> io::Result<SnapshotId> {
432 // Guard against disk blowup (#1112): if the snapshot directory has
433 // grown beyond the limit, prune aggressively before adding more.
434 // When the prune actually destroys restore points the user is told
435 // once per workspace — losing undo history to a log line is the S5
436 // failure mode (2026-08-04 snapshot hunt).
437 if let Ok(removed) = self.prune_size_pressure(
438 MAX_SNAPSHOT_SIZE_MB * BYTES_PER_MB,
439 PRUNE_TARGET_MB * BYTES_PER_MB,
440 ) && removed > 0
441 {
442 notify_snapshot_history_pruned_once(&self.work_tree, removed);
443 }
444 // Stage every tracked + untracked path the workspace exposes.
445 // `--all` here means `add` + `update` + `remove` — the same set
446 // `git status` would show.
447 let add = run_git(&self.git_dir, &self.work_tree, &["add", "-A"])?;
448 if !add.status.success() {
449 return Err(io_other(format!(
450 "git add -A failed: {}",
451 String::from_utf8_lossy(&add.stderr).trim()
452 )));
453 }
454
455 let tree = run_git(&self.git_dir, &self.work_tree, &["write-tree"])?;
456 if !tree.status.success() {
457 return Err(io_other(format!(
458 "git write-tree failed: {}",
459 String::from_utf8_lossy(&tree.stderr).trim()
460 )));
461 }
462 let tree = String::from_utf8_lossy(&tree.stdout).trim().to_string();
463
464 let parent = run_git(
465 &self.git_dir,
466 &self.work_tree,
467 &["rev-parse", "--verify", "HEAD"],
468 )?;
469 let parent = parent
470 .status
471 .success()
472 .then(|| String::from_utf8_lossy(&parent.stdout).trim().to_string())
473 .filter(|s| !s.is_empty());
474
475 let mut args = vec!["commit-tree".to_string(), tree];
476 if let Some(parent) = parent {
477 args.push("-p".to_string());
478 args.push(parent);
479 }
480 args.push("-m".to_string());
481 args.push(Self::encode_session_label(label, session_id));
482 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
483
484 // `commit-tree` creates marker commits even when the tree matches its
485 // parent, and it does not run user/global commit hooks.
486 let commit = run_git(&self.git_dir, &self.work_tree, &arg_refs)?;
487 if !commit.status.success() {
488 return Err(io_other(format!(
489 "git commit-tree failed: {}",
490 String::from_utf8_lossy(&commit.stderr).trim()
491 )));
492 }
493 let sha = String::from_utf8_lossy(&commit.stdout).trim().to_string();
494
495 let update = run_git(
496 &self.git_dir,
497 &self.work_tree,
498 &["update-ref", "HEAD", &sha],
499 )?;
500 if !update.status.success() {
501 return Err(io_other(format!(
502 "git update-ref HEAD failed: {}",
503 String::from_utf8_lossy(&update.stderr).trim()
504 )));
505 }
506
507 Ok(SnapshotId(sha))
508 }
509
510 /// Prefix a snapshot label with its owning session id, if any.
511 fn encode_session_label(label: &str, session_id: Option<&str>) -> String {
512 match session_id {
513 Some(sid) if !sid.is_empty() => format!("[sid={sid}] {label}"),
514 _ => label.to_string(),
515 }
516 }
517
518 /// Split a possibly session-tagged label back into `(session_id, label)`.
519 ///
520 /// Returns `(None, label)` for untagged labels. The decoded label is
521 /// the original one without the `[sid=...] ` prefix, so consumers that
522 /// match on `pre-turn:`/`tool:`/`redo:` prefixes keep working unchanged.
523 fn decode_session_label(label: &str) -> (Option<String>, String) {
524 let Some(rest) = label.strip_prefix("[sid=") else {
525 return (None, label.to_string());
526 };
527 let Some(end) = rest.find("] ") else {
528 return (None, label.to_string());
529 };
530 let sid = &rest[..end];
531 let plain = &rest[end + 2..];
532 if sid.is_empty() || plain.is_empty() {
533 return (None, label.to_string());
534 }
535 (Some(sid.to_string()), plain.to_string())
536 }
537 /// Size-pressure prune (#1112): if the side repo exceeds `max_bytes`,
538 /// walk backward from a 1-second retention toward zero until the store is
539 /// at or under `target_bytes`, escalating to a full wipe when nothing
540 /// else helps. Returns the total number of snapshots destroyed, so the
541 /// caller can tell the user their undo history shrank (S5 — the wipe was
542 /// previously announced only by a `tracing::warn`).
543 fn prune_size_pressure(&self, max_bytes: u64, target_bytes: u64) -> io::Result<usize> {
544 let current_bytes = dir_size_bytes(&self.git_dir)?;
545 if current_bytes <= max_bytes {
546 return Ok(0);
547 }
548 tracing::warn!(
549 target: "snapshot",
550 current_mb = current_bytes / BYTES_PER_MB,
551 limit_mb = max_bytes / BYTES_PER_MB,
552 "snapshot storage approaching limit — pruning aggressively"
553 );
554 let mut removed_total: usize = 0;
555 // Walk backward from a 1-second retention to zero until
556 // we're under the target, or until there's nothing left.
557 let mut age = Duration::from_secs(1);
558 for _ in 0..10 {
559 if let Ok(removed) = self.prune_older_than(age) {
560 removed_total = removed_total.saturating_add(removed);
561 }
562 if let Ok(new_size) = dir_size_bytes(&self.git_dir)
563 && new_size <= target_bytes
564 {
565 tracing::info!(
566 target: "snapshot",
567 new_size_mb = new_size / BYTES_PER_MB,
568 "pruned snapshot storage back under limit"
569 );
570 break;
571 }
572 age = age.saturating_sub(Duration::from_millis(100));
573 }
574 // Fallback: if even 0-second pruning didn't help (shouldn't
575 // happen but belt-and-suspenders), nuke the refs so the next
576 // snapshot starts a fresh history.
577 if let Ok(final_size) = dir_size_bytes(&self.git_dir)
578 && final_size > max_bytes
579 {
580 tracing::warn!(
581 target: "snapshot",
582 "snapshot storage still over limit after pruning; wiping history"
583 );
584 if let Ok(removed) = self.prune_older_than(Duration::ZERO) {
585 removed_total = removed_total.saturating_add(removed);
586 }
587 let _ = self.prune_unreachable_objects();
588 }
589 Ok(removed_total)
590 }
591
592 /// Restore the workspace to the state at `id`.
593 ///
594 /// Uses `git checkout <sha> -- :/` which checks out every path in the
595 /// snapshot tree relative to the workspace root. We do NOT touch the
596 /// user's own `.git` — snapshots only contain working-tree files.
597 pub fn restore(&self, id: &SnapshotId) -> io::Result<()> {
598 // Restore is the one destructive operation with no undo of its own.
599 // Capture the pre-restore state first so the restore itself can be
600 // reversed (2026-08-04 snapshot hunt: makes several other findings
601 // recoverable instead of final). The `pre-restore:` prefix is
602 // deliberately not a `/undo` or `revert_turn` candidate label, so the
603 // safety net never changes snapshot selection. Best-effort: a failed
604 // safety snapshot must never block the restore the user asked for.
605 let target_short = &id.as_str()[..id.as_str().len().min(12)];
606 if let Err(e) = self.snapshot_with_session(&format!("pre-restore:{target_short}"), None) {
607 tracing::warn!(
608 target: "snapshot",
609 "pre-restore safety snapshot failed (restore will proceed): {e}"
610 );
611 }
612 let current_paths = self.tree_paths("HEAD")?;
613 let target_paths = self.tree_paths(id.as_str())?;
614 let checkout = run_git(
615 &self.git_dir,
616 &self.work_tree,
617 &["checkout", id.as_str(), "--", ":/"],
618 )?;
619 if !checkout.status.success() {
620 return Err(io_other(format!(
621 "git checkout failed: {}",
622 String::from_utf8_lossy(&checkout.stderr).trim()
623 )));
624 }
625 self.remove_paths_missing_from_target(&current_paths, &target_paths)?;
626 Ok(())
627 }
628
629 /// File restore never traverses symlinks, directories, or Git metadata.
630 /// Validate every existing component before reading, backing up or writing.
631 pub fn validate_restore_file(&self, rel: &Path) -> io::Result<bool> {
632 if !is_safe_relative_path(rel)
633 || rel.components().any(|part| {
634 part.as_os_str()
635 .as_encoded_bytes()
636 .eq_ignore_ascii_case(b".git")
637 })
638 {
639 return Err(io::Error::new(
640 io::ErrorKind::InvalidInput,
641 format!(
642 "refusing to restore unsafe path '{}': restore requires a regular workspace file",
643 rel.display()
644 ),
645 ));
646 }
647 let mut path = self.work_tree.clone();
648 for part in rel.components() {
649 path.push(part);
650 match std::fs::symlink_metadata(&path) {
651 Ok(meta)
652 if meta.file_type().is_symlink()
653 || (path == self.work_tree.join(rel) && !meta.is_file())
654 || (path != self.work_tree.join(rel) && !meta.is_dir()) =>
655 {
656 return Err(io::Error::new(
657 io::ErrorKind::InvalidInput,
658 "restore refuses directories, symlinks and non-regular files",
659 ));
660 }
661 Ok(_) => {}
662 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
663 Err(error) => return Err(error),
664 }
665 }
666 Ok(true)
667 }
668
669 fn snapshot_contains_regular_file(&self, id: &SnapshotId, rel: &Path) -> io::Result<bool> {
670 let entry = run_git(
671 &self.git_dir,
672 &self.work_tree,
673 &[
674 "--literal-pathspecs",
675 "ls-tree",
676 "-z",
677 id.as_str(),
678 "--",
679 rel.to_str()
680 .ok_or_else(|| io_other("restore path must be UTF-8"))?,
681 ],
682 )?;
683 if !entry.status.success() {
684 return Err(io_other(format!(
685 "Failed to inspect snapshot file: {}",
686 String::from_utf8_lossy(&entry.stderr).trim()
687 )));
688 }
689 if entry.stdout.is_empty() {
690 return Ok(false);
691 }
692 if !(entry.stdout.starts_with(b"100644 blob ") || entry.stdout.starts_with(b"100755 blob "))
693 {
694 return Err(io::Error::new(
695 io::ErrorKind::InvalidInput,
696 "snapshot path is not a regular file",
697 ));
698 }
699 Ok(true)
700 }
701
702 /// Return whether `rel` differs between snapshot `id` and the current
703 /// working tree.
704 ///
705 /// This is the single-path counterpart of
706 /// [`Self::work_tree_matches_snapshot`]: it answers "would restoring just
707 /// this file change anything?", which is what file-scoped revert
708 /// cursoring needs. A path that exists in neither the snapshot nor the
709 /// working tree does not differ.
710 pub fn path_differs_from_snapshot(&self, id: &SnapshotId, rel: &Path) -> io::Result<bool> {
711 let in_work = self.validate_restore_file(rel)?;
712 let in_target = self.snapshot_contains_regular_file(id, rel)?;
713 match (in_target, in_work) {
714 // Neither side has it: nothing to restore and nothing to remove.
715 (false, false) => Ok(false),
716 // The snapshot has it and the working tree lost it.
717 (true, false) => Ok(true),
718 // The path was created after the snapshot.
719 (false, true) => Ok(true),
720 (true, true) => {
721 let rel = rel.to_string_lossy().into_owned();
722 let diff = run_git(
723 &self.git_dir,
724 &self.work_tree,
725 &[
726 "--literal-pathspecs",
727 "diff",
728 "--quiet",
729 id.as_str(),
730 "--",
731 rel.as_str(),
732 ],
733 )?;
734 git_diff_matches(diff).map(|matches| !matches)
735 }
736 }
737 }
738
739 /// Restore only `rel_paths` from snapshot `id`.
740 ///
741 /// This is the file-scoped counterpart of [`Self::restore`]. The
742 /// difference that matters: the whole-tree `git checkout <sha> -- :/` is
743 /// replaced by a pathspec-limited checkout, so a working-tree path outside
744 /// `rel_paths` is never written or deleted. The safety backup reads the workspace.
745 ///
746 /// A path the snapshot does not track is removed from the working tree
747 /// (that is how a file created after the snapshot is reverted), and a path
748 /// the snapshot tracks but the working tree lost is recreated. A path that
749 /// exists in neither side produces no outcome at all, rather than a
750 /// report claiming a change that did not happen.
751 #[cfg(test)]
752 pub fn restore_paths(
753 &self,
754 id: &SnapshotId,
755 rel_paths: &[PathBuf],
756 ) -> io::Result<Vec<PathRestoreOutcome>> {
757 self.restore_paths_checked(id, rel_paths, || Ok(()))
758 }
759
760 pub fn restore_file_if_unchanged(
761 &self,
762 id: &SnapshotId,
763 rel: &Path,
764 expected_hash: &str,
765 ) -> io::Result<Vec<PathRestoreOutcome>> {
766 let verify = || {
767 let actual = if self.validate_restore_file(rel)? {
768 let bytes = std::fs::read(self.work_tree.join(rel))?;
769 format!("sha256:{}", crate::hashing::sha256_hex(bytes))
770 } else {
771 "absent".to_string()
772 };
773 if actual != expected_hash {
774 return Err(io::Error::new(
775 io::ErrorKind::WouldBlock,
776 "The file changed after the selected change record. Refresh and review it before restoring; nothing was changed.",
777 ));
778 }
779 Ok(())
780 };
781 verify()?;
782 self.restore_paths_checked(id, &[rel.to_path_buf()], verify)
783 }
784
785 fn restore_paths_checked(
786 &self,
787 id: &SnapshotId,
788 rel_paths: &[PathBuf],
789 preflight: impl FnOnce() -> io::Result<()>,
790 ) -> io::Result<Vec<PathRestoreOutcome>> {
791 if rel_paths.is_empty() {
792 return Ok(Vec::new());
793 }
794 // Validate the entire request before any mutation or backup. A snapshot
795 // directory entry must not turn a file action into recursive checkout.
796 let mut pre_state = Vec::with_capacity(rel_paths.len());
797 for rel in rel_paths {
798 let in_work = self.validate_restore_file(rel)?;
799 let in_target = self.snapshot_contains_regular_file(id, rel)?;
800 pre_state.push((rel.clone(), in_target, in_work));
801 }
802
803 // A durable backup is required for this new destructive API. Ignored
804 // files cannot be removed/overwritten if the snapshot cannot retain them.
805 let target_short = &id.as_str()[..id.as_str().len().min(12)];
806 let backup = self.snapshot_with_session(&format!("pre-restore:{target_short}"), None)?;
807 for (rel, _, in_work) in &pre_state {
808 if *in_work && !self.snapshot_contains_regular_file(&backup, rel)? {
809 return Err(io_other(
810 "File was excluded from the safety snapshot; nothing was restored",
811 ));
812 }
813 self.validate_restore_file(rel)?;
814 }
815
816 // Recheck after the potentially slow safety snapshot, immediately
817 // before checkout/removal. New editor work is retained in the backup.
818 preflight()?;
819
820 let tracked: Vec<String> = pre_state
821 .iter()
822 .filter(|(_, in_target, _)| *in_target)
823 .map(|(rel, _, _)| rel.to_string_lossy().into_owned())
824 .collect();
825 if !tracked.is_empty() {
826 let mut args: Vec<String> = vec![
827 "--literal-pathspecs".to_string(),
828 "checkout".to_string(),
829 id.as_str().to_string(),
830 "--".to_string(),
831 ];
832 args.extend(tracked);
833 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
834 let checkout = run_git(&self.git_dir, &self.work_tree, &arg_refs)?;
835 if !checkout.status.success() {
836 return Err(io_other(format!(
837 "git checkout failed: {} (safety snapshot {} holds the previous files)",
838 String::from_utf8_lossy(&checkout.stderr).trim(),
839 backup.as_str()
840 )));
841 }
842 }
843
844 let mut outcomes = Vec::new();
845 for (rel, in_target, was_in_work) in pre_state {
846 match (in_target, was_in_work) {
847 (true, true) => outcomes.push(PathRestoreOutcome {
848 path: rel,
849 action: PathRestoreAction::Modified,
850 }),
851 (true, false) => outcomes.push(PathRestoreOutcome {
852 path: rel,
853 action: PathRestoreAction::Recreated,
854 }),
855 (false, true) => {
856 let path = self.work_tree.join(&rel);
857 self.validate_restore_file(&rel)?;
858 // Only the requested file goes; its parent directories
859 // stay even when emptied, because the request named a
860 // file, not a tree.
861 std::fs::remove_file(&path).map_err(|error| {
862 io_other(format!(
863 "removing '{}' failed: {error} (safety snapshot {} holds the previous files)",
864 rel.display(),
865 backup.as_str()
866 ))
867 })?;
868 outcomes.push(PathRestoreOutcome {
869 path: rel,
870 action: PathRestoreAction::Removed,
871 });
872 }
873 // Already in the snapshot's state.
874 (false, false) => {}
875 }
876 }
877 Ok(outcomes)
878 }
879
880 /// `git diff --stat` between snapshot `id` and the current working tree,
881 /// computed inside the side repo.
882 ///
883 /// This is what restoring `id` *would* change, so it must be captured
884 /// before the restore runs — afterwards the work tree matches the snapshot
885 /// and the diff is empty by construction.
886 ///
887 /// It deliberately runs against the side repo rather than the user's. The
888 /// previous summary ran `git diff --stat` in the workspace with the user's
889 /// `.git`, which reports the user's own uncommitted work: it listed files
890 /// the restore had not touched, and reported nothing when that work
891 /// happened to be committed. Returns `None` when nothing differs.
892 pub fn snapshot_diff_stat(&self, id: &SnapshotId) -> io::Result<Option<String>> {
893 let diff = run_git(
894 &self.git_dir,
895 &self.work_tree,
896 &["diff", "--stat", id.as_str(), "--", ":/"],
897 )?;
898 if !diff.status.success() {
899 return Err(io_other(format!(
900 "git diff --stat failed: {}",
901 String::from_utf8_lossy(&diff.stderr).trim()
902 )));
903 }
904 let stat = String::from_utf8_lossy(&diff.stdout).trim().to_string();
905 Ok((!stat.is_empty()).then_some(stat))
906 }
907
908 /// Return whether the current workspace matches the given snapshot's
909 /// tracked file content.
910 ///
911 /// This is intentionally narrower than a full "workspace identical"
912 /// claim: it compares the current working tree against the snapshot's
913 /// tracked paths via git's diff machinery. That is sufficient for
914 /// `/undo` cursoring — if the diff is empty, restoring this snapshot
915 /// again would be a no-op, so the caller should continue scanning
916 /// older snapshots.
917 pub fn work_tree_matches_snapshot(&self, id: &SnapshotId) -> io::Result<bool> {
918 let diff = run_git(
919 &self.git_dir,
920 &self.work_tree,
921 &["diff", "--quiet", id.as_str(), "--", ":/"],
922 )?;
923 git_diff_matches(diff)
924 }
925
926 fn tree_paths(&self, treeish: &str) -> io::Result<HashSet<PathBuf>> {
927 let ls = run_git(
928 &self.git_dir,
929 &self.work_tree,
930 &["ls-tree", "-r", "-z", "--name-only", treeish],
931 )?;
932 if !ls.status.success() {
933 return Err(io_other(format!(
934 "git ls-tree failed: {}",
935 String::from_utf8_lossy(&ls.stderr).trim()
936 )));
937 }
938 Ok(parse_nul_paths(&ls.stdout))
939 }
940
941 fn remove_paths_missing_from_target(
942 &self,
943 current_paths: &HashSet<PathBuf>,
944 target_paths: &HashSet<PathBuf>,
945 ) -> io::Result<()> {
946 for rel in current_paths.difference(target_paths) {
947 if !is_safe_relative_path(rel) {
948 continue;
949 }
950 let path = self.work_tree.join(rel);
951 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
952 continue;
953 };
954 if metadata.file_type().is_dir() {
955 let _ = std::fs::remove_dir(&path);
956 } else {
957 std::fs::remove_file(&path)?;
958 }
959 self.prune_empty_parent_dirs(path.parent());
960 }
961 Ok(())
962 }
963
964 fn prune_empty_parent_dirs(&self, mut dir: Option<&Path>) {
965 while let Some(path) = dir {
966 if path == self.work_tree {
967 break;
968 }
969 if std::fs::remove_dir(path).is_err() {
970 break;
971 }
972 dir = path.parent();
973 }
974 }
975
976 /// List up to `limit` most-recent snapshots, newest first.
977 pub fn list(&self, limit: usize) -> io::Result<Vec<Snapshot>> {
978 // `git log -<n>` is the short form of `--max-count=<n>`; if `limit`
979 // is `usize::MAX` (caller asked for "everything") we pass an empty
980 // count so git defaults to no upper bound.
981 let mut args: Vec<String> = vec!["log".to_string()];
982 if limit < usize::MAX {
983 args.push(format!("--max-count={limit}"));
984 }
985 args.push("--pretty=format:%H%x09%at%x09%s".to_string());
986 args.push("--no-color".to_string());
987 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
988 let log = run_git(&self.git_dir, &self.work_tree, &arg_refs)?;
989 if !log.status.success() {
990 let head = run_git(
991 &self.git_dir,
992 &self.work_tree,
993 &["symbolic-ref", "-q", "HEAD"],
994 )?;
995 if head.status.success() {
996 let reference = String::from_utf8_lossy(&head.stdout);
997 let exists = run_git(
998 &self.git_dir,
999 &self.work_tree,
1000 &["show-ref", "--verify", "--quiet", reference.trim()],
1001 )?;
1002 if exists.status.code() == Some(1) {
1003 return Ok(Vec::new());
1004 }
1005 }
1006 return Err(io_other(format!(
1007 "git log failed: {}",
1008 String::from_utf8_lossy(&log.stderr).trim()
1009 )));
1010 }
1011 let stdout = String::from_utf8_lossy(&log.stdout);
1012 let mut out = Vec::new();
1013 for line in stdout.lines() {
1014 let mut parts = line.splitn(3, '\t');
1015 let sha = parts.next().unwrap_or("").to_string();
1016 let ts = parts
1017 .next()
1018 .and_then(|s| s.parse::<i64>().ok())
1019 .unwrap_or(0);
1020 let subject = parts.next().unwrap_or("").to_string();
1021 if sha.is_empty() {
1022 continue;
1023 }
1024 let (session_id, label) = Self::decode_session_label(&subject);
1025 out.push(Snapshot {
1026 id: SnapshotId(sha),
1027 label,
1028 timestamp: ts,
1029 session_id,
1030 });
1031 }
1032 Ok(out)
1033 }
1034
1035 /// Drop snapshots older than `max_age`, returning the count removed.
1036 ///
1037 /// Strategy: identify keepable commits (younger than the cutoff),
1038 /// reset HEAD to the oldest survivor, then `git reflog expire` +
1039 /// `git gc --prune=now` to actually reclaim space. Cheap and avoids
1040 /// rewriting history when nothing has aged out.
1041 pub fn prune_older_than(&self, max_age: Duration) -> io::Result<usize> {
1042 let now = SystemTime::now()
1043 .duration_since(UNIX_EPOCH)
1044 .map_err(|e| io_other(format!("clock error: {e}")))?
1045 .as_secs() as i64;
1046 let cutoff = now - max_age.as_secs() as i64;
1047
1048 let snapshots = self.list(usize::MAX)?;
1049 if snapshots.is_empty() {
1050 return Ok(0);
1051 }
1052
1053 // Snapshots are newest-first. Find the index of the first one
1054 // at-or-older than the cutoff — every entry from that index
1055 // onward is a candidate for removal. We use `<=` so a 0-second
1056 // retention drops same-second commits (otherwise tests calling
1057 // `prune_older_than(Duration::ZERO)` immediately after creating
1058 // a snapshot would never prune anything).
1059 let cut_index = snapshots.iter().position(|s| s.timestamp <= cutoff);
1060 let Some(cut) = cut_index else {
1061 return Ok(0);
1062 };
1063 let removed = snapshots.len() - cut;
1064 if removed == 0 {
1065 return Ok(0);
1066 }
1067
1068 if cut == 0 {
1069 // Every snapshot is older than the cutoff — wipe the repo
1070 // entirely so the next snapshot starts a fresh history.
1071 // Removing `.git/refs/heads/*` is enough to orphan the old
1072 // commits, then gc reclaims them.
1073 let refs_dir = self.git_dir.join("refs").join("heads");
1074 if refs_dir.exists() {
1075 for entry in std::fs::read_dir(&refs_dir)? {
1076 let path = entry?.path();
1077 if path.is_file() {
1078 let _ = std::fs::remove_file(&path);
1079 }
1080 }
1081 }
1082 // Also drop HEAD's packed refs so `git log` returns nothing.
1083 let packed = self.git_dir.join("packed-refs");
1084 if packed.exists() {
1085 let _ = std::fs::remove_file(&packed);
1086 }
1087 } else {
1088 // Keep the newest `cut` snapshots (indices [0..cut], newest-first)
1089 // and drop the older tail. This MUST rebuild the survivors as a
1090 // fresh orphan chain, not `update-ref HEAD <oldest survivor>`:
1091 // the snapshots are a parent-linked commit chain with the newest
1092 // at HEAD, so pointing HEAD at the oldest survivor orphaned every
1093 // NEWER snapshot (gc then destroyed them) while keeping the very
1094 // snapshots we meant to remove as its ancestors — the exact
1095 // inverse of the intent (2026-08-04 review, reproduced).
1096 self.rebuild_survivor_chain(&snapshots[..cut])?;
1097 }
1098
1099 // Reclaim space.
1100 let _ = run_git(
1101 &self.git_dir,
1102 &self.work_tree,
1103 &["reflog", "expire", "--expire=now", "--all"],
1104 );
1105 let _ = run_git(
1106 &self.git_dir,
1107 &self.work_tree,
1108 &["gc", "--prune=now", "--quiet"],
1109 );
1110
1111 Ok(removed)
1112 }
1113
1114 /// Rebuild `survivors` (newest-first) as a fresh orphan commit chain and
1115 /// point HEAD at its tip, so every snapshot NOT in `survivors` becomes
1116 /// unreachable for gc to reclaim. Each survivor's tree, label, session
1117 /// id, and author/committer timestamp are preserved, so ages do not lie
1118 /// after a prune (finding: `prune_keep_last_n` previously reset them to
1119 /// "now"). Assumes `survivors` is non-empty.
1120 fn rebuild_survivor_chain(&self, survivors: &[Snapshot]) -> io::Result<()> {
1121 let mut prev_sha: Option<String> = None;
1122 for s in survivors.iter().rev() {
1123 let tree = run_git(
1124 &self.git_dir,
1125 &self.work_tree,
1126 &["rev-parse", &format!("{}^{{tree}}", s.id.as_str())],
1127 )?;
1128 if !tree.status.success() {
1129 return Err(io_other(format!(
1130 "rev-parse {}^{{tree}} failed: {}",
1131 s.id.as_str(),
1132 String::from_utf8_lossy(&tree.stderr).trim()
1133 )));
1134 }
1135 let tree_hash = String::from_utf8_lossy(&tree.stdout).trim().to_string();
1136
1137 let mut args = vec![
1138 "commit-tree".to_string(),
1139 "-m".to_string(),
1140 Self::encode_session_label(&s.label, s.session_id.as_deref()),
1141 tree_hash,
1142 ];
1143 if let Some(ref p) = prev_sha {
1144 args.push("-p".to_string());
1145 args.push(p.clone());
1146 }
1147 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1148 let new_sha = self.commit_tree_preserving_date(&arg_refs, s.timestamp)?;
1149 prev_sha = Some(new_sha);
1150 }
1151
1152 if let Some(final_sha) = prev_sha {
1153 let up = run_git(
1154 &self.git_dir,
1155 &self.work_tree,
1156 &["update-ref", "HEAD", &final_sha],
1157 )?;
1158 if !up.status.success() {
1159 return Err(io_other(format!(
1160 "update-ref HEAD failed: {}",
1161 String::from_utf8_lossy(&up.stderr).trim()
1162 )));
1163 }
1164 }
1165 Ok(())
1166 }
1167
1168 /// Run a `commit-tree` invocation with the author/committer dates pinned
1169 /// to `timestamp` (Unix seconds), so a rebuilt survivor keeps its real
1170 /// age instead of stamping "now".
1171 fn commit_tree_preserving_date(&self, args: &[&str], timestamp: i64) -> io::Result<String> {
1172 let date = format!("{timestamp} +0000");
1173 let out = crate::dependencies::Git::command()
1174 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "git not found on PATH"))?
1175 .arg("--git-dir")
1176 .arg(&self.git_dir)
1177 .arg("--work-tree")
1178 .arg(&self.work_tree)
1179 .env("GIT_AUTHOR_DATE", &date)
1180 .env("GIT_COMMITTER_DATE", &date)
1181 .args(args)
1182 .output()?;
1183 if !out.status.success() {
1184 return Err(io_other(format!(
1185 "commit-tree failed: {}",
1186 String::from_utf8_lossy(&out.stderr).trim()
1187 )));
1188 }
1189 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1190 }
1191
1192 /// Keep only the latest `max_count` snapshots, dropping older ones.
1193 ///
1194 /// Uses `commit-tree` with no `-p` to create a true orphan commit at
1195 /// the eldest survivor's tree, preserving its label. The old chain
1196 /// has zero refs after gc and is physically reclaimed.
1197 /// Keep only the latest `max_count` snapshots by rebuilding the
1198 /// survivor chain as orphan commits. Each survivor's tree and label
1199 /// are preserved — only the parent chain to older snapshots is cut.
1200 /// Old objects become unreachable and gc reclaims them.
1201 pub fn prune_keep_last_n(&self, max_count: usize) -> io::Result<usize> {
1202 let snapshots = self.list(usize::MAX)?;
1203 if snapshots.len() <= max_count {
1204 return Ok(0);
1205 }
1206 let keep = max_count;
1207 let removed = snapshots.len() - keep;
1208 // snapshots are newest-first: [0..keep] are the survivors. Rebuild
1209 // them as an orphan chain so the older tail is reclaimed.
1210 self.rebuild_survivor_chain(&snapshots[..keep])?;
1211 let _ = run_git(
1212 &self.git_dir,
1213 &self.work_tree,
1214 &["reflog", "expire", "--expire=now", "--all"],
1215 );
1216 let _ = run_git(
1217 &self.git_dir,
1218 &self.work_tree,
1219 &["gc", "--prune=now", "--quiet"],
1220 );
1221 Ok(removed)
1222 }
1223
1224 /// Drop unreachable loose objects left behind by interrupted or
1225 /// orphaned side-repo operations.
1226 pub fn prune_unreachable_objects(&self) -> io::Result<()> {
1227 let prune = run_git(&self.git_dir, &self.work_tree, &["prune", "--expire=now"])?;
1228 if !prune.status.success() {
1229 return Err(io_other(format!(
1230 "git prune failed: {}",
1231 String::from_utf8_lossy(&prune.stderr).trim()
1232 )));
1233 }
1234 Ok(())
1235 }
1236
1237 /// Return the side-repo's `.git` directory (for diagnostics).
1238 #[cfg_attr(not(test), expect(dead_code))]
1239 pub fn git_dir(&self) -> &Path {
1240 &self.git_dir
1241 }
1242
1243 /// Return the work tree path (for diagnostics).
1244 #[cfg_attr(not(test), expect(dead_code))]
1245 pub fn work_tree(&self) -> &Path {
1246 &self.work_tree
1247 }
1248 }
1249
1250 fn write_builtin_excludes(git_dir: &Path) -> io::Result<()> {
1251 let info_dir = git_dir.join("info");
1252 std::fs::create_dir_all(&info_dir)?;
1253 std::fs::write(info_dir.join("exclude"), BUILTIN_EXCLUDES)
1254 }
1255
1256 /// Recursively compute the total size of a directory in bytes.
1257 fn dir_size_bytes(root: &Path) -> io::Result<u64> {
1258 fn walk(dir: &Path, total: &mut u64) -> io::Result<()> {
1259 if !dir.is_dir() {
1260 return Ok(());
1261 }
1262 for entry in std::fs::read_dir(dir)? {
1263 let entry = entry?;
1264 let path = entry.path();
1265 let ft = entry.file_type()?;
1266 if ft.is_symlink() {
1267 continue;
1268 }
1269 if ft.is_dir() {
1270 walk(&path, total)?;
1271 } else if ft.is_file() {
1272 *total = total.saturating_add(entry.metadata().map(|m| m.len()).unwrap_or(0));
1273 }
1274 }
1275 Ok(())
1276 }
1277 let mut total: u64 = 0;
1278 walk(root, &mut total)?;
1279 Ok(total)
1280 }
1281
1282 /// One prominent notice per workspace per process when the size-pressure
1283 /// prune destroys restore points — silent loss of undo history is the S5
1284 /// failure mode (2026-08-04 snapshot hunt). The stderr print is deliberate:
1285 /// headless/CLI stderr is the user surface for once-per-workspace snapshot
1286 /// warnings, matching `maybe_notify_snapshots_disabled_once` in
1287 /// `core/turn.rs`.
1288 #[allow(clippy::print_stderr)]
1289 fn notify_snapshot_history_pruned_once(workspace: &Path, removed: usize) {
1290 use std::collections::HashSet;
1291 use std::sync::{Mutex, OnceLock};
1292 static NOTIFIED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1293 let key = workspace.to_string_lossy().into_owned();
1294 let set = NOTIFIED.get_or_init(|| Mutex::new(HashSet::new()));
1295 let Ok(mut guard) = set.lock() else {
1296 return;
1297 };
1298 if !guard.insert(key) {
1299 return;
1300 }
1301 drop(guard);
1302 eprint!("{}", snapshot_history_pruned_message(workspace, removed));
1303 }
1304
1305 /// Build the user-visible notice for a size-pressure prune. Kept pure and
1306 /// separate from the emit/dedup shell so the content is unit-testable.
1307 fn snapshot_history_pruned_message(workspace: &Path, removed: usize) -> String {
1308 format!(
1309 "warning: snapshot/undo history for {} was pruned to stay under the {} MB snapshot storage cap.
1310 {} snapshot(s) were removed and can no longer be restored.
1311 The cap bounds the undo side-repo's disk use; high-churn or large workspaces hit it sooner.
1312 ",
1313 workspace.display(),
1314 MAX_SNAPSHOT_SIZE_MB,
1315 removed
1316 )
1317 }
1318
1319 fn cleanup_stale_pack_temps(git_dir: &Path, stale_age: Duration) -> io::Result<usize> {
1320 let pack_dir = git_dir.join("objects").join("pack");
1321 if !pack_dir.exists() {
1322 return Ok(0);
1323 }
1324 cleanup_stale_pack_temps_in(&pack_dir, stale_age, SystemTime::now())
1325 }
1326
1327 fn cleanup_stale_pack_temps_in(
1328 pack_dir: &Path,
1329 stale_age: Duration,
1330 now: SystemTime,
1331 ) -> io::Result<usize> {
1332 let mut removed = 0;
1333 for entry in std::fs::read_dir(pack_dir)? {
1334 let entry = entry?;
1335 let name = entry.file_name();
1336 let Some(name) = name.to_str() else {
1337 continue;
1338 };
1339 if !name.starts_with("tmp_pack_") {
1340 continue;
1341 }
1342 if !entry.file_type()?.is_file() {
1343 continue;
1344 }
1345
1346 let metadata = entry.metadata()?;
1347 let Ok(modified) = metadata.modified() else {
1348 continue;
1349 };
1350 let Ok(age) = now.duration_since(modified) else {
1351 continue;
1352 };
1353 if age < stale_age {
1354 continue;
1355 }
1356
1357 match std::fs::remove_file(entry.path()) {
1358 Ok(()) => removed += 1,
1359 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
1360 Err(err) => return Err(err),
1361 }
1362 }
1363 Ok(removed)
1364 }
1365
1366 fn run_git(git_dir: &Path, work_tree: &Path, args: &[&str]) -> io::Result<Output> {
1367 crate::dependencies::Git::command()
1368 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "git not found on PATH"))?
1369 .arg("--git-dir")
1370 .arg(git_dir)
1371 .arg("--work-tree")
1372 .arg(work_tree)
1373 .args(args)
1374 .output()
1375 }
1376
1377 fn git_diff_matches(output: Output) -> io::Result<bool> {
1378 match output.status.code() {
1379 Some(0) => Ok(true),
1380 Some(1) => Ok(false),
1381 _ => Err(io_other(format!(
1382 "git diff failed: {}",
1383 String::from_utf8_lossy(&output.stderr).trim()
1384 ))),
1385 }
1386 }
1387
1388 fn io_other(msg: impl Into<String>) -> io::Error {
1389 io::Error::other(msg.into())
1390 }
1391
1392 /// Walk `workspace` and accumulate file sizes, returning `Ok(total)`
1393 /// when the workspace fits under `cap_bytes` and `Err(gate)` naming the
1394 /// bound that tripped. Honors `.gitignore` — whether or not the
1395 /// workspace is itself a git repo, matching the `git add -A` that the
1396 /// snapshot commit actually runs against this work tree — and the
1397 /// snapshot-specific skip list above, so the measured size reflects
1398 /// what would land in a snapshot commit rather than the raw `du -sh`
1399 /// total.
1400 ///
1401 /// The walk is bounded by both `cap_bytes` and `max_entries`, and the
1402 /// two bounds are reported separately because they have different
1403 /// recoveries. A `cap_bytes` of `0` disables the byte cap entirely (so
1404 /// config can opt out) but not the entry bound.
1405 ///
1406 /// Production passes [`SIZE_WALK_MAX_ENTRIES`] for `max_entries`; it is
1407 /// a parameter only so the entry bound is reachable in a test without
1408 /// creating 200,000 inodes. It must not be threaded up through
1409 /// [`SnapshotRepo::open_or_init_with_cap`]: [`WorkspaceGate::describe`]
1410 /// interpolates the constant into the user-facing message, so a weaker
1411 /// injected bound would report a number that did not trip.
1412 pub fn estimate_workspace_size_bounded(
1413 workspace: &Path,
1414 cap_bytes: u64,
1415 max_entries: usize,
1416 ) -> Result<u64, WorkspaceGate> {
1417 use ignore::WalkBuilder;
1418 let mut total: u64 = 0;
1419 let mut entries: usize = 0;
1420 let skip: HashSet<&'static str> = SIZE_WALK_SKIP_DIRS.iter().copied().collect();
1421 let walker = WalkBuilder::new(workspace)
1422 .hidden(false)
1423 // `ignore` defaults to `require_git(true)`, which silently disables
1424 // every gitignore rule when the workspace is not inside a git repo.
1425 // The snapshot's own `git add -A` honors `.gitignore` regardless, so
1426 // without this the estimator over-counts a non-git workspace and can
1427 // refuse it while offering a `.gitignore` remedy that cannot work.
1428 .require_git(false)
1429 .follow_links(false)
1430 .filter_entry(move |entry| {
1431 // Skip the well-known build-output directories at any depth.
1432 // The `ignore` crate calls `filter_entry` once per dir/file;
1433 // returning `false` here prunes the whole subtree.
1434 entry
1435 .file_name()
1436 .to_str()
1437 .is_none_or(|name| !skip.contains(name))
1438 })
1439 .build();
1440 for entry in walker.flatten() {
1441 entries += 1;
1442 if entries > max_entries {
1443 return Err(WorkspaceGate::TooManyEntries);
1444 }
1445 if let Ok(meta) = entry.metadata()
1446 && meta.is_file()
1447 {
1448 total = total.saturating_add(meta.len());
1449 if cap_bytes > 0 && total > cap_bytes {
1450 return Err(WorkspaceGate::TooLarge);
1451 }
1452 }
1453 }
1454 Ok(total)
1455 }
1456
1457 fn unsafe_workspace_snapshot_reason(workspace: &Path, home: Option<&Path>) -> Option<&'static str> {
1458 let workspace = normalize_path_for_safety(workspace);
1459 if is_filesystem_root(&workspace) {
1460 return Some("filesystem root");
1461 }
1462
1463 if is_home_directory(&workspace, home) {
1464 return Some("home directory");
1465 }
1466
1467 let home = home.map(normalize_path_for_safety)?;
1468 if workspace.parent() == Some(home.as_path()) {
1469 let name = workspace.file_name().and_then(|name| name.to_str());
1470 if matches!(
1471 name,
1472 Some(
1473 "Desktop" | "Documents" | "Downloads" | "Library" | "Movies" | "Music" | "Pictures"
1474 )
1475 ) {
1476 return Some("home collection directory");
1477 }
1478 }
1479
1480 None
1481 }
1482
1483 fn normalize_path_for_safety(path: &Path) -> PathBuf {
1484 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
1485 }
1486
1487 fn is_filesystem_root(path: &Path) -> bool {
1488 path.parent().is_none()
1489 }
1490
1491 fn is_home_directory(work_tree: &Path, home: Option<&Path>) -> bool {
1492 let Some(home) = home else {
1493 return false;
1494 };
1495
1496 let home_canonical = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
1497 work_tree == home_canonical
1498 }
1499
1500 fn parse_nul_paths(bytes: &[u8]) -> HashSet<PathBuf> {
1501 bytes
1502 .split(|b| *b == 0)
1503 .filter(|chunk| !chunk.is_empty())
1504 .map(|chunk| PathBuf::from(String::from_utf8_lossy(chunk).into_owned()))
1505 .collect()
1506 }
1507
1508 fn is_safe_relative_path(path: &Path) -> bool {
1509 !path.as_os_str().is_empty()
1510 && path
1511 .components()
1512 .all(|component| matches!(component, Component::Normal(_)))
1513 }
1514
1515 /// Normalize a caller-supplied path into a safe workspace-relative path.
1516 ///
1517 /// Accepts either a workspace-relative path or an absolute path inside
1518 /// `workspace`. Returns `None` when the result is not a plain relative path —
1519 /// absolute, empty, containing `..`, or pointing outside the workspace. Every
1520 /// file-scoped restore path passes through here, so a caller never gets to
1521 /// name a path the snapshot repo would resolve outside the work tree.
1522 ///
1523 /// The name is literal: leading or trailing spaces, brackets and glob
1524 /// characters are filename bytes, never trimmed and never patterns. Git is
1525 /// invoked with `--literal-pathspecs` for every file-scoped operation.
1526 pub fn workspace_relative_path(workspace: &Path, raw: &str) -> Option<PathBuf> {
1527 if raw.is_empty() {
1528 return None;
1529 }
1530 let candidate = Path::new(raw);
1531 let rel = if candidate.is_absolute() {
1532 candidate.strip_prefix(workspace).ok()?.to_path_buf()
1533 } else {
1534 candidate.to_path_buf()
1535 };
1536 is_safe_relative_path(&rel).then_some(rel)
1537 }
1538
1539 #[cfg(test)]
1540 mod tests {
1541 use super::*;
1542 use crate::test_support::lock_test_env;
1543 use std::fs::{File, FileTimes};
1544 use tempfile::tempdir;
1545
1546 /// Holds the home directory pinned to a tempdir for the lifetime of a test. Also
1547 /// owns the process-wide env-var mutex so tests across modules
1548 /// don't trample each other's home env vars.
1549 pub(super) struct ScopedHome {
1550 prev_vars: Vec<(&'static str, Option<std::ffi::OsString>)>,
1551 _guard: crate::test_support::TestEnvLock,
1552 }
1553 impl Drop for ScopedHome {
1554 fn drop(&mut self) {
1555 // SAFETY: process-wide lock still held.
1556 unsafe {
1557 for (key, prev) in self.prev_vars.drain(..) {
1558 match prev {
1559 Some(value) => std::env::set_var(key, value),
1560 None => std::env::remove_var(key),
1561 }
1562 }
1563 }
1564 }
1565 }
1566 pub(super) fn scoped_home(home: &Path) -> ScopedHome {
1567 let guard = lock_test_env();
1568 let prev_vars = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"]
1569 .into_iter()
1570 .map(|key| (key, std::env::var_os(key)))
1571 .collect();
1572 // SAFETY: serialised by the global env lock.
1573 unsafe {
1574 std::env::set_var("HOME", home);
1575 std::env::set_var("USERPROFILE", home);
1576 std::env::remove_var("HOMEDRIVE");
1577 std::env::remove_var("HOMEPATH");
1578 }
1579 ScopedHome {
1580 prev_vars,
1581 _guard: guard,
1582 }
1583 }
1584
1585 /// Build a side-repo whose snapshot dir lives under the same
1586 /// tempdir we're using for `HOME` — so the inner `crate::config::effective_home_dir()`
1587 /// lookup stays inside our sandbox. Returns the guard alongside so
1588 /// the caller can keep HOME pinned for the rest of the test.
1589 fn make_repo(tmp: &Path) -> (SnapshotRepo, ScopedHome) {
1590 let workspace = tmp.join("workspace");
1591 std::fs::create_dir_all(&workspace).unwrap();
1592 let guard = scoped_home(tmp);
1593 let repo = SnapshotRepo::open_or_init(&workspace).expect("open_or_init");
1594 (repo, guard)
1595 }
1596
1597 #[test]
1598 fn snapshot_creates_commit_in_side_repo_only() {
1599 let tmp = tempdir().unwrap();
1600 let (repo, _home) = make_repo(tmp.path());
1601 std::fs::write(repo.work_tree().join("a.txt"), b"alpha").unwrap();
1602
1603 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1604 assert_eq!(id.as_str().len(), 40);
1605
1606 let list = repo.list(10).expect("list");
1607 assert_eq!(list.len(), 1);
1608 assert_eq!(list[0].label, "pre-turn:1");
1609
1610 // The user's workspace must NOT have a real `.git` because we
1611 // never created one in their workspace — only in the side dir.
1612 assert!(!repo.work_tree().join(".git").exists());
1613 }
1614
1615 #[test]
1616 fn open_existing_is_read_only_and_does_not_initialize() {
1617 let tmp = tempdir().unwrap();
1618 let workspace = tmp.path().join("workspace");
1619 std::fs::create_dir_all(&workspace).unwrap();
1620 let _home = scoped_home(tmp.path());
1621
1622 let before = SnapshotRepo::open_existing(&workspace).expect("open existing");
1623 assert!(before.is_none());
1624 assert!(
1625 !snapshot_git_dir(&workspace).exists(),
1626 "read-only open must not create the side repo"
1627 );
1628
1629 let repo = SnapshotRepo::open_or_init(&workspace).expect("open_or_init");
1630 std::fs::write(repo.work_tree().join("a.txt"), b"alpha").unwrap();
1631 repo.snapshot("pre-turn:1").expect("snapshot");
1632
1633 let after = SnapshotRepo::open_existing(&workspace).expect("open existing");
1634 assert!(after.is_some());
1635 }
1636
1637 #[test]
1638 fn restore_reverts_workspace_files() {
1639 let tmp = tempdir().unwrap();
1640 let (repo, _home) = make_repo(tmp.path());
1641 let f = repo.work_tree().join("file.txt");
1642
1643 std::fs::write(&f, b"original").unwrap();
1644 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1645
1646 std::fs::write(&f, b"clobbered").unwrap();
1647 repo.snapshot("post-turn:1").expect("snapshot 2");
1648
1649 repo.restore(&id).expect("restore");
1650 let after = std::fs::read_to_string(&f).unwrap();
1651 assert_eq!(after, "original");
1652 }
1653
1654 #[test]
1655 fn restore_removes_files_added_after_target_snapshot() {
1656 let tmp = tempdir().unwrap();
1657 let (repo, _home) = make_repo(tmp.path());
1658 let original = repo.work_tree().join("original.txt");
1659 let added = repo.work_tree().join("added.txt");
1660
1661 std::fs::write(&original, b"original").unwrap();
1662 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1663
1664 std::fs::write(&added, b"new file").unwrap();
1665 repo.snapshot("post-turn:1").expect("snapshot 2");
1666
1667 repo.restore(&id).expect("restore");
1668 assert!(original.exists());
1669 assert!(!added.exists(), "restore must remove tracked added files");
1670 }
1671
1672 #[test]
1673 fn restore_paths_leaves_unrelated_files_alone() {
1674 let tmp = tempdir().unwrap();
1675 let (repo, _home) = make_repo(tmp.path());
1676 let wanted = repo.work_tree().join("wanted.txt");
1677 let unrelated = repo.work_tree().join("unrelated.txt");
1678
1679 std::fs::write(&wanted, b"original").unwrap();
1680 std::fs::write(&unrelated, b"original").unwrap();
1681 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1682
1683 std::fs::write(&wanted, b"clobbered").unwrap();
1684 std::fs::write(&unrelated, b"also clobbered").unwrap();
1685 repo.snapshot("post-turn:1").expect("snapshot 2");
1686
1687 let outcomes = repo
1688 .restore_paths(&id, &[PathBuf::from("wanted.txt")])
1689 .expect("scoped restore");
1690
1691 assert_eq!(std::fs::read_to_string(&wanted).unwrap(), "original");
1692 assert_eq!(
1693 std::fs::read_to_string(&unrelated).unwrap(),
1694 "also clobbered",
1695 "a file-scoped restore must not touch a path it was not given"
1696 );
1697 assert_eq!(outcomes.len(), 1);
1698 assert_eq!(outcomes[0].path, PathBuf::from("wanted.txt"));
1699 assert_eq!(outcomes[0].action, PathRestoreAction::Modified);
1700 }
1701
1702 #[test]
1703 fn only_restore_paths_is_safe_for_a_single_file_action() {
1704 // Characterizes the difference the per-file Revert control depends on.
1705 // `restore()` is what the TUI's `patch_undo()` and the runtime's
1706 // `patch-undo` endpoint both call. It is scoped in *snapshot selection*
1707 // (it picks a recent `tool:` snapshot) but not in *effect*: it checks
1708 // out the whole tree, so it also rolls back a working-tree path that no
1709 // tool touched. That is the data loss #2 removed the control over, and
1710 // it is why a per-file action cannot be built on top of it.
1711 let tmp = tempdir().unwrap();
1712 let (repo, _home) = make_repo(tmp.path());
1713 let touched = repo.work_tree().join("touched.txt");
1714 let unrelated = repo.work_tree().join("unrelated.txt");
1715
1716 std::fs::write(&touched, b"v1").unwrap();
1717 std::fs::write(&unrelated, b"snapshot-time").unwrap();
1718 let id = repo.snapshot("tool:call-1").expect("snapshot");
1719
1720 // The tool edits one file; something else — the user, another editor —
1721 // changes the other one after the snapshot was taken.
1722 std::fs::write(&touched, b"v2").unwrap();
1723 std::fs::write(&unrelated, b"user-work-in-progress").unwrap();
1724
1725 repo.restore(&id).expect("whole-tree restore");
1726 assert_eq!(std::fs::read_to_string(&touched).unwrap(), "v1");
1727 assert_eq!(
1728 std::fs::read_to_string(&unrelated).unwrap(),
1729 "snapshot-time",
1730 "whole-tree restore rolls back a file no tool touched"
1731 );
1732
1733 // Same situation again, but through the file-scoped path the
1734 // `file-revert` endpoint uses.
1735 std::fs::write(&touched, b"v1").unwrap();
1736 std::fs::write(&unrelated, b"snapshot-time").unwrap();
1737 let id2 = repo.snapshot("tool:call-2").expect("snapshot 2");
1738 std::fs::write(&touched, b"v2").unwrap();
1739 std::fs::write(&unrelated, b"user-work-in-progress").unwrap();
1740
1741 repo.restore_paths(&id2, &[PathBuf::from("touched.txt")])
1742 .expect("scoped restore");
1743 assert_eq!(std::fs::read_to_string(&touched).unwrap(), "v1");
1744 assert_eq!(
1745 std::fs::read_to_string(&unrelated).unwrap(),
1746 "user-work-in-progress",
1747 "the scoped restore must leave the unrelated edit alone"
1748 );
1749 }
1750
1751 #[test]
1752 fn snapshot_diff_stat_describes_what_a_restore_would_change() {
1753 let tmp = tempdir().unwrap();
1754 let (repo, _home) = make_repo(tmp.path());
1755 let changed = repo.work_tree().join("changed.txt");
1756 let untouched = repo.work_tree().join("untouched.txt");
1757
1758 std::fs::write(&changed, b"v1").unwrap();
1759 std::fs::write(&untouched, b"stable").unwrap();
1760 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1761
1762 std::fs::write(&changed, b"v2").unwrap();
1763
1764 let stat = repo
1765 .snapshot_diff_stat(&id)
1766 .expect("diff stat")
1767 .expect("the snapshot differs, so something must be reported");
1768 assert!(stat.contains("changed.txt"), "got: {stat}");
1769 // The stat describes the restore's effect, not the workspace's whole
1770 // uncommitted state — a file the restore will not touch must not appear.
1771 assert!(!stat.contains("untouched.txt"), "got: {stat}");
1772
1773 // After restoring, the two sides agree: nothing left to report. (Which
1774 // is why the caller must capture this *before* the restore runs.)
1775 repo.restore(&id).expect("restore");
1776 assert_eq!(repo.snapshot_diff_stat(&id).expect("diff stat"), None);
1777 }
1778
1779 #[test]
1780 fn restore_paths_removes_a_file_created_after_the_snapshot() {
1781 let tmp = tempdir().unwrap();
1782 let (repo, _home) = make_repo(tmp.path());
1783 let kept = repo.work_tree().join("kept.txt");
1784 let created = repo.work_tree().join("created.txt");
1785
1786 std::fs::write(&kept, b"kept").unwrap();
1787 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1788
1789 std::fs::write(&created, b"new file").unwrap();
1790 repo.snapshot("post-turn:1").expect("snapshot 2");
1791
1792 let outcomes = repo
1793 .restore_paths(&id, &[PathBuf::from("created.txt")])
1794 .expect("scoped restore");
1795
1796 assert!(
1797 !created.exists(),
1798 "a created file must be removed by revert"
1799 );
1800 assert!(kept.exists(), "the untouched file must survive");
1801 assert_eq!(outcomes[0].action, PathRestoreAction::Removed);
1802 }
1803
1804 #[test]
1805 fn restore_paths_recreates_a_file_deleted_after_the_snapshot() {
1806 let tmp = tempdir().unwrap();
1807 let (repo, _home) = make_repo(tmp.path());
1808 let deleted = repo.work_tree().join("deleted.txt");
1809
1810 std::fs::write(&deleted, b"content").unwrap();
1811 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1812
1813 std::fs::remove_file(&deleted).unwrap();
1814 repo.snapshot("post-turn:1").expect("snapshot 2");
1815
1816 let outcomes = repo
1817 .restore_paths(&id, &[PathBuf::from("deleted.txt")])
1818 .expect("scoped restore");
1819
1820 assert_eq!(std::fs::read_to_string(&deleted).unwrap(), "content");
1821 assert_eq!(outcomes[0].action, PathRestoreAction::Recreated);
1822 }
1823
1824 #[test]
1825 fn restore_paths_rejects_parent_traversal() {
1826 let tmp = tempdir().unwrap();
1827 let (repo, _home) = make_repo(tmp.path());
1828 std::fs::write(repo.work_tree().join("a.txt"), b"a").unwrap();
1829 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1830
1831 let err = repo
1832 .restore_paths(&id, &[PathBuf::from("../escape.txt")])
1833 .expect_err("traversal must be refused");
1834 assert!(err.to_string().contains("unsafe path"), "got: {err}");
1835 }
1836
1837 #[test]
1838 fn path_differs_from_snapshot_is_scoped_to_the_named_path() {
1839 let tmp = tempdir().unwrap();
1840 let (repo, _home) = make_repo(tmp.path());
1841 let touched = repo.work_tree().join("touched.txt");
1842 let untouched = repo.work_tree().join("untouched.txt");
1843
1844 std::fs::write(&touched, b"v1").unwrap();
1845 std::fs::write(&untouched, b"v1").unwrap();
1846 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1847
1848 std::fs::write(&touched, b"v2").unwrap();
1849
1850 assert!(
1851 repo.path_differs_from_snapshot(&id, Path::new("touched.txt"))
1852 .expect("differs")
1853 );
1854 assert!(
1855 !repo
1856 .path_differs_from_snapshot(&id, Path::new("untouched.txt"))
1857 .expect("differs")
1858 );
1859 }
1860
1861 #[test]
1862 fn workspace_relative_path_accepts_inside_paths_and_refuses_outside_ones() {
1863 // A real absolute temp path so the fixture is absolute on Windows too
1864 // (`/tmp/ws` is a relative path with a root-dir component there).
1865 let temp = std::env::temp_dir();
1866 let workspace = temp.join("ws");
1867 let other = temp.join("other");
1868
1869 assert_eq!(
1870 workspace_relative_path(&workspace, "src/lib.rs"),
1871 Some(PathBuf::from("src/lib.rs"))
1872 );
1873 let inside = workspace.join("src").join("lib.rs");
1874 assert_eq!(
1875 workspace_relative_path(&workspace, &inside.to_string_lossy()),
1876 Some(PathBuf::from("src").join("lib.rs"))
1877 );
1878 let outside = other.join("lib.rs");
1879 assert_eq!(
1880 workspace_relative_path(&workspace, &outside.to_string_lossy()),
1881 None
1882 );
1883 assert_eq!(workspace_relative_path(&workspace, "../escape"), None);
1884 assert_eq!(
1885 workspace_relative_path(&workspace, "src/../../escape"),
1886 None
1887 );
1888 assert_eq!(workspace_relative_path(&workspace, ""), None);
1889 // Whitespace and glob characters are literal filename bytes.
1890 assert_eq!(
1891 workspace_relative_path(&workspace, " padded.txt "),
1892 Some(PathBuf::from(" padded.txt "))
1893 );
1894 assert_eq!(
1895 workspace_relative_path(&workspace, "file[12].txt"),
1896 Some(PathBuf::from("file[12].txt"))
1897 );
1898 }
1899
1900 fn sha256_hash(path: &Path) -> String {
1901 format!(
1902 "sha256:{}",
1903 crate::hashing::sha256_hex(std::fs::read(path).unwrap())
1904 )
1905 }
1906
1907 /// The Git primitive treats `[12]` as a pattern even after `--`; the
1908 /// file-scoped restore must not. `file[12].txt` and `file1.txt` both exist
1909 /// in the snapshot, so only literal pathspecs keep the second one intact.
1910 #[test]
1911 fn restore_file_if_unchanged_treats_glob_characters_literally() {
1912 let tmp = tempdir().unwrap();
1913 let (repo, _home) = make_repo(tmp.path());
1914 let literal = repo.work_tree().join("file[12].txt");
1915 let sibling = repo.work_tree().join("file1.txt");
1916 std::fs::write(&literal, b"literal-before").unwrap();
1917 std::fs::write(&sibling, b"sibling-before").unwrap();
1918 let id = repo.snapshot("tool:call-1").expect("snapshot");
1919 std::fs::write(&literal, b"literal-after").unwrap();
1920 std::fs::write(&sibling, b"sibling-after").unwrap();
1921
1922 assert!(
1923 repo.path_differs_from_snapshot(&id, Path::new("file[12].txt"))
1924 .unwrap()
1925 );
1926 let outcomes = repo
1927 .restore_file_if_unchanged(&id, Path::new("file[12].txt"), &sha256_hash(&literal))
1928 .expect("literal restore");
1929 assert_eq!(outcomes.len(), 1);
1930 assert_eq!(outcomes[0].action, PathRestoreAction::Modified);
1931 assert_eq!(std::fs::read_to_string(&literal).unwrap(), "literal-before");
1932 assert_eq!(
1933 std::fs::read_to_string(&sibling).unwrap(),
1934 "sibling-after",
1935 "a bracketed filename must never restore its glob siblings"
1936 );
1937 }
1938
1939 #[test]
1940 fn restore_file_if_unchanged_refuses_when_the_reviewed_bytes_changed() {
1941 let tmp = tempdir().unwrap();
1942 let (repo, _home) = make_repo(tmp.path());
1943 let file = repo.work_tree().join("a.txt");
1944 std::fs::write(&file, b"v1").unwrap();
1945 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1946 std::fs::write(&file, b"v2").unwrap();
1947 let reviewed = sha256_hash(&file);
1948 // The user edits again after the client captured its change record.
1949 std::fs::write(&file, b"v3-user-edit").unwrap();
1950
1951 let err = repo
1952 .restore_file_if_unchanged(&id, Path::new("a.txt"), &reviewed)
1953 .expect_err("stale hash must refuse");
1954 assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
1955 assert_eq!(std::fs::read_to_string(&file).unwrap(), "v3-user-edit");
1956 // `absent` is only valid for a file the client saw as deleted.
1957 let err = repo
1958 .restore_file_if_unchanged(&id, Path::new("a.txt"), "absent")
1959 .expect_err("absent must not match an existing file");
1960 assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
1961 // The exact current bytes restore.
1962 let outcomes = repo
1963 .restore_file_if_unchanged(&id, Path::new("a.txt"), &sha256_hash(&file))
1964 .expect("current hash restores");
1965 assert_eq!(outcomes[0].action, PathRestoreAction::Modified);
1966 assert_eq!(std::fs::read_to_string(&file).unwrap(), "v1");
1967 }
1968
1969 #[test]
1970 fn restore_file_if_unchanged_handles_deleted_and_created_files() {
1971 let tmp = tempdir().unwrap();
1972 let (repo, _home) = make_repo(tmp.path());
1973 let deleted = repo.work_tree().join("deleted.txt");
1974 std::fs::write(&deleted, b"content").unwrap();
1975 let id = repo.snapshot("pre-turn:1").expect("snapshot");
1976 std::fs::remove_file(&deleted).unwrap();
1977 let created = repo.work_tree().join("created.txt");
1978 std::fs::write(&created, b"new").unwrap();
1979
1980 let outcomes = repo
1981 .restore_file_if_unchanged(&id, Path::new("deleted.txt"), "absent")
1982 .expect("recreate");
1983 assert_eq!(outcomes[0].action, PathRestoreAction::Recreated);
1984 assert_eq!(std::fs::read_to_string(&deleted).unwrap(), "content");
1985
1986 let outcomes = repo
1987 .restore_file_if_unchanged(&id, Path::new("created.txt"), &sha256_hash(&created))
1988 .expect("remove");
1989 assert_eq!(outcomes[0].action, PathRestoreAction::Removed);
1990 assert!(!created.exists());
1991 // A created file inside a new directory is removed alone; the
1992 // directory the user made stays.
1993 let nested_dir = repo.work_tree().join("newdir");
1994 std::fs::create_dir_all(&nested_dir).unwrap();
1995 let nested = nested_dir.join("only.txt");
1996 std::fs::write(&nested, b"n").unwrap();
1997 let outcomes = repo
1998 .restore_file_if_unchanged(&id, Path::new("newdir/only.txt"), &sha256_hash(&nested))
1999 .expect("remove nested");
2000 assert_eq!(outcomes[0].action, PathRestoreAction::Removed);
2001 assert!(!nested.exists());
2002 assert!(nested_dir.is_dir(), "the parent directory is not pruned");
2003 // A path missing on both sides is not a change and reports nothing.
2004 assert!(
2005 !repo
2006 .path_differs_from_snapshot(&id, Path::new("never.txt"))
2007 .unwrap()
2008 );
2009 }
2010
2011 #[test]
2012 fn restore_file_if_unchanged_refuses_directories_git_metadata_and_ignored_files() {
2013 let tmp = tempdir().unwrap();
2014 let (repo, _home) = make_repo(tmp.path());
2015 let dir = repo.work_tree().join("src");
2016 std::fs::create_dir_all(&dir).unwrap();
2017 std::fs::write(dir.join("lib.rs"), b"fn a() {}").unwrap();
2018 std::fs::write(
2019 repo.work_tree().join(".gitignore"),
2020 "ignored.txt
2021 ",
2022 )
2023 .unwrap();
2024 std::fs::write(repo.work_tree().join("ignored.txt"), b"secret").unwrap();
2025 let id = repo.snapshot("pre-turn:1").expect("snapshot");
2026 std::fs::write(dir.join("lib.rs"), b"fn b() {}").unwrap();
2027
2028 for rel in ["src", ".git/config", "src/.GIT/x", ".git"] {
2029 let err = repo.validate_restore_file(Path::new(rel)).expect_err(rel);
2030 assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{rel}");
2031 }
2032 let err = repo
2033 .restore_file_if_unchanged(&id, Path::new("src"), "absent")
2034 .expect_err("directories are refused");
2035 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
2036 assert_eq!(
2037 std::fs::read_to_string(dir.join("lib.rs")).unwrap(),
2038 "fn b() {}"
2039 );
2040
2041 // A gitignored file is excluded from the safety backup, so removing
2042 // it would be unrecoverable: refuse and leave it in place.
2043 let ignored = repo.work_tree().join("ignored.txt");
2044 let err = repo
2045 .restore_file_if_unchanged(&id, Path::new("ignored.txt"), &sha256_hash(&ignored))
2046 .expect_err("ignored files are refused");
2047 assert!(err.to_string().contains("safety snapshot"), "got: {err}");
2048 assert_eq!(std::fs::read_to_string(&ignored).unwrap(), "secret");
2049 }
2050
2051 #[cfg(unix)]
2052 #[test]
2053 fn restore_file_if_unchanged_refuses_symlinks_anywhere_in_the_path() {
2054 let tmp = tempdir().unwrap();
2055 let (repo, _home) = make_repo(tmp.path());
2056 let outside = tmp.path().join("outside");
2057 std::fs::create_dir_all(&outside).unwrap();
2058 std::fs::write(outside.join("target.txt"), b"outside").unwrap();
2059 std::fs::write(repo.work_tree().join("real.txt"), b"real").unwrap();
2060 std::os::unix::fs::symlink(&outside, repo.work_tree().join("linkdir")).unwrap();
2061 std::os::unix::fs::symlink(
2062 outside.join("target.txt"),
2063 repo.work_tree().join("link.txt"),
2064 )
2065 .unwrap();
2066 let id = repo.snapshot("pre-turn:1").expect("snapshot");
2067
2068 for rel in ["link.txt", "linkdir/target.txt"] {
2069 let err = repo
2070 .restore_file_if_unchanged(&id, Path::new(rel), "absent")
2071 .expect_err(rel);
2072 assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{rel}");
2073 }
2074 assert_eq!(
2075 std::fs::read_to_string(outside.join("target.txt")).unwrap(),
2076 "outside"
2077 );
2078 // The snapshot side is checked too: a symlink entry in the tree is
2079 // not a regular file even when the work tree copy is gone.
2080 std::fs::remove_file(repo.work_tree().join("link.txt")).unwrap();
2081 let err = repo
2082 .restore_file_if_unchanged(&id, Path::new("link.txt"), "absent")
2083 .expect_err("snapshot symlink entry");
2084 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
2085 assert!(!repo.work_tree().join("link.txt").exists());
2086 }
2087
2088 #[test]
2089 fn list_distinguishes_an_unborn_head_from_broken_history() {
2090 let tmp = tempdir().unwrap();
2091 let (repo, _home) = make_repo(tmp.path());
2092 assert!(repo.list(10).expect("unborn HEAD lists nothing").is_empty());
2093
2094 std::fs::write(repo.work_tree().join("a.txt"), b"a").unwrap();
2095 repo.snapshot("pre-turn:1").expect("snapshot");
2096 assert_eq!(repo.list(10).unwrap().len(), 1);
2097
2098 // Point the branch at an object that does not exist: the history is
2099 // now broken, which must surface as an error rather than "no
2100 // snapshots" (an empty list would let patch-undo drop a turn).
2101 let head = String::from_utf8(
2102 run_git(repo.git_dir(), repo.work_tree(), &["symbolic-ref", "HEAD"])
2103 .unwrap()
2104 .stdout,
2105 )
2106 .unwrap();
2107 std::fs::write(
2108 repo.git_dir().join(head.trim()),
2109 "0123456789abcdef0123456789abcdef01234567\n",
2110 )
2111 .unwrap();
2112 let err = repo.list(10).expect_err("broken history must error");
2113 assert!(err.to_string().contains("git log failed"), "got: {err}");
2114 }
2115
2116 #[test]
2117 fn restore_takes_a_pre_restore_safety_snapshot_that_round_trips() {
2118 let tmp = tempdir().unwrap();
2119 let (repo, _home) = make_repo(tmp.path());
2120 let f = repo.work_tree().join("file.txt");
2121
2122 std::fs::write(&f, b"v1").unwrap();
2123 let id1 = repo.snapshot("pre-turn:1").expect("snapshot v1");
2124
2125 std::fs::write(&f, b"v2").unwrap();
2126 repo.snapshot("post-turn:1").expect("snapshot v2");
2127
2128 repo.restore(&id1).expect("restore to v1");
2129 assert_eq!(std::fs::read_to_string(&f).unwrap(), "v1");
2130
2131 // The restore must have captured the pre-restore state (v2) under a
2132 // `pre-restore:` label naming its target, so the destructive op is
2133 // itself reversible (2026-08-04 snapshot hunt).
2134 let snapshots = repo.list(usize::MAX).expect("list");
2135 let safety = snapshots
2136 .iter()
2137 .find(|s| s.label.starts_with("pre-restore:"))
2138 .expect("a pre-restore safety snapshot must exist");
2139 assert!(
2140 safety.label.ends_with(&id1.as_str()[..12]),
2141 "safety label should name the restore target: {}",
2142 safety.label
2143 );
2144
2145 repo.restore(&safety.id)
2146 .expect("restore the safety snapshot");
2147 assert_eq!(
2148 std::fs::read_to_string(&f).unwrap(),
2149 "v2",
2150 "the safety snapshot must bring back the pre-restore state"
2151 );
2152 }
2153
2154 #[test]
2155 fn snapshot_and_restore_do_not_move_user_git_head() {
2156 let tmp = tempdir().unwrap();
2157 let workspace = tmp.path().join("workspace");
2158 std::fs::create_dir_all(&workspace).unwrap();
2159 crate::dependencies::Git::command()
2160 .expect("git not found")
2161 .arg("-C")
2162 .arg(&workspace)
2163 .arg("init")
2164 .arg("--quiet")
2165 .status()
2166 .unwrap();
2167 std::fs::write(workspace.join("tracked.txt"), b"committed").unwrap();
2168 crate::dependencies::Git::command()
2169 .expect("git not found")
2170 .arg("-C")
2171 .arg(&workspace)
2172 .arg("add")
2173 .arg("tracked.txt")
2174 .status()
2175 .unwrap();
2176 crate::dependencies::Git::command()
2177 .expect("git not found")
2178 .arg("-C")
2179 .arg(&workspace)
2180 .arg("-c")
2181 .arg("user.name=user")
2182 .arg("-c")
2183 .arg("user.email=user@example.test")
2184 .arg("commit")
2185 .arg("--quiet")
2186 .arg("-m")
2187 .arg("init")
2188 .status()
2189 .unwrap();
2190 let user_head_before = crate::dependencies::Git::command()
2191 .expect("git not found")
2192 .arg("-C")
2193 .arg(&workspace)
2194 .args(["rev-parse", "HEAD"])
2195 .output()
2196 .unwrap()
2197 .stdout;
2198
2199 let _home = scoped_home(tmp.path());
2200 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
2201 std::fs::write(workspace.join("tracked.txt"), b"dirty-before").unwrap();
2202 let id = repo.snapshot("pre-turn:1").unwrap();
2203 std::fs::write(workspace.join("tracked.txt"), b"dirty-after").unwrap();
2204 repo.snapshot("post-turn:1").unwrap();
2205 repo.restore(&id).unwrap();
2206
2207 let user_head_after = crate::dependencies::Git::command()
2208 .expect("git not found")
2209 .arg("-C")
2210 .arg(&workspace)
2211 .args(["rev-parse", "HEAD"])
2212 .output()
2213 .unwrap()
2214 .stdout;
2215 assert_eq!(user_head_after, user_head_before);
2216 assert_eq!(
2217 std::fs::read_to_string(workspace.join("tracked.txt")).unwrap(),
2218 "dirty-before"
2219 );
2220 }
2221
2222 #[test]
2223 fn list_respects_limit() {
2224 let tmp = tempdir().unwrap();
2225 let (repo, _home) = make_repo(tmp.path());
2226 for i in 0..5 {
2227 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
2228 repo.snapshot(&format!("turn:{i}")).unwrap();
2229 }
2230 let three = repo.list(3).unwrap();
2231 assert_eq!(three.len(), 3);
2232 // Newest first.
2233 assert_eq!(three[0].label, "turn:4");
2234 }
2235
2236 #[test]
2237 fn prune_drops_snapshots_older_than_threshold() {
2238 let tmp = tempdir().unwrap();
2239 let (repo, _home) = make_repo(tmp.path());
2240 std::fs::write(repo.work_tree().join("f.txt"), "v0").unwrap();
2241 repo.snapshot("turn:0").unwrap();
2242
2243 // Wait one second so the snapshot's commit timestamp is strictly
2244 // in the past relative to the prune call's "now" — otherwise
2245 // same-second comparisons make the assertion flaky.
2246 std::thread::sleep(Duration::from_millis(1100));
2247
2248 let removed = repo.prune_older_than(Duration::from_secs(0)).unwrap();
2249 assert!(removed >= 1, "expected at least 1 pruned, got {removed}");
2250
2251 // After pruning everything, the next snapshot should start a
2252 // fresh history.
2253 std::fs::write(repo.work_tree().join("f.txt"), "v1").unwrap();
2254 repo.snapshot("turn:1").unwrap();
2255 let list = repo.list(10).unwrap();
2256 assert_eq!(list.len(), 1);
2257 assert_eq!(list[0].label, "turn:1");
2258 }
2259
2260 /// The 2026-08-04 regression: with a cut in the MIDDLE of history,
2261 /// `prune_older_than` used to `update-ref HEAD <oldest survivor>`, which
2262 /// orphaned (and gc destroyed) the NEWEST snapshots while keeping the
2263 /// old ones as ancestors — the inverse of the intent, firing on every
2264 /// boot. This pins the correct partial-cut behavior.
2265 #[test]
2266 fn prune_older_than_keeps_the_newest_and_drops_only_the_old_tail() {
2267 let tmp = tempdir().unwrap();
2268 let (repo, _home) = make_repo(tmp.path());
2269
2270 // Two "old" snapshots, then a pause, then two "new" ones.
2271 for i in 0..2 {
2272 std::fs::write(repo.work_tree().join("f.txt"), format!("old{i}")).unwrap();
2273 repo.snapshot(&format!("old:{i}")).unwrap();
2274 std::thread::sleep(Duration::from_millis(1100));
2275 }
2276 // A wide gap so git's whole-second commit timestamps land the cut
2277 // unambiguously between the old and new pairs. The margins are
2278 // deliberately generous: this test runs under full-suite parallelism
2279 // where a sleep can overrun, and the cut is wall-clock. At prune time
2280 // the newest pair is ~0-1.2s old against a 6s cutoff, and the old
2281 // pair is ~9s old — ~5s of slack in both directions.
2282 std::thread::sleep(Duration::from_secs(8));
2283 for i in 0..2 {
2284 std::fs::write(repo.work_tree().join("f.txt"), format!("new{i}")).unwrap();
2285 repo.snapshot(&format!("new:{i}")).unwrap();
2286 if i == 0 {
2287 std::thread::sleep(Duration::from_millis(1100));
2288 }
2289 }
2290 let before = repo.list(usize::MAX).unwrap();
2291 assert_eq!(before.len(), 4);
2292 // Derive the cut from the timestamps actually recorded rather than a
2293 // fixed 6s. A fixed cut assumes `repo.snapshot()` is fast: `new:0` is
2294 // only ~1.2s plus one git subprocess older than prune time, so on a
2295 // loaded Windows runner that subprocess alone pushed it past 6s and
2296 // three snapshots were pruned instead of two. (The old fixture guard
2297 // could not catch it either — it checked `before[0]` and `before[2]`,
2298 // and `before[1]` is the entry that drifts.)
2299 let now = std::time::SystemTime::now()
2300 .duration_since(std::time::UNIX_EPOCH)
2301 .unwrap()
2302 .as_secs() as i64;
2303 // Newest-first: [new:1, new:0, old:1, old:0]. The cut must land
2304 // strictly between the pairs, so aim at the midpoint of the 8s gap —
2305 // that leaves ~4s of slack against clock drift and a slow runner in
2306 // both directions.
2307 let survivor = before[1].timestamp;
2308 let victim = before[2].timestamp;
2309 assert!(
2310 survivor - victim >= 8,
2311 "fixture needs an 8s gap between the pairs (survivor {survivor}, victim {victim})"
2312 );
2313 let midpoint = victim + (survivor - victim) / 2;
2314 assert!(
2315 now > midpoint,
2316 "fixture cutoff is not before the current time"
2317 );
2318 let max_age = Duration::from_secs((now - midpoint) as u64);
2319
2320 // The two old snapshots drop, the two new ones survive.
2321 let removed = repo.prune_older_than(max_age).unwrap();
2322 assert_eq!(removed, 2, "only the old tail should be removed");
2323
2324 let remaining = repo.list(usize::MAX).unwrap();
2325 assert_eq!(remaining.len(), 2, "the two newest must survive");
2326 assert_eq!(
2327 remaining[0].label, "new:1",
2328 "newest survives (was destroyed before)"
2329 );
2330 assert_eq!(remaining[1].label, "new:0");
2331 assert!(
2332 !remaining.iter().any(|s| s.label.starts_with("old:")),
2333 "old snapshots must be gone, not kept as ancestors: {:?}",
2334 remaining.iter().map(|s| &s.label).collect::<Vec<_>>()
2335 );
2336
2337 // The survivors' contents are intact and restorable.
2338 repo.restore(&remaining[0].id).unwrap();
2339 assert_eq!(
2340 std::fs::read_to_string(repo.work_tree().join("f.txt")).unwrap(),
2341 "new1"
2342 );
2343 }
2344
2345 #[test]
2346 fn prune_keep_last_n_keeps_latest_and_gc_reclaims_rest() {
2347 let tmp = tempdir().unwrap();
2348 let (repo, _home) = make_repo(tmp.path());
2349
2350 for i in 0..3 {
2351 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
2352 repo.snapshot(&format!("turn:{i}")).unwrap();
2353 std::thread::sleep(Duration::from_millis(1100));
2354 }
2355
2356 assert_eq!(repo.list(usize::MAX).unwrap().len(), 3);
2357
2358 let removed = repo.prune_keep_last_n(1).unwrap();
2359 assert_eq!(removed, 2);
2360
2361 let remaining = repo.list(usize::MAX).unwrap();
2362 assert_eq!(remaining.len(), 1);
2363 assert_eq!(remaining[0].label, "turn:2");
2364
2365 // New snapshot starts a clean chain (not appending to old).
2366 std::fs::write(repo.work_tree().join("f.txt"), "fresh").unwrap();
2367 repo.snapshot("turn:new").unwrap();
2368 assert_eq!(repo.list(usize::MAX).unwrap().len(), 2);
2369 }
2370
2371 #[test]
2372 fn prune_keep_last_n_preserves_multiple_snapshots_in_order() {
2373 let tmp = tempdir().unwrap();
2374 let (repo, _home) = make_repo(tmp.path());
2375
2376 for i in 0..4 {
2377 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
2378 repo.snapshot(&format!("turn:{i}")).unwrap();
2379 std::thread::sleep(Duration::from_millis(1100));
2380 }
2381
2382 assert_eq!(repo.list(usize::MAX).unwrap().len(), 4);
2383
2384 let removed = repo.prune_keep_last_n(2).unwrap();
2385 assert_eq!(removed, 2);
2386
2387 let remaining = repo.list(usize::MAX).unwrap();
2388 assert_eq!(remaining.len(), 2);
2389 // Should be newest-first: turn:3 (newest), turn:2 (second newest)
2390 assert_eq!(remaining[0].label, "turn:3");
2391 assert_eq!(remaining[1].label, "turn:2");
2392
2393 // New snapshot continues the chain.
2394 std::fs::write(repo.work_tree().join("f.txt"), "fresh").unwrap();
2395 repo.snapshot("turn:new").unwrap();
2396 let after = repo.list(usize::MAX).unwrap();
2397 assert_eq!(after.len(), 3);
2398 assert_eq!(after[0].label, "turn:new");
2399 }
2400
2401 #[test]
2402 fn open_or_init_removes_stale_tmp_pack_files_only() {
2403 let tmp = tempdir().unwrap();
2404 let (repo, _home) = make_repo(tmp.path());
2405 let workspace = repo.work_tree().to_path_buf();
2406 let pack_dir = repo.git_dir().join("objects").join("pack");
2407 std::fs::create_dir_all(&pack_dir).unwrap();
2408
2409 let stale = pack_dir.join("tmp_pack_stale");
2410 let fresh = pack_dir.join("tmp_pack_fresh");
2411 let ordinary_pack = pack_dir.join("pack-kept.pack");
2412 std::fs::write(&stale, b"stale").unwrap();
2413 std::fs::write(&fresh, b"fresh").unwrap();
2414 std::fs::write(&ordinary_pack, b"pack").unwrap();
2415
2416 let old_time = SystemTime::now() - STALE_TMP_PACK_AGE - Duration::from_secs(60);
2417 {
2418 let file = File::options().write(true).open(&stale).unwrap();
2419 file.set_times(FileTimes::new().set_modified(old_time))
2420 .unwrap();
2421 }
2422
2423 SnapshotRepo::open_or_init(&workspace).unwrap();
2424
2425 assert!(!stale.exists(), "stale tmp_pack file should be removed");
2426 assert!(fresh.exists(), "fresh tmp_pack file should be kept");
2427 assert!(ordinary_pack.exists(), "non-temp pack file should be kept");
2428 }
2429
2430 #[test]
2431 fn snapshot_respects_workspace_gitignore() {
2432 let tmp = tempdir().unwrap();
2433 let (repo, _home) = make_repo(tmp.path());
2434 std::fs::write(repo.work_tree().join(".gitignore"), "ignored.txt\n").unwrap();
2435 std::fs::write(repo.work_tree().join("ignored.txt"), b"secret").unwrap();
2436 std::fs::write(repo.work_tree().join("kept.txt"), b"public").unwrap();
2437
2438 let id = repo.snapshot("pre-turn:1").expect("snapshot");
2439
2440 // `git ls-tree` against the snapshot's commit shouldn't list ignored.txt.
2441 let ls = run_git(
2442 repo.git_dir(),
2443 repo.work_tree(),
2444 &["ls-tree", "-r", "--name-only", id.as_str()],
2445 )
2446 .expect("ls-tree");
2447 let names = String::from_utf8_lossy(&ls.stdout);
2448 assert!(names.contains("kept.txt"), "kept.txt missing: {names}");
2449 assert!(
2450 !names.contains("ignored.txt"),
2451 "ignored.txt should not be in snapshot: {names}",
2452 );
2453 }
2454
2455 #[test]
2456 fn unsafe_workspace_rejects_home_directory_workspace() {
2457 let tmp = tempdir().unwrap();
2458 let home = tmp.path();
2459
2460 assert_eq!(
2461 unsafe_workspace_snapshot_reason(home, Some(home)),
2462 Some("home directory")
2463 );
2464 }
2465
2466 #[test]
2467 fn unsafe_workspace_rejects_home_collection_directories() {
2468 let tmp = tempdir().unwrap();
2469 let home = tmp.path();
2470 let desktop = tmp.path().join("Desktop");
2471 std::fs::create_dir_all(&desktop).unwrap();
2472
2473 assert_eq!(
2474 unsafe_workspace_snapshot_reason(&desktop, Some(home)),
2475 Some("home collection directory")
2476 );
2477 }
2478
2479 #[test]
2480 fn unsafe_workspace_allows_project_directories_under_home() {
2481 let tmp = tempdir().unwrap();
2482 let home = tmp.path();
2483 let workspace = tmp.path().join("code").join("project");
2484 std::fs::create_dir_all(&workspace).unwrap();
2485
2486 assert_eq!(
2487 unsafe_workspace_snapshot_reason(&workspace, Some(home)),
2488 None
2489 );
2490 }
2491
2492 #[test]
2493 fn snapshot_respects_builtin_excludes() {
2494 let tmp = tempdir().unwrap();
2495 let (repo, _home) = make_repo(tmp.path());
2496 std::fs::create_dir_all(repo.work_tree().join("node_modules/pkg")).unwrap();
2497 std::fs::create_dir_all(repo.work_tree().join(".next/cache")).unwrap();
2498 std::fs::create_dir_all(repo.work_tree().join("src")).unwrap();
2499 std::fs::write(
2500 repo.work_tree().join("node_modules/pkg/index.js"),
2501 b"generated",
2502 )
2503 .unwrap();
2504 std::fs::write(repo.work_tree().join(".next/cache/chunk.bin"), b"generated").unwrap();
2505 std::fs::write(repo.work_tree().join("debug.wasm"), b"binary").unwrap();
2506 std::fs::write(repo.work_tree().join("src/main.rs"), b"fn main() {}").unwrap();
2507
2508 let excludes = std::fs::read_to_string(repo.git_dir().join("info/exclude")).unwrap();
2509 assert!(excludes.contains("node_modules/"));
2510 assert!(excludes.contains(".next/"));
2511 assert!(excludes.contains("*.wasm"));
2512
2513 let id = repo.snapshot("pre-turn:1").expect("snapshot");
2514 let ls = run_git(
2515 repo.git_dir(),
2516 repo.work_tree(),
2517 &["ls-tree", "-r", "--name-only", id.as_str()],
2518 )
2519 .expect("ls-tree");
2520 let names = String::from_utf8_lossy(&ls.stdout);
2521 assert!(
2522 names.contains("src/main.rs"),
2523 "src/main.rs missing: {names}"
2524 );
2525 assert!(
2526 !names.contains("node_modules"),
2527 "node_modules should not be in snapshot: {names}",
2528 );
2529 assert!(
2530 !names.contains(".next"),
2531 ".next should not be in snapshot: {names}",
2532 );
2533 assert!(
2534 !names.contains("debug.wasm"),
2535 "binary artifacts should not be in snapshot: {names}",
2536 );
2537 }
2538
2539 #[test]
2540 fn open_or_init_is_idempotent() {
2541 let tmp = tempdir().unwrap();
2542 let (_r, _h) = make_repo(tmp.path());
2543 // Second open should not panic and should reuse the existing
2544 // `.git`. We re-open via the public API rather than make_repo to
2545 // avoid double-acquiring HOME (the guard would deadlock).
2546 drop((_r, _h));
2547 let (_r2, _h2) = make_repo(tmp.path());
2548 }
2549
2550 #[test]
2551 fn home_directory_guard_matches_canonical_paths() {
2552 let tmp = tempdir().unwrap();
2553 let home = tmp.path();
2554 let home_canonical = home.canonicalize().unwrap();
2555 let workspace = home.join("workspace");
2556 std::fs::create_dir_all(&workspace).unwrap();
2557 let workspace_canonical = workspace.canonicalize().unwrap();
2558
2559 assert!(is_home_directory(&home_canonical, Some(home)));
2560 assert!(!is_home_directory(&workspace_canonical, Some(home)));
2561 assert!(!is_home_directory(&home_canonical, None));
2562 }
2563
2564 #[test]
2565 fn dir_size_bytes_measures_directory_bytes() {
2566 let tmp = tempdir().unwrap();
2567 let dir = tmp.path().join("sizedir");
2568 std::fs::create_dir_all(dir.join("sub")).unwrap();
2569 // 3 bytes per file.
2570 std::fs::write(dir.join("a.txt"), b"abc").unwrap();
2571 std::fs::write(dir.join("sub/b.txt"), b"xyz").unwrap();
2572
2573 let size = dir_size_bytes(&dir).expect("dir_size_bytes");
2574 assert_eq!(size, 6, "two 3-byte files should measure 6 bytes");
2575
2576 // Write 2 MB of data.
2577 let big = dir.join("big.bin");
2578 std::fs::write(&big, vec![0u8; 2 * 1024 * 1024]).unwrap();
2579 let size = dir_size_bytes(&dir).expect("dir_size_bytes after big write");
2580 assert_eq!(
2581 size,
2582 2 * 1024 * 1024 + 6,
2583 "expected 2 MB + 6 bytes after writing a 2 MB file"
2584 );
2585 }
2586
2587 /// Regression: snapshot size cap (#1112). When the snapshot dir grows,
2588 /// `snapshot()` must prune old snapshots to stay under the limit.
2589 /// This test uses the real size constants, which are 500/400 MB —
2590 /// we can't easily blow up a temp dir to 500 MB in a unit test.
2591 /// Instead we verify the guard logic doesn't panic or error on a
2592 /// small repo (well under the cap), and that `snapshot()` still works.
2593 #[test]
2594 fn snapshot_succeeds_when_under_size_cap() {
2595 let tmp = tempdir().unwrap();
2596 let (repo, _home) = make_repo(tmp.path());
2597 // The side repo is tiny — well under 500 MB. Snapshot should work.
2598 std::fs::write(repo.work_tree().join("f.txt"), b"hello").unwrap();
2599 let id = repo.snapshot("pre-turn:1").expect("snapshot under cap");
2600 assert_eq!(id.as_str().len(), 40);
2601 }
2602
2603 #[test]
2604 fn prune_size_pressure_counts_and_removes_history_when_over_limit() {
2605 let tmp = tempdir().unwrap();
2606 let (repo, _home) = make_repo(tmp.path());
2607 for i in 0..3 {
2608 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
2609 repo.snapshot(&format!("pre-turn:{i}")).expect("snapshot");
2610 }
2611 assert_eq!(repo.list(usize::MAX).unwrap().len(), 3);
2612 // A zero byte limit makes any non-empty side repo "over limit", so the
2613 // prune must run and report exactly what it destroyed. This is the S5
2614 // wipe path; the count is what the user-visible notice is built from.
2615 let removed = repo.prune_size_pressure(0, 0).expect("prune_size_pressure");
2616 assert_eq!(removed, 3, "every snapshot must be reported as removed");
2617 assert!(
2618 repo.list(usize::MAX).unwrap().is_empty(),
2619 "history should be empty after the forced wipe"
2620 );
2621 }
2622
2623 #[test]
2624 fn prune_size_pressure_is_a_noop_under_the_limit() {
2625 let tmp = tempdir().unwrap();
2626 let (repo, _home) = make_repo(tmp.path());
2627 std::fs::write(repo.work_tree().join("f.txt"), b"v0").unwrap();
2628 repo.snapshot("pre-turn:0").expect("snapshot");
2629 let removed = repo
2630 .prune_size_pressure(u64::MAX, u64::MAX)
2631 .expect("prune_size_pressure");
2632 assert_eq!(removed, 0, "under the limit nothing may be removed");
2633 assert_eq!(repo.list(usize::MAX).unwrap().len(), 1);
2634 }
2635
2636 #[test]
2637 fn snapshot_history_pruned_message_names_workspace_count_and_cap() {
2638 let msg = snapshot_history_pruned_message(Path::new("/tmp/ws"), 7);
2639 assert!(msg.contains("/tmp/ws"), "message must name the workspace");
2640 assert!(msg.contains("7"), "message must state the removed count");
2641 assert!(
2642 msg.contains(&MAX_SNAPSHOT_SIZE_MB.to_string()),
2643 "message must state the storage cap"
2644 );
2645 }
2646
2647 #[test]
2648 fn estimate_workspace_size_bounded_returns_total_when_under_cap() {
2649 let tmp = tempdir().unwrap();
2650 let workspace = tmp.path().join("workspace");
2651 std::fs::create_dir_all(&workspace).unwrap();
2652 std::fs::write(workspace.join("a.txt"), vec![b'a'; 100]).unwrap();
2653 std::fs::write(workspace.join("b.txt"), vec![b'b'; 50]).unwrap();
2654 let total = estimate_workspace_size_bounded(&workspace, 10_000, SIZE_WALK_MAX_ENTRIES)
2655 .expect("under-cap walk must return a total");
2656 assert!(
2657 total >= 150,
2658 "total ({total}) must include both files (≥150 bytes)"
2659 );
2660 }
2661
2662 #[test]
2663 fn estimate_workspace_size_bounded_reports_the_size_gate_when_over_cap() {
2664 let tmp = tempdir().unwrap();
2665 let workspace = tmp.path().join("workspace");
2666 std::fs::create_dir_all(&workspace).unwrap();
2667 // Two 1 KB files, cap at 1 KB — second file should trip the cap.
2668 std::fs::write(workspace.join("a.bin"), vec![b'a'; 1024]).unwrap();
2669 std::fs::write(workspace.join("b.bin"), vec![b'b'; 1024]).unwrap();
2670 assert_eq!(
2671 estimate_workspace_size_bounded(&workspace, 1024, SIZE_WALK_MAX_ENTRIES),
2672 Err(WorkspaceGate::TooLarge),
2673 "over-cap walk must name the size gate for early bailout"
2674 );
2675 }
2676
2677 #[test]
2678 fn oversize_gate_message_states_the_byte_cap_without_a_remedy() {
2679 // The remedy is localized by the notice surfaces; repeating it here is
2680 // what produced the doubled warning.
2681 let message =
2682 WorkspaceGate::TooLarge.describe(2 * 1024 * 1024 * 1024, Path::new("/tmp/ws"));
2683 assert!(message.starts_with(GATE_TOO_LARGE_MARKER));
2684 assert!(message.contains("/tmp/ws"));
2685 assert!(!message.contains("max_workspace_gb"));
2686 assert_eq!(message.lines().count(), 1, "the gate message is one line");
2687 }
2688
2689 #[test]
2690 fn entry_gate_message_is_distinct_and_never_blames_the_size_cap() {
2691 let message = WorkspaceGate::TooManyEntries.describe(0, Path::new("/tmp/ws"));
2692 assert!(message.starts_with(GATE_TOO_MANY_ENTRIES_MARKER));
2693 assert!(
2694 !message.contains(GATE_TOO_LARGE_MARKER),
2695 "the entry gate must not be reported as a size trip"
2696 );
2697 assert!(message.contains(&SIZE_WALK_MAX_ENTRIES.to_string()));
2698 assert!(!message.contains("max_workspace_gb"));
2699 }
2700
2701 #[test]
2702 fn estimate_workspace_size_bounded_skips_builtin_excluded_dirs() {
2703 let tmp = tempdir().unwrap();
2704 let workspace = tmp.path().join("workspace");
2705 std::fs::create_dir_all(workspace.join("node_modules")).unwrap();
2706 std::fs::create_dir_all(workspace.join("target")).unwrap();
2707 std::fs::create_dir_all(workspace.join("src")).unwrap();
2708 // 2 MB of "build output" in excluded dirs — must not count toward
2709 // the cap.
2710 std::fs::write(workspace.join("node_modules/big.bin"), vec![0u8; 1_000_000]).unwrap();
2711 std::fs::write(workspace.join("target/big.bin"), vec![0u8; 1_000_000]).unwrap();
2712 std::fs::write(workspace.join("src/lib.rs"), b"// real source").unwrap();
2713 let total = estimate_workspace_size_bounded(&workspace, 500_000, SIZE_WALK_MAX_ENTRIES)
2714 .expect("walk must succeed since real source is tiny");
2715 assert!(
2716 total < 1_000,
2717 "total ({total}) must reflect only src/, not node_modules/ or target/"
2718 );
2719 }
2720
2721 #[test]
2722 fn estimate_workspace_size_bounded_cap_zero_disables_cap() {
2723 let tmp = tempdir().unwrap();
2724 let workspace = tmp.path().join("workspace");
2725 std::fs::create_dir_all(&workspace).unwrap();
2726 // 10 KB file — would trip a 1 KB cap, but cap=0 means no cap.
2727 std::fs::write(workspace.join("big.bin"), vec![0u8; 10 * 1024]).unwrap();
2728 let total = estimate_workspace_size_bounded(&workspace, 0, SIZE_WALK_MAX_ENTRIES)
2729 .expect("cap=0 must always return a total");
2730 assert!(
2731 total >= 10 * 1024,
2732 "total ({total}) must include the 10 KB file when cap is disabled"
2733 );
2734 }
2735
2736 /// The entry ceiling is the bound that no test could reach before
2737 /// `max_entries` became a parameter: 200,000 inodes per run is not a
2738 /// price a unit test should pay. A byte-cheap workspace must still be
2739 /// refused, and refused as the *entry* gate — reporting `TooLarge` here
2740 /// would offer `max_workspace_gb` as a remedy that cannot lift it.
2741 #[test]
2742 fn entry_ceiling_refuses_a_byte_cheap_workspace_with_too_many_entries() {
2743 let tmp = tempdir().unwrap();
2744 let workspace = tmp.path().join("workspace");
2745 std::fs::create_dir_all(&workspace).unwrap();
2746 for i in 0..10 {
2747 std::fs::write(workspace.join(format!("f{i}.txt")), b"x").unwrap();
2748 }
2749 assert_eq!(
2750 estimate_workspace_size_bounded(&workspace, 10_000_000, 3),
2751 Err(WorkspaceGate::TooManyEntries),
2752 "ten tiny files under a 10 MB cap must trip the entry bound, not the byte cap"
2753 );
2754 }
2755
2756 /// The invariant documented on `WorkspaceGate::TooManyEntries` and on the
2757 /// estimator: `max_workspace_gb = 0` opts out of the byte cap only. A
2758 /// future "if `cap_bytes == 0`, skip the walk" shortcut would satisfy
2759 /// every other test here and silently delete the ceiling that exists to
2760 /// stop a multi-minute `git add -A`.
2761 #[test]
2762 fn cap_zero_does_not_lift_the_entry_ceiling() {
2763 let tmp = tempdir().unwrap();
2764 let workspace = tmp.path().join("workspace");
2765 std::fs::create_dir_all(&workspace).unwrap();
2766 for i in 0..10 {
2767 std::fs::write(workspace.join(format!("f{i}.txt")), b"x").unwrap();
2768 }
2769 assert_eq!(
2770 estimate_workspace_size_bounded(&workspace, 0, 3),
2771 Err(WorkspaceGate::TooManyEntries),
2772 "cap_bytes = 0 disables the byte cap, never the entry ceiling"
2773 );
2774 }
2775
2776 /// `ignore` disables gitignore matching entirely when no ancestor holds a
2777 /// `.git` (`require_git` defaults to true), but the snapshot's own
2778 /// `git add -A --work-tree <workspace>` reads `.gitignore` either way. A
2779 /// non-git workspace was therefore measured on content that would never
2780 /// be staged — and then told to fix it by editing `.gitignore`.
2781 ///
2782 /// Deliberately creates no `.git`: the point is the non-repo case.
2783 #[test]
2784 fn gitignored_content_is_excluded_outside_a_git_repo() {
2785 let tmp = tempdir().unwrap();
2786 let workspace = tmp.path().join("workspace");
2787 std::fs::create_dir_all(workspace.join("src")).unwrap();
2788 std::fs::write(workspace.join(".gitignore"), "big.bin\n").unwrap();
2789 std::fs::write(workspace.join("big.bin"), vec![0u8; 1_000_000]).unwrap();
2790 std::fs::write(workspace.join("src/lib.rs"), b"// real source").unwrap();
2791 assert!(
2792 !workspace.join(".git").exists(),
2793 "this test is only meaningful outside a git repo"
2794 );
2795 let total = estimate_workspace_size_bounded(&workspace, 500_000, SIZE_WALK_MAX_ENTRIES)
2796 .expect("the only large file is gitignored, so the walk must fit under the cap");
2797 assert!(
2798 total < 1_000,
2799 "total ({total}) must exclude the gitignored 1 MB file"
2800 );
2801 }
2802
2803 #[test]
2804 fn open_or_init_with_cap_rejects_oversized_workspace() {
2805 let tmp = tempdir().unwrap();
2806 let workspace = tmp.path().join("workspace");
2807 std::fs::create_dir_all(&workspace).unwrap();
2808 let _home = scoped_home(tmp.path());
2809 // Drop a 4 KB file under a 1 KB cap.
2810 std::fs::write(workspace.join("big.bin"), vec![0u8; 4096]).unwrap();
2811 let outcome = SnapshotRepo::open_or_init_with_cap(&workspace, 1024);
2812 let err = match outcome {
2813 Ok(_) => panic!("oversized workspace must fail open_or_init_with_cap"),
2814 Err(e) => e,
2815 };
2816 let msg = err.to_string();
2817 assert!(
2818 msg.contains(GATE_TOO_LARGE_MARKER),
2819 "error must call out the size cap; got: {msg}"
2820 );
2821 let named_owned = workspace.display().to_string();
2822 let named = named_owned
2823 .strip_prefix(r"\\?\")
2824 .or_else(|| named_owned.strip_prefix("//?/"))
2825 .unwrap_or(named_owned.as_str());
2826 assert!(
2827 msg.contains(named),
2828 "error must name the workspace it refused; got: {msg}"
2829 );
2830 // The remedy belongs to the localized notice. Repeating it here is
2831 // what produced the doubled, three-line warning users saw.
2832 assert!(
2833 !msg.contains("max_workspace_gb"),
2834 "gate error must not carry its own remedy copy; got: {msg}"
2835 );
2836 }
2837
2838 #[test]
2839 fn open_or_init_with_cap_zero_disables_size_check() {
2840 let tmp = tempdir().unwrap();
2841 let workspace = tmp.path().join("workspace");
2842 std::fs::create_dir_all(&workspace).unwrap();
2843 let _home = scoped_home(tmp.path());
2844 // 4 KB file but cap=0 → should still succeed.
2845 std::fs::write(workspace.join("big.bin"), vec![0u8; 4096]).unwrap();
2846 let repo = SnapshotRepo::open_or_init_with_cap(&workspace, 0)
2847 .expect("cap=0 must skip the size check");
2848 let id = repo
2849 .snapshot("pre-turn:1")
2850 .expect("snapshot under disabled cap");
2851 assert_eq!(id.as_str().len(), 40);
2852 }
2853
2854 #[test]
2855 fn session_tagged_snapshot_round_trips_through_list() {
2856 let tmp = tempdir().unwrap();
2857 let (repo, _home) = make_repo(tmp.path());
2858 std::fs::write(repo.work_tree().join("a.txt"), b"x").unwrap();
2859
2860 repo.snapshot_with_session("pre-turn:1", Some("sess-42"))
2861 .expect("snapshot with session");
2862
2863 let list = repo.list(10).expect("list");
2864 assert_eq!(list.len(), 1);
2865 // The visible label stays clean; the session id is decoded separately.
2866 assert_eq!(list[0].label, "pre-turn:1");
2867 assert_eq!(list[0].session_id.as_deref(), Some("sess-42"));
2868 }
2869
2870 #[test]
2871 fn untagged_snapshot_decodes_without_session() {
2872 let tmp = tempdir().unwrap();
2873 let (repo, _home) = make_repo(tmp.path());
2874 std::fs::write(repo.work_tree().join("a.txt"), b"x").unwrap();
2875
2876 repo.snapshot("pre-turn:1").expect("snapshot");
2877
2878 let list = repo.list(10).expect("list");
2879 assert_eq!(list.len(), 1);
2880 assert_eq!(list[0].label, "pre-turn:1");
2881 assert_eq!(list[0].session_id, None);
2882 }
2883
2884 #[test]
2885 fn prune_keep_last_n_preserves_session_tags() {
2886 let tmp = tempdir().unwrap();
2887 let (repo, _home) = make_repo(tmp.path());
2888 let file = repo.work_tree().join("a.txt");
2889
2890 // More snapshots than DEFAULT_MAX_SNAPSHOTS (50) so the survivor
2891 // chain is rebuilt as orphan commits — the path that previously
2892 // dropped the [sid=...] label prefix and turned every surviving
2893 // snapshot into a "legacy" (untagged) one.
2894 for i in 0..55 {
2895 std::fs::write(&file, format!("v{i}")).unwrap();
2896 repo.snapshot_with_session(&format!("pre-turn:{i}"), Some("sess-p"))
2897 .expect("tagged snapshot");
2898 }
2899
2900 let removed = repo.prune_keep_last_n(50).expect("prune");
2901 assert!(removed > 0, "expected prune to drop older snapshots");
2902
2903 let list = repo.list(usize::MAX).expect("list");
2904 assert_eq!(list.len(), 50);
2905 assert!(
2906 list.iter()
2907 .all(|s| s.session_id.as_deref() == Some("sess-p")),
2908 "prune must preserve [sid=...] prefixes; got untagged survivors"
2909 );
2910 }
2911
2912 #[test]
2913 fn tagged_and_untagged_snapshots_coexist_in_one_chain() {
2914 let tmp = tempdir().unwrap();
2915 let (repo, _home) = make_repo(tmp.path());
2916 std::fs::write(repo.work_tree().join("a.txt"), b"v1").unwrap();
2917
2918 // Legacy untagged snapshot, then a session-tagged one.
2919 repo.snapshot("pre-turn:1").expect("legacy snapshot");
2920 std::fs::write(repo.work_tree().join("a.txt"), b"v2").unwrap();
2921 repo.snapshot_with_session("pre-turn:1", Some("sess-a"))
2922 .expect("tagged snapshot");
2923
2924 let list = repo.list(10).expect("list");
2925 assert_eq!(list.len(), 2);
2926 // Newest first.
2927 assert_eq!(list[0].session_id.as_deref(), Some("sess-a"));
2928 assert_eq!(list[1].session_id, None);
2929 assert_eq!(list[1].label, "pre-turn:1");
2930 }
2931 }
2932
2932 lines RUST