| 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(¤t_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 |