返回 DeepSeek-TUI-2026
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::{Command, Output};
19 use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21 use super::paths::{ensure_snapshot_dir, snapshot_git_dir};
22
23 /// Identifier for a snapshot — currently the underlying git commit SHA.
24 #[derive(Debug, Clone, PartialEq, Eq)]
25 pub struct SnapshotId(pub String);
26
27 impl SnapshotId {
28 /// Borrow the SHA as a string slice.
29 pub fn as_str(&self) -> &str {
30 &self.0
31 }
32 }
33
34 /// A single snapshot record (one row in `git log`).
35 #[derive(Debug, Clone)]
36 pub struct Snapshot {
37 /// Commit SHA inside the side repo.
38 pub id: SnapshotId,
39 /// Subject line — the label passed to [`SnapshotRepo::snapshot`].
40 pub label: String,
41 /// Author timestamp (Unix seconds).
42 pub timestamp: i64,
43 }
44
45 /// Wrapper around the per-workspace side-git repo.
46 pub struct SnapshotRepo {
47 git_dir: PathBuf,
48 work_tree: PathBuf,
49 }
50
51 const BUILTIN_EXCLUDES: &str = "\
52 # DeepSeek TUI built-in snapshot exclusions
53 node_modules/
54 target/
55 dist/
56 build/
57 .build/
58 .next/
59 .nuxt/
60 .svelte-kit/
61 .turbo/
62 .parcel-cache/
63 vendor/
64 .cargo/
65 .rustup/
66 .npm/
67 .bun/
68 .yarn/
69 .pnpm-store/
70 .cache/
71 .venv/
72 venv/
73 .tox/
74 __pycache__/
75 *.pyc
76 .mypy_cache/
77 .pytest_cache/
78 .ruff_cache/
79 .gradle/
80 .m2/
81 .local/
82 .DS_Store
83
84 # Binary and generated artifacts. Snapshots are source rollback checkpoints,
85 # not a full binary backup; keeping these out avoids side-repo bloat.
86 *.exe
87 *.dll
88 *.so
89 *.dylib
90 *.wasm
91 *.o
92 *.obj
93 *.class
94 *.pdb
95 *.dSYM
96 *.zip
97 *.tar
98 *.tar.gz
99 *.tgz
100 *.tar.bz2
101 *.tar.xz
102 *.7z
103 *.rar
104 *.iso
105 *.dmg
106 *.bin
107 *.mp4
108 *.mov
109 *.mkv
110 *.avi
111 *.webm
112 *.mp3
113 *.wav
114 *.flac
115 *.aac
116 ";
117
118 impl SnapshotRepo {
119 /// Open or initialize the snapshot repo for `workspace`.
120 ///
121 /// On first use this:
122 /// 1. Creates the `~/.deepseek/snapshots/<…>/.git` dir.
123 /// 2. Runs `git init --bare=false --quiet`.
124 /// 3. Sets a fixed `user.name` / `user.email` so commits don't pick up
125 /// the user's global git identity (we don't want our snapshots to
126 /// look like they came from the user).
127 pub fn open_or_init(workspace: &Path) -> io::Result<Self> {
128 let work_tree = workspace
129 .canonicalize()
130 .unwrap_or_else(|_| workspace.to_path_buf());
131 if let Some(reason) =
132 unsafe_workspace_snapshot_reason(&work_tree, dirs::home_dir().as_deref())
133 {
134 return Err(io::Error::new(
135 io::ErrorKind::InvalidInput,
136 format!(
137 "workspace snapshots are disabled for {reason}: {}",
138 work_tree.display()
139 ),
140 ));
141 }
142
143 let _ = ensure_snapshot_dir(&work_tree)?;
144 let git_dir = snapshot_git_dir(&work_tree);
145
146 let needs_init = !git_dir.exists();
147 if needs_init {
148 let parent = git_dir.parent().ok_or_else(|| {
149 io::Error::new(io::ErrorKind::InvalidInput, "snapshot dir has no parent")
150 })?;
151 std::fs::create_dir_all(parent)?;
152 // `git init` here uses the parent directory as the work tree
153 // and stores metadata in `.git`. We then continue to use
154 // explicit `--git-dir` / `--work-tree` flags for every other
155 // command so behaviour is invariant of cwd.
156 let init = Command::new("git")
157 .arg("init")
158 .arg("--quiet")
159 .arg(parent)
160 .output()
161 .map_err(|e| io_other(format!("failed to spawn git init: {e}")))?;
162 if !init.status.success() {
163 return Err(io_other(format!(
164 "git init failed: {}",
165 String::from_utf8_lossy(&init.stderr).trim()
166 )));
167 }
168
169 // Pin a stable identity so snapshot commits are recognisable
170 // and don't bleed into the user's git config.
171 let _ = run_git(
172 &git_dir,
173 &work_tree,
174 &["config", "user.name", "deepseek-snapshots"],
175 );
176 let _ = run_git(
177 &git_dir,
178 &work_tree,
179 &["config", "user.email", "snapshots@deepseek-tui.local"],
180 );
181 // Don't auto-gc on every commit; we manage pruning ourselves.
182 let _ = run_git(&git_dir, &work_tree, &["config", "gc.auto", "0"]);
183 // Ignore CRLF rewriting — we want byte-for-byte fidelity.
184 let _ = run_git(&git_dir, &work_tree, &["config", "core.autocrlf", "false"]);
185 }
186
187 write_builtin_excludes(&git_dir)?;
188 Ok(Self { git_dir, work_tree })
189 }
190
191 /// Take a snapshot of the current working tree.
192 ///
193 /// Internally: `git add -A`, `git write-tree`, `git commit-tree`, then
194 /// `git update-ref HEAD <commit>`.
195 /// `git add -A` honours the user's workspace ignore rules while staging
196 /// into the side repo's index.
197 ///
198 /// Returns the snapshot's commit SHA.
199 pub fn snapshot(&self, label: &str) -> io::Result<SnapshotId> {
200 // Stage every tracked + untracked path the workspace exposes.
201 // `--all` here means `add` + `update` + `remove` — the same set
202 // `git status` would show.
203 let add = run_git(&self.git_dir, &self.work_tree, &["add", "-A"])?;
204 if !add.status.success() {
205 return Err(io_other(format!(
206 "git add -A failed: {}",
207 String::from_utf8_lossy(&add.stderr).trim()
208 )));
209 }
210
211 let tree = run_git(&self.git_dir, &self.work_tree, &["write-tree"])?;
212 if !tree.status.success() {
213 return Err(io_other(format!(
214 "git write-tree failed: {}",
215 String::from_utf8_lossy(&tree.stderr).trim()
216 )));
217 }
218 let tree = String::from_utf8_lossy(&tree.stdout).trim().to_string();
219
220 let parent = run_git(
221 &self.git_dir,
222 &self.work_tree,
223 &["rev-parse", "--verify", "HEAD"],
224 )?;
225 let parent = parent
226 .status
227 .success()
228 .then(|| String::from_utf8_lossy(&parent.stdout).trim().to_string())
229 .filter(|s| !s.is_empty());
230
231 let mut args = vec!["commit-tree".to_string(), tree];
232 if let Some(parent) = parent {
233 args.push("-p".to_string());
234 args.push(parent);
235 }
236 args.push("-m".to_string());
237 args.push(label.to_string());
238 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
239
240 // `commit-tree` creates marker commits even when the tree matches its
241 // parent, and it does not run user/global commit hooks.
242 let commit = run_git(&self.git_dir, &self.work_tree, &arg_refs)?;
243 if !commit.status.success() {
244 return Err(io_other(format!(
245 "git commit-tree failed: {}",
246 String::from_utf8_lossy(&commit.stderr).trim()
247 )));
248 }
249 let sha = String::from_utf8_lossy(&commit.stdout).trim().to_string();
250
251 let update = run_git(
252 &self.git_dir,
253 &self.work_tree,
254 &["update-ref", "HEAD", &sha],
255 )?;
256 if !update.status.success() {
257 return Err(io_other(format!(
258 "git update-ref HEAD failed: {}",
259 String::from_utf8_lossy(&update.stderr).trim()
260 )));
261 }
262
263 Ok(SnapshotId(sha))
264 }
265
266 /// Restore the workspace to the state at `id`.
267 ///
268 /// Uses `git checkout <sha> -- :/` which checks out every path in the
269 /// snapshot tree relative to the workspace root. We do NOT touch the
270 /// user's own `.git` — snapshots only contain working-tree files.
271 pub fn restore(&self, id: &SnapshotId) -> io::Result<()> {
272 let current_paths = self.tree_paths("HEAD")?;
273 let target_paths = self.tree_paths(id.as_str())?;
274 let checkout = run_git(
275 &self.git_dir,
276 &self.work_tree,
277 &["checkout", id.as_str(), "--", ":/"],
278 )?;
279 if !checkout.status.success() {
280 return Err(io_other(format!(
281 "git checkout failed: {}",
282 String::from_utf8_lossy(&checkout.stderr).trim()
283 )));
284 }
285 self.remove_paths_missing_from_target(&current_paths, &target_paths)?;
286 Ok(())
287 }
288
289 fn tree_paths(&self, treeish: &str) -> io::Result<HashSet<PathBuf>> {
290 let ls = run_git(
291 &self.git_dir,
292 &self.work_tree,
293 &["ls-tree", "-r", "-z", "--name-only", treeish],
294 )?;
295 if !ls.status.success() {
296 return Err(io_other(format!(
297 "git ls-tree failed: {}",
298 String::from_utf8_lossy(&ls.stderr).trim()
299 )));
300 }
301 Ok(parse_nul_paths(&ls.stdout))
302 }
303
304 fn remove_paths_missing_from_target(
305 &self,
306 current_paths: &HashSet<PathBuf>,
307 target_paths: &HashSet<PathBuf>,
308 ) -> io::Result<()> {
309 for rel in current_paths.difference(target_paths) {
310 if !is_safe_relative_path(rel) {
311 continue;
312 }
313 let path = self.work_tree.join(rel);
314 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
315 continue;
316 };
317 if metadata.file_type().is_dir() {
318 let _ = std::fs::remove_dir(&path);
319 } else {
320 std::fs::remove_file(&path)?;
321 }
322 self.prune_empty_parent_dirs(path.parent());
323 }
324 Ok(())
325 }
326
327 fn prune_empty_parent_dirs(&self, mut dir: Option<&Path>) {
328 while let Some(path) = dir {
329 if path == self.work_tree {
330 break;
331 }
332 if std::fs::remove_dir(path).is_err() {
333 break;
334 }
335 dir = path.parent();
336 }
337 }
338
339 /// List up to `limit` most-recent snapshots, newest first.
340 pub fn list(&self, limit: usize) -> io::Result<Vec<Snapshot>> {
341 // `git log -<n>` is the short form of `--max-count=<n>`; if `limit`
342 // is `usize::MAX` (caller asked for "everything") we pass an empty
343 // count so git defaults to no upper bound.
344 let mut args: Vec<String> = vec!["log".to_string()];
345 if limit < usize::MAX {
346 args.push(format!("--max-count={limit}"));
347 }
348 args.push("--pretty=format:%H%x09%at%x09%s".to_string());
349 args.push("--no-color".to_string());
350 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
351 let log = run_git(&self.git_dir, &self.work_tree, &arg_refs)?;
352 if !log.status.success() {
353 // No commits yet → empty list.
354 return Ok(Vec::new());
355 }
356 let stdout = String::from_utf8_lossy(&log.stdout);
357 let mut out = Vec::new();
358 for line in stdout.lines() {
359 let mut parts = line.splitn(3, '\t');
360 let sha = parts.next().unwrap_or("").to_string();
361 let ts = parts
362 .next()
363 .and_then(|s| s.parse::<i64>().ok())
364 .unwrap_or(0);
365 let subject = parts.next().unwrap_or("").to_string();
366 if sha.is_empty() {
367 continue;
368 }
369 out.push(Snapshot {
370 id: SnapshotId(sha),
371 label: subject,
372 timestamp: ts,
373 });
374 }
375 Ok(out)
376 }
377
378 /// Drop snapshots older than `max_age`, returning the count removed.
379 ///
380 /// Strategy: identify keepable commits (younger than the cutoff),
381 /// reset HEAD to the oldest survivor, then `git reflog expire` +
382 /// `git gc --prune=now` to actually reclaim space. Cheap and avoids
383 /// rewriting history when nothing has aged out.
384 pub fn prune_older_than(&self, max_age: Duration) -> io::Result<usize> {
385 let now = SystemTime::now()
386 .duration_since(UNIX_EPOCH)
387 .map_err(|e| io_other(format!("clock error: {e}")))?
388 .as_secs() as i64;
389 let cutoff = now - max_age.as_secs() as i64;
390
391 let snapshots = self.list(usize::MAX)?;
392 if snapshots.is_empty() {
393 return Ok(0);
394 }
395
396 // Snapshots are newest-first. Find the index of the first one
397 // at-or-older than the cutoff — every entry from that index
398 // onward is a candidate for removal. We use `<=` so a 0-second
399 // retention drops same-second commits (otherwise tests calling
400 // `prune_older_than(Duration::ZERO)` immediately after creating
401 // a snapshot would never prune anything).
402 let cut_index = snapshots.iter().position(|s| s.timestamp <= cutoff);
403 let Some(cut) = cut_index else {
404 return Ok(0);
405 };
406 let removed = snapshots.len() - cut;
407 if removed == 0 {
408 return Ok(0);
409 }
410
411 if cut == 0 {
412 // Every snapshot is older than the cutoff — wipe the repo
413 // entirely so the next snapshot starts a fresh history.
414 // Removing `.git/refs/heads/*` is enough to orphan the old
415 // commits, then gc reclaims them.
416 let refs_dir = self.git_dir.join("refs").join("heads");
417 if refs_dir.exists() {
418 for entry in std::fs::read_dir(&refs_dir)? {
419 let path = entry?.path();
420 if path.is_file() {
421 let _ = std::fs::remove_file(&path);
422 }
423 }
424 }
425 // Also drop HEAD's packed refs so `git log` returns nothing.
426 let packed = self.git_dir.join("packed-refs");
427 if packed.exists() {
428 let _ = std::fs::remove_file(&packed);
429 }
430 } else {
431 // Reset HEAD to the youngest commit older-than-cutoff's
432 // *predecessor* — i.e. the oldest surviving snapshot.
433 let survivor = &snapshots[cut - 1];
434 let reset = run_git(
435 &self.git_dir,
436 &self.work_tree,
437 &["update-ref", "HEAD", survivor.id.as_str()],
438 )?;
439 if !reset.status.success() {
440 return Err(io_other(format!(
441 "git update-ref failed: {}",
442 String::from_utf8_lossy(&reset.stderr).trim()
443 )));
444 }
445 }
446
447 // Reclaim space.
448 let _ = run_git(
449 &self.git_dir,
450 &self.work_tree,
451 &["reflog", "expire", "--expire=now", "--all"],
452 );
453 let _ = run_git(
454 &self.git_dir,
455 &self.work_tree,
456 &["gc", "--prune=now", "--quiet"],
457 );
458
459 Ok(removed)
460 }
461
462 /// Return the side-repo's `.git` directory (for diagnostics).
463 #[allow(dead_code)]
464 pub fn git_dir(&self) -> &Path {
465 &self.git_dir
466 }
467
468 /// Return the work tree path (for diagnostics).
469 #[allow(dead_code)]
470 pub fn work_tree(&self) -> &Path {
471 &self.work_tree
472 }
473 }
474
475 fn write_builtin_excludes(git_dir: &Path) -> io::Result<()> {
476 let info_dir = git_dir.join("info");
477 std::fs::create_dir_all(&info_dir)?;
478 std::fs::write(info_dir.join("exclude"), BUILTIN_EXCLUDES)
479 }
480
481 fn run_git(git_dir: &Path, work_tree: &Path, args: &[&str]) -> io::Result<Output> {
482 Command::new("git")
483 .arg("--git-dir")
484 .arg(git_dir)
485 .arg("--work-tree")
486 .arg(work_tree)
487 .args(args)
488 .output()
489 }
490
491 fn io_other(msg: impl Into<String>) -> io::Error {
492 io::Error::other(msg.into())
493 }
494
495 fn unsafe_workspace_snapshot_reason(workspace: &Path, home: Option<&Path>) -> Option<&'static str> {
496 let workspace = normalize_path_for_safety(workspace);
497 if is_filesystem_root(&workspace) {
498 return Some("filesystem root");
499 }
500
501 if is_home_directory(&workspace, home) {
502 return Some("home directory");
503 }
504
505 let home = home.map(normalize_path_for_safety)?;
506 if workspace.parent() == Some(home.as_path()) {
507 let name = workspace.file_name().and_then(|name| name.to_str());
508 if matches!(
509 name,
510 Some(
511 "Desktop" | "Documents" | "Downloads" | "Library" | "Movies" | "Music" | "Pictures"
512 )
513 ) {
514 return Some("home collection directory");
515 }
516 }
517
518 None
519 }
520
521 fn normalize_path_for_safety(path: &Path) -> PathBuf {
522 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
523 }
524
525 fn is_filesystem_root(path: &Path) -> bool {
526 path.parent().is_none()
527 }
528
529 fn is_home_directory(work_tree: &Path, home: Option<&Path>) -> bool {
530 let Some(home) = home else {
531 return false;
532 };
533
534 let home_canonical = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
535 work_tree == home_canonical
536 }
537
538 fn parse_nul_paths(bytes: &[u8]) -> HashSet<PathBuf> {
539 bytes
540 .split(|b| *b == 0)
541 .filter(|chunk| !chunk.is_empty())
542 .map(|chunk| PathBuf::from(String::from_utf8_lossy(chunk).into_owned()))
543 .collect()
544 }
545
546 fn is_safe_relative_path(path: &Path) -> bool {
547 !path.as_os_str().is_empty()
548 && path
549 .components()
550 .all(|component| matches!(component, Component::Normal(_)))
551 }
552
553 #[cfg(test)]
554 mod tests {
555 use super::*;
556 use crate::test_support::lock_test_env;
557 use std::sync::MutexGuard;
558 use tempfile::tempdir;
559
560 /// Holds the home directory pinned to a tempdir for the lifetime of a test. Also
561 /// owns the process-wide env-var mutex so tests across modules
562 /// don't trample each other's home env vars.
563 pub(super) struct ScopedHome {
564 prev_vars: Vec<(&'static str, Option<std::ffi::OsString>)>,
565 _guard: MutexGuard<'static, ()>,
566 }
567 impl Drop for ScopedHome {
568 fn drop(&mut self) {
569 // SAFETY: process-wide lock still held.
570 unsafe {
571 for (key, prev) in self.prev_vars.drain(..) {
572 match prev {
573 Some(value) => std::env::set_var(key, value),
574 None => std::env::remove_var(key),
575 }
576 }
577 }
578 }
579 }
580 pub(super) fn scoped_home(home: &Path) -> ScopedHome {
581 let guard = lock_test_env();
582 let prev_vars = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"]
583 .into_iter()
584 .map(|key| (key, std::env::var_os(key)))
585 .collect();
586 // SAFETY: serialised by the global env lock.
587 unsafe {
588 std::env::set_var("HOME", home);
589 std::env::set_var("USERPROFILE", home);
590 std::env::remove_var("HOMEDRIVE");
591 std::env::remove_var("HOMEPATH");
592 }
593 ScopedHome {
594 prev_vars,
595 _guard: guard,
596 }
597 }
598
599 /// Build a side-repo whose snapshot dir lives under the same
600 /// tempdir we're using for `HOME` — so the inner `dirs::home_dir()`
601 /// lookup stays inside our sandbox. Returns the guard alongside so
602 /// the caller can keep HOME pinned for the rest of the test.
603 fn make_repo(tmp: &Path) -> (SnapshotRepo, ScopedHome) {
604 let workspace = tmp.join("workspace");
605 std::fs::create_dir_all(&workspace).unwrap();
606 let guard = scoped_home(tmp);
607 let repo = SnapshotRepo::open_or_init(&workspace).expect("open_or_init");
608 (repo, guard)
609 }
610
611 #[test]
612 fn snapshot_creates_commit_in_side_repo_only() {
613 let tmp = tempdir().unwrap();
614 let (repo, _home) = make_repo(tmp.path());
615 std::fs::write(repo.work_tree().join("a.txt"), b"alpha").unwrap();
616
617 let id = repo.snapshot("pre-turn:1").expect("snapshot");
618 assert_eq!(id.as_str().len(), 40);
619
620 let list = repo.list(10).expect("list");
621 assert_eq!(list.len(), 1);
622 assert_eq!(list[0].label, "pre-turn:1");
623
624 // The user's workspace must NOT have a real `.git` because we
625 // never created one in their workspace — only in the side dir.
626 assert!(!repo.work_tree().join(".git").exists());
627 }
628
629 #[test]
630 fn restore_reverts_workspace_files() {
631 let tmp = tempdir().unwrap();
632 let (repo, _home) = make_repo(tmp.path());
633 let f = repo.work_tree().join("file.txt");
634
635 std::fs::write(&f, b"original").unwrap();
636 let id = repo.snapshot("pre-turn:1").expect("snapshot");
637
638 std::fs::write(&f, b"clobbered").unwrap();
639 repo.snapshot("post-turn:1").expect("snapshot 2");
640
641 repo.restore(&id).expect("restore");
642 let after = std::fs::read_to_string(&f).unwrap();
643 assert_eq!(after, "original");
644 }
645
646 #[test]
647 fn restore_removes_files_added_after_target_snapshot() {
648 let tmp = tempdir().unwrap();
649 let (repo, _home) = make_repo(tmp.path());
650 let original = repo.work_tree().join("original.txt");
651 let added = repo.work_tree().join("added.txt");
652
653 std::fs::write(&original, b"original").unwrap();
654 let id = repo.snapshot("pre-turn:1").expect("snapshot");
655
656 std::fs::write(&added, b"new file").unwrap();
657 repo.snapshot("post-turn:1").expect("snapshot 2");
658
659 repo.restore(&id).expect("restore");
660 assert!(original.exists());
661 assert!(!added.exists(), "restore must remove tracked added files");
662 }
663
664 #[test]
665 fn snapshot_and_restore_do_not_move_user_git_head() {
666 let tmp = tempdir().unwrap();
667 let workspace = tmp.path().join("workspace");
668 std::fs::create_dir_all(&workspace).unwrap();
669 Command::new("git")
670 .arg("-C")
671 .arg(&workspace)
672 .arg("init")
673 .arg("--quiet")
674 .status()
675 .unwrap();
676 std::fs::write(workspace.join("tracked.txt"), b"committed").unwrap();
677 Command::new("git")
678 .arg("-C")
679 .arg(&workspace)
680 .arg("add")
681 .arg("tracked.txt")
682 .status()
683 .unwrap();
684 Command::new("git")
685 .arg("-C")
686 .arg(&workspace)
687 .arg("-c")
688 .arg("user.name=user")
689 .arg("-c")
690 .arg("user.email=user@example.test")
691 .arg("commit")
692 .arg("--quiet")
693 .arg("-m")
694 .arg("init")
695 .status()
696 .unwrap();
697 let user_head_before = Command::new("git")
698 .arg("-C")
699 .arg(&workspace)
700 .args(["rev-parse", "HEAD"])
701 .output()
702 .unwrap()
703 .stdout;
704
705 let _home = scoped_home(tmp.path());
706 let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
707 std::fs::write(workspace.join("tracked.txt"), b"dirty-before").unwrap();
708 let id = repo.snapshot("pre-turn:1").unwrap();
709 std::fs::write(workspace.join("tracked.txt"), b"dirty-after").unwrap();
710 repo.snapshot("post-turn:1").unwrap();
711 repo.restore(&id).unwrap();
712
713 let user_head_after = Command::new("git")
714 .arg("-C")
715 .arg(&workspace)
716 .args(["rev-parse", "HEAD"])
717 .output()
718 .unwrap()
719 .stdout;
720 assert_eq!(user_head_after, user_head_before);
721 assert_eq!(
722 std::fs::read_to_string(workspace.join("tracked.txt")).unwrap(),
723 "dirty-before"
724 );
725 }
726
727 #[test]
728 fn list_respects_limit() {
729 let tmp = tempdir().unwrap();
730 let (repo, _home) = make_repo(tmp.path());
731 for i in 0..5 {
732 std::fs::write(repo.work_tree().join("f.txt"), format!("v{i}")).unwrap();
733 repo.snapshot(&format!("turn:{i}")).unwrap();
734 }
735 let three = repo.list(3).unwrap();
736 assert_eq!(three.len(), 3);
737 // Newest first.
738 assert_eq!(three[0].label, "turn:4");
739 }
740
741 #[test]
742 fn prune_drops_snapshots_older_than_threshold() {
743 let tmp = tempdir().unwrap();
744 let (repo, _home) = make_repo(tmp.path());
745 std::fs::write(repo.work_tree().join("f.txt"), "v0").unwrap();
746 repo.snapshot("turn:0").unwrap();
747
748 // Wait one second so the snapshot's commit timestamp is strictly
749 // in the past relative to the prune call's "now" — otherwise
750 // same-second comparisons make the assertion flaky.
751 std::thread::sleep(Duration::from_millis(1100));
752
753 let removed = repo.prune_older_than(Duration::from_secs(0)).unwrap();
754 assert!(removed >= 1, "expected at least 1 pruned, got {removed}");
755
756 // After pruning everything, the next snapshot should start a
757 // fresh history.
758 std::fs::write(repo.work_tree().join("f.txt"), "v1").unwrap();
759 repo.snapshot("turn:1").unwrap();
760 let list = repo.list(10).unwrap();
761 assert_eq!(list.len(), 1);
762 assert_eq!(list[0].label, "turn:1");
763 }
764
765 #[test]
766 fn snapshot_respects_workspace_gitignore() {
767 let tmp = tempdir().unwrap();
768 let (repo, _home) = make_repo(tmp.path());
769 std::fs::write(repo.work_tree().join(".gitignore"), "ignored.txt\n").unwrap();
770 std::fs::write(repo.work_tree().join("ignored.txt"), b"secret").unwrap();
771 std::fs::write(repo.work_tree().join("kept.txt"), b"public").unwrap();
772
773 let id = repo.snapshot("pre-turn:1").expect("snapshot");
774
775 // `git ls-tree` against the snapshot's commit shouldn't list ignored.txt.
776 let ls = run_git(
777 repo.git_dir(),
778 repo.work_tree(),
779 &["ls-tree", "-r", "--name-only", id.as_str()],
780 )
781 .expect("ls-tree");
782 let names = String::from_utf8_lossy(&ls.stdout);
783 assert!(names.contains("kept.txt"), "kept.txt missing: {names}");
784 assert!(
785 !names.contains("ignored.txt"),
786 "ignored.txt should not be in snapshot: {names}",
787 );
788 }
789
790 #[test]
791 fn unsafe_workspace_rejects_home_directory_workspace() {
792 let tmp = tempdir().unwrap();
793 let home = tmp.path();
794
795 assert_eq!(
796 unsafe_workspace_snapshot_reason(home, Some(home)),
797 Some("home directory")
798 );
799 }
800
801 #[test]
802 fn unsafe_workspace_rejects_home_collection_directories() {
803 let tmp = tempdir().unwrap();
804 let home = tmp.path();
805 let desktop = tmp.path().join("Desktop");
806 std::fs::create_dir_all(&desktop).unwrap();
807
808 assert_eq!(
809 unsafe_workspace_snapshot_reason(&desktop, Some(home)),
810 Some("home collection directory")
811 );
812 }
813
814 #[test]
815 fn unsafe_workspace_allows_project_directories_under_home() {
816 let tmp = tempdir().unwrap();
817 let home = tmp.path();
818 let workspace = tmp.path().join("code").join("project");
819 std::fs::create_dir_all(&workspace).unwrap();
820
821 assert_eq!(
822 unsafe_workspace_snapshot_reason(&workspace, Some(home)),
823 None
824 );
825 }
826
827 #[test]
828 fn snapshot_respects_builtin_excludes() {
829 let tmp = tempdir().unwrap();
830 let (repo, _home) = make_repo(tmp.path());
831 std::fs::create_dir_all(repo.work_tree().join("node_modules/pkg")).unwrap();
832 std::fs::create_dir_all(repo.work_tree().join(".next/cache")).unwrap();
833 std::fs::create_dir_all(repo.work_tree().join("src")).unwrap();
834 std::fs::write(
835 repo.work_tree().join("node_modules/pkg/index.js"),
836 b"generated",
837 )
838 .unwrap();
839 std::fs::write(repo.work_tree().join(".next/cache/chunk.bin"), b"generated").unwrap();
840 std::fs::write(repo.work_tree().join("debug.wasm"), b"binary").unwrap();
841 std::fs::write(repo.work_tree().join("src/main.rs"), b"fn main() {}").unwrap();
842
843 let excludes = std::fs::read_to_string(repo.git_dir().join("info/exclude")).unwrap();
844 assert!(excludes.contains("node_modules/"));
845 assert!(excludes.contains(".next/"));
846 assert!(excludes.contains("*.wasm"));
847
848 let id = repo.snapshot("pre-turn:1").expect("snapshot");
849 let ls = run_git(
850 repo.git_dir(),
851 repo.work_tree(),
852 &["ls-tree", "-r", "--name-only", id.as_str()],
853 )
854 .expect("ls-tree");
855 let names = String::from_utf8_lossy(&ls.stdout);
856 assert!(
857 names.contains("src/main.rs"),
858 "src/main.rs missing: {names}"
859 );
860 assert!(
861 !names.contains("node_modules"),
862 "node_modules should not be in snapshot: {names}",
863 );
864 assert!(
865 !names.contains(".next"),
866 ".next should not be in snapshot: {names}",
867 );
868 assert!(
869 !names.contains("debug.wasm"),
870 "binary artifacts should not be in snapshot: {names}",
871 );
872 }
873
874 #[test]
875 fn open_or_init_is_idempotent() {
876 let tmp = tempdir().unwrap();
877 let (_r, _h) = make_repo(tmp.path());
878 // Second open should not panic and should reuse the existing
879 // `.git`. We re-open via the public API rather than make_repo to
880 // avoid double-acquiring HOME (the guard would deadlock).
881 drop((_r, _h));
882 let (_r2, _h2) = make_repo(tmp.path());
883 }
884
885 #[test]
886 fn home_directory_guard_matches_canonical_paths() {
887 let tmp = tempdir().unwrap();
888 let home = tmp.path();
889 let home_canonical = home.canonicalize().unwrap();
890 let workspace = home.join("workspace");
891 std::fs::create_dir_all(&workspace).unwrap();
892 let workspace_canonical = workspace.canonicalize().unwrap();
893
894 assert!(is_home_directory(&home_canonical, Some(home)));
895 assert!(!is_home_directory(&workspace_canonical, Some(home)));
896 assert!(!is_home_directory(&home_canonical, None));
897 }
898 }
899
899 lines RUST