1use anyhow::{Context, Result, bail};
2use semver::Version;
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::process::Command;
7
8use crate::commit::Commit;
9use crate::error::ReleaseError;
10
11fn sha256_hex(data: &[u8]) -> String {
12 let mut hasher = Sha256::new();
13 hasher.update(data);
14 format!("{:x}", hasher.finalize())
15}
16
17#[derive(Debug, Clone)]
19pub struct TagInfo {
20 pub name: String,
21 pub version: Version,
22 pub sha: String,
23}
24
25pub trait GitRepository: Send + Sync {
27 fn latest_tag(&self, prefix: &str) -> Result<Option<TagInfo>, ReleaseError>;
29
30 fn commits_since(&self, from: Option<&str>) -> Result<Vec<Commit>, ReleaseError>;
33
34 fn create_tag(&self, name: &str, message: &str, sign: bool) -> Result<(), ReleaseError>;
36
37 fn push_tag(&self, name: &str) -> Result<(), ReleaseError>;
39
40 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError>;
43
44 fn is_dirty(&self) -> Result<bool, ReleaseError>;
46
47 fn push(&self) -> Result<(), ReleaseError>;
49
50 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError>;
52
53 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError>;
55
56 fn all_tags(&self, prefix: &str) -> Result<Vec<TagInfo>, ReleaseError>;
58
59 fn commits_between(&self, from: Option<&str>, to: &str) -> Result<Vec<Commit>, ReleaseError>;
62
63 fn tag_date(&self, tag_name: &str) -> Result<String, ReleaseError>;
65
66 fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError>;
68
69 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError>;
71
72 fn head_sha(&self) -> Result<String, ReleaseError>;
74
75 fn commits_since_in_path(
77 &self,
78 from: Option<&str>,
79 path: &str,
80 ) -> Result<Vec<Commit>, ReleaseError> {
81 let _ = path;
83 self.commits_since(from)
84 }
85
86 fn commits_between_in_path(
88 &self,
89 from: Option<&str>,
90 to: &str,
91 path: &str,
92 ) -> Result<Vec<Commit>, ReleaseError> {
93 let _ = path;
94 self.commits_between(from, to)
95 }
96}
97
98fn git_unquote(s: &str) -> String {
103 let s = s.trim();
104 if !(s.starts_with('"') && s.ends_with('"')) {
105 return s.to_string();
106 }
107 let inner = &s[1..s.len() - 1];
109 let mut out = Vec::new();
110 let bytes = inner.as_bytes();
111 let mut i = 0;
112 while i < bytes.len() {
113 if bytes[i] == b'\\' && i + 1 < bytes.len() {
114 i += 1;
115 match bytes[i] {
116 b'\\' => out.push(b'\\'),
117 b'"' => out.push(b'"'),
118 b'n' => out.push(b'\n'),
119 b't' => out.push(b'\t'),
120 b'r' => out.push(b'\r'),
121 b'a' => out.push(0x07),
122 b'b' => out.push(0x08),
123 b'f' => out.push(0x0C),
124 b'v' => out.push(0x0B),
125 b'0'..=b'3' => {
127 let mut val = (bytes[i] - b'0') as u16;
128 for _ in 0..2 {
129 if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
130 i += 1;
131 val = val * 8 + (bytes[i] - b'0') as u16;
132 } else {
133 break;
134 }
135 }
136 out.push(val as u8);
137 }
138 other => {
139 out.push(b'\\');
140 out.push(other);
141 }
142 }
143 } else {
144 out.push(bytes[i]);
145 }
146 i += 1;
147 }
148 String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).to_string())
149}
150
151pub struct GitRepo {
152 root: PathBuf,
153}
154
155#[allow(dead_code)]
156impl GitRepo {
157 pub fn discover() -> Result<Self> {
158 let output = Command::new("git")
159 .args(["rev-parse", "--show-toplevel"])
160 .output()
161 .context("failed to run git")?;
162
163 if !output.status.success() {
164 bail!("not in a git repository");
165 }
166
167 let root = String::from_utf8(output.stdout)
168 .context("invalid utf-8 from git")?
169 .trim()
170 .into();
171
172 Ok(Self { root })
173 }
174
175 pub fn root(&self) -> &PathBuf {
176 &self.root
177 }
178
179 fn git(&self, args: &[&str]) -> Result<String> {
180 let output = Command::new("git")
181 .args(["-C", self.root.to_str().unwrap()])
182 .args(args)
183 .output()
184 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
185
186 if !output.status.success() {
187 let stderr = String::from_utf8_lossy(&output.stderr);
188 bail!("git {} failed: {}", args.join(" "), stderr.trim());
189 }
190
191 Ok(String::from_utf8_lossy(&output.stdout).to_string())
192 }
193
194 fn git_allow_failure(&self, args: &[&str]) -> Result<(bool, String)> {
195 let output = Command::new("git")
196 .args(["-C", self.root.to_str().unwrap()])
197 .args(args)
198 .output()
199 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
200
201 Ok((
202 output.status.success(),
203 String::from_utf8_lossy(&output.stdout).to_string(),
204 ))
205 }
206
207 pub fn has_staged_changes(&self) -> Result<bool> {
208 let out = self.git(&["diff", "--cached", "--name-only"])?;
209 Ok(!out.trim().is_empty())
210 }
211
212 pub fn has_any_changes(&self) -> Result<bool> {
213 let out = self.git(&["status", "--porcelain"])?;
214 Ok(!out.trim().is_empty())
215 }
216
217 pub fn has_head(&self) -> Result<bool> {
218 let (ok, _) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
219 Ok(ok)
220 }
221
222 pub fn reset_head(&self) -> Result<()> {
223 if self.has_head()? {
224 self.git(&["reset", "HEAD", "--quiet"])?;
225 } else {
226 let _ = self.git_allow_failure(&["rm", "--cached", "-r", ".", "--quiet"]);
228 }
229 Ok(())
230 }
231
232 pub fn stage_file(&self, file: &str) -> Result<bool> {
233 let (ok, _) = self.git_allow_failure(&["add", "--", file])?;
241 Ok(ok)
242 }
243
244 pub fn has_staged_after_add(&self) -> Result<bool> {
245 self.has_staged_changes()
246 }
247
248 pub fn commit(&self, message: &str) -> Result<()> {
249 let output = Command::new("git")
250 .args(["-C", self.root.to_str().unwrap()])
251 .args(["commit", "-F", "-"])
252 .stdin(std::process::Stdio::piped())
253 .stdout(std::process::Stdio::piped())
254 .stderr(std::process::Stdio::piped())
255 .spawn()
256 .context("failed to spawn git commit")?;
257
258 use std::io::Write;
259 let mut child = output;
260 if let Some(mut stdin) = child.stdin.take() {
261 stdin.write_all(message.as_bytes())?;
262 }
263
264 let out = child.wait_with_output()?;
265 if !out.status.success() {
266 let stderr = String::from_utf8_lossy(&out.stderr);
267 bail!("git commit failed: {}", stderr.trim());
268 }
269
270 Ok(())
271 }
272
273 pub fn recent_commits(&self, count: usize) -> Result<String> {
274 self.git(&["--no-pager", "log", "--oneline", &format!("-{count}")])
275 }
276
277 pub fn diff_cached(&self) -> Result<String> {
278 self.git(&["diff", "--cached"])
279 }
280
281 pub fn diff_cached_stat(&self) -> Result<String> {
282 self.git(&["diff", "--cached", "--stat"])
283 }
284
285 pub fn diff_head(&self) -> Result<String> {
286 let (ok, out) = self.git_allow_failure(&["diff", "HEAD"])?;
287 if ok { Ok(out) } else { self.git(&["diff"]) }
288 }
289
290 pub fn status_porcelain(&self) -> Result<String> {
291 self.git(&["status", "--porcelain"])
292 }
293
294 pub fn untracked_files(&self) -> Result<String> {
295 self.git(&["ls-files", "--others", "--exclude-standard"])
296 }
297
298 pub fn show(&self, rev: &str) -> Result<String> {
299 self.git(&["show", rev])
300 }
301
302 pub fn log_range(&self, base: &str, count: Option<usize>) -> Result<String> {
303 let mut args = vec!["--no-pager", "log", "--oneline"];
304 let count_str;
305 if let Some(n) = count {
306 count_str = format!("-{n}");
307 args.push(&count_str);
308 }
309 args.push(base);
310 self.git(&args)
311 }
312
313 pub fn diff_range(&self, base: &str) -> Result<String> {
314 self.git(&["diff", base])
315 }
316
317 pub fn current_branch(&self) -> Result<String> {
318 let out = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
319 Ok(out.trim().to_string())
320 }
321
322 pub fn head_short(&self) -> Result<String> {
323 let out = self.git(&["rev-parse", "--short", "HEAD"])?;
324 Ok(out.trim().to_string())
325 }
326
327 pub fn commits_since_last_tag(&self) -> Result<usize> {
329 let (ok, tag) = self.git_allow_failure(&["describe", "--tags", "--abbrev=0"])?;
331 let tag = tag.trim();
332
333 let out = if ok && !tag.is_empty() {
334 self.git(&["rev-list", &format!("{tag}..HEAD"), "--count"])?
335 } else {
336 self.git(&["rev-list", "HEAD", "--count"])?
337 };
338
339 out.trim()
340 .parse::<usize>()
341 .context("failed to parse commit count")
342 }
343
344 pub fn log_detailed(&self, count: usize) -> Result<String> {
346 let out = self.git(&[
347 "--no-pager",
348 "log",
349 "--reverse",
350 &format!("-{count}"),
351 "--format=%h %s%n%b%n---",
352 ])?;
353 Ok(out)
354 }
355
356 pub fn file_statuses(&self) -> Result<HashMap<String, char>> {
357 let out = self.git(&["status", "--porcelain"])?;
358 let mut map = HashMap::new();
359 for line in out.lines() {
360 if line.len() < 3 {
361 continue;
362 }
363 let xy = &line.as_bytes()[..2];
364 let path = line[3..].to_string();
365 let (x, y) = (xy[0], xy[1]);
366 let is_rename = matches!((x, y), (b'R', _) | (_, b'R'));
367 if is_rename {
368 if let Some(pos) = path.find(" -> ") {
369 let old_path = git_unquote(&path[..pos]);
370 let new_path = git_unquote(&path[pos + 4..]);
371 map.insert(old_path, 'D');
372 map.insert(new_path, 'R');
373 } else {
374 map.insert(git_unquote(&path), 'R');
375 }
376 } else {
377 let status = match (x, y) {
378 (b'?', b'?') => 'A',
379 (b'A', _) | (_, b'A') => 'A',
380 (b'D', _) | (_, b'D') => 'D',
381 (b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => 'M',
382 _ => '~',
383 };
384 map.insert(git_unquote(&path), status);
385 }
386 }
387 Ok(map)
388 }
389
390 pub fn snapshot_working_tree(&self) -> Result<PathBuf> {
403 let snapshot_dir = snapshot_dir_for(&self.root)
404 .context("failed to resolve snapshot directory (no data directory available)")?;
405 if snapshot_dir.exists() {
407 std::fs::remove_dir_all(&snapshot_dir).ok();
408 }
409 std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot directory")?;
410
411 let files_dir = snapshot_dir.join("files");
412 std::fs::create_dir_all(&files_dir)?;
413
414 std::fs::write(
416 snapshot_dir.join("repo_root"),
417 self.root.to_string_lossy().as_bytes(),
418 )
419 .context("failed to write repo_root")?;
420
421 let (has_head, head_ref) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
423 if has_head {
424 std::fs::write(snapshot_dir.join("head_ref"), head_ref.trim())
425 .context("failed to write head_ref")?;
426 }
427
428 let porcelain = self.git(&["status", "--porcelain"])?;
431 let staged_names = self.git(&["diff", "--cached", "--name-only", "-z"])?;
432 let staged_set: std::collections::HashSet<String> = staged_names
433 .split('\0')
434 .map(|l| l.trim().to_string())
435 .filter(|l| !l.is_empty())
436 .collect();
437
438 #[derive(serde::Serialize, serde::Deserialize)]
439 struct ManifestEntry {
440 path: String,
441 index_status: char,
443 worktree_status: char,
445 staged: bool,
447 has_content: bool,
449 }
450
451 let mut manifest: Vec<ManifestEntry> = Vec::new();
452
453 for line in porcelain.lines() {
454 if line.len() < 3 {
455 continue;
456 }
457 let bytes = line.as_bytes();
458 let x = bytes[0] as char;
459 let y = bytes[1] as char;
460 let raw = line[3..].to_string();
461 let path = if let Some(pos) = raw.find(" -> ") {
463 git_unquote(&raw[pos + 4..])
464 } else {
465 git_unquote(&raw)
466 };
467
468 let src = self.root.join(&path);
469 let has_content = src.exists() && src.is_file();
470
471 if has_content {
472 let dest = files_dir.join(&path);
473 if let Some(parent) = dest.parent() {
474 std::fs::create_dir_all(parent).ok();
475 }
476 if let Err(e) = std::fs::copy(&src, &dest) {
477 eprintln!("warning: failed to snapshot {path}: {e}");
478 }
479 }
480
481 manifest.push(ManifestEntry {
482 staged: staged_set.contains(path.as_str()),
483 path,
484 index_status: x,
485 worktree_status: y,
486 has_content,
487 });
488 }
489
490 let manifest_json =
491 serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")?;
492 std::fs::write(snapshot_dir.join("manifest.json"), manifest_json)
493 .context("failed to write manifest.json")?;
494
495 let now = std::time::SystemTime::now()
497 .duration_since(std::time::UNIX_EPOCH)
498 .unwrap_or_default()
499 .as_secs();
500 std::fs::write(snapshot_dir.join("timestamp"), now.to_string())
501 .context("failed to write timestamp")?;
502
503 Ok(snapshot_dir)
504 }
505
506 pub fn restore_snapshot(&self) -> Result<()> {
516 let snapshot_dir = self.snapshot_dir()?;
517 if !snapshot_dir.join("timestamp").exists() {
518 bail!("no valid snapshot found");
519 }
520
521 let files_dir = snapshot_dir.join("files");
522
523 let head_ref_path = snapshot_dir.join("head_ref");
525 if head_ref_path.exists() {
526 let original_head = std::fs::read_to_string(&head_ref_path)?;
527 let original_head = original_head.trim();
528 if !original_head.is_empty() {
529 let _ = self.git_allow_failure(&["reset", "--soft", original_head]);
530 }
531 }
532
533 self.reset_head()?;
535
536 let manifest_path = snapshot_dir.join("manifest.json");
538 if !manifest_path.exists() {
539 bail!("snapshot manifest.json missing — cannot restore");
540 }
541
542 #[derive(serde::Deserialize)]
543 struct ManifestEntry {
544 path: String,
545 index_status: char,
546 worktree_status: char,
547 staged: bool,
548 has_content: bool,
549 }
550
551 let manifest_data = std::fs::read_to_string(&manifest_path)?;
552 let manifest: Vec<ManifestEntry> =
553 serde_json::from_str(&manifest_data).context("failed to parse snapshot manifest")?;
554
555 let mut restored = 0usize;
556 let mut failed = 0usize;
557
558 for entry in &manifest {
559 let dest = self.root.join(&entry.path);
560
561 if entry.has_content {
562 let src = files_dir.join(&entry.path);
564 if src.exists() {
565 if let Some(parent) = dest.parent() {
566 std::fs::create_dir_all(parent).ok();
567 }
568 match std::fs::copy(&src, &dest) {
569 Ok(_) => restored += 1,
570 Err(e) => {
571 eprintln!("warning: failed to restore {}: {e}", entry.path);
572 failed += 1;
573 }
574 }
575 } else {
576 eprintln!("warning: snapshot missing content for {}", entry.path);
577 failed += 1;
578 }
579 } else if entry.index_status == 'D' || entry.worktree_status == 'D' {
580 if dest.exists() {
582 std::fs::remove_file(&dest).ok();
583 }
584 }
585
586 if entry.staged {
588 let _ = self.git_allow_failure(&["add", "--", &entry.path]);
589 }
590 }
591
592 if failed > 0 {
593 eprintln!("sr: restored {restored} files, {failed} failed");
594 }
595
596 Ok(())
597 }
598
599 pub fn clear_snapshot(&self) {
601 if let Ok(dir) = self.snapshot_dir() {
602 let _ = std::fs::remove_dir_all(&dir);
603 }
604 }
605
606 pub fn snapshot_dir(&self) -> Result<PathBuf> {
608 snapshot_dir_for(&self.root)
609 .context("failed to resolve snapshot directory (no data directory available)")
610 }
611
612 pub fn has_snapshot(&self) -> bool {
614 self.snapshot_dir()
615 .map(|d| d.join("timestamp").exists())
616 .unwrap_or(false)
617 }
618}
619
620fn snapshot_dir_for(repo_root: &std::path::Path) -> Option<PathBuf> {
623 let base = dirs::data_local_dir()?;
624 let repo_id = &sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
625 Some(base.join("sr").join("snapshots").join(repo_id))
626}
627
628pub struct SnapshotGuard<'a> {
631 repo: &'a GitRepo,
632 succeeded: bool,
633}
634
635impl<'a> SnapshotGuard<'a> {
636 pub fn new(repo: &'a GitRepo) -> Result<Self> {
638 repo.snapshot_working_tree()?;
639 Ok(Self {
640 repo,
641 succeeded: false,
642 })
643 }
644
645 pub fn success(mut self) {
647 self.succeeded = true;
648 self.repo.clear_snapshot();
649 }
650}
651
652impl Drop for SnapshotGuard<'_> {
653 fn drop(&mut self) {
654 if !self.succeeded && self.repo.has_snapshot() {
655 eprintln!("sr: operation failed, restoring working tree from snapshot...");
656 if let Err(e) = self.repo.restore_snapshot() {
657 eprintln!("sr: warning: snapshot restore failed: {e}");
658 if let Ok(dir) = self.repo.snapshot_dir() {
659 eprintln!(
660 "sr: snapshot preserved at {} for manual recovery",
661 dir.display()
662 );
663 }
664 } else {
665 self.repo.clear_snapshot();
666 }
667 }
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use std::fs;
675
676 fn temp_repo() -> (tempfile::TempDir, GitRepo) {
678 let dir = tempfile::tempdir().unwrap();
679 let root = dir.path().to_path_buf();
680
681 let git = |args: &[&str]| {
682 Command::new("git")
683 .args(["-C", root.to_str().unwrap()])
684 .args(args)
685 .output()
686 .unwrap()
687 };
688
689 git(&["init"]);
690 git(&["config", "user.email", "test@test.com"]);
691 git(&["config", "user.name", "Test"]);
692 fs::write(root.join("init.txt"), "init").unwrap();
694 git(&["add", "init.txt"]);
695 git(&["commit", "-m", "initial"]);
696
697 let repo = GitRepo { root };
698 (dir, repo)
699 }
700
701 #[test]
702 fn snapshot_creates_manifest_with_staged_files() {
703 let (_dir, repo) = temp_repo();
704
705 fs::write(repo.root.join("new.go"), "package main").unwrap();
707 repo.git(&["add", "new.go"]).unwrap();
708
709 let snap_dir = repo.snapshot_working_tree().unwrap();
710
711 let manifest_path = snap_dir.join("manifest.json");
713 assert!(manifest_path.exists(), "manifest.json should exist");
714
715 let data = fs::read_to_string(&manifest_path).unwrap();
716 assert!(data.contains("new.go"), "manifest should list new.go");
717 assert!(
718 data.contains("\"staged\": true"),
719 "new.go should be marked staged"
720 );
721
722 assert!(
724 snap_dir.join("files/new.go").exists(),
725 "file content should be copied"
726 );
727 assert_eq!(
728 fs::read_to_string(snap_dir.join("files/new.go")).unwrap(),
729 "package main"
730 );
731
732 assert!(snap_dir.join("head_ref").exists());
734
735 repo.clear_snapshot();
736 }
737
738 #[test]
739 fn snapshot_restore_recovers_staged_new_files() {
740 let (_dir, repo) = temp_repo();
741
742 fs::write(repo.root.join("a.go"), "package a").unwrap();
744 fs::write(repo.root.join("b.go"), "package b").unwrap();
745 repo.git(&["add", "a.go", "b.go"]).unwrap();
746
747 repo.snapshot_working_tree().unwrap();
748
749 repo.reset_head().unwrap();
751 repo.git(&["add", "a.go"]).unwrap();
752 repo.git(&["commit", "-m", "partial"]).unwrap();
753
754 repo.restore_snapshot().unwrap();
756
757 assert!(repo.root.join("a.go").exists());
759 assert!(repo.root.join("b.go").exists());
760 assert_eq!(
761 fs::read_to_string(repo.root.join("a.go")).unwrap(),
762 "package a"
763 );
764 assert_eq!(
765 fs::read_to_string(repo.root.join("b.go")).unwrap(),
766 "package b"
767 );
768
769 let staged = repo.git(&["diff", "--cached", "--name-only"]).unwrap();
771 assert!(staged.contains("a.go"), "a.go should be re-staged");
772 assert!(staged.contains("b.go"), "b.go should be re-staged");
773
774 let log = repo.git(&["log", "--oneline"]).unwrap();
776 assert!(
777 !log.contains("partial"),
778 "partial commit should be undone by HEAD reset"
779 );
780
781 repo.clear_snapshot();
782 }
783
784 #[test]
785 fn snapshot_restore_with_dirty_index_does_not_conflict() {
786 let (_dir, repo) = temp_repo();
787
788 fs::write(repo.root.join("file.rs"), "fn main() {}").unwrap();
790 repo.git(&["add", "file.rs"]).unwrap();
791
792 repo.snapshot_working_tree().unwrap();
793
794 repo.reset_head().unwrap();
796 repo.git(&["add", "file.rs"]).unwrap();
797 let result = repo.restore_snapshot();
801 assert!(
802 result.is_ok(),
803 "restore should succeed with dirty index: {result:?}"
804 );
805
806 assert_eq!(
807 fs::read_to_string(repo.root.join("file.rs")).unwrap(),
808 "fn main() {}"
809 );
810
811 repo.clear_snapshot();
812 }
813
814 #[test]
815 fn snapshot_handles_modified_files() {
816 let (_dir, repo) = temp_repo();
817
818 fs::write(repo.root.join("init.txt"), "modified content").unwrap();
820 repo.git(&["add", "init.txt"]).unwrap();
821
822 repo.snapshot_working_tree().unwrap();
823
824 repo.reset_head().unwrap();
826 fs::write(repo.root.join("init.txt"), "wrong content").unwrap();
827
828 repo.restore_snapshot().unwrap();
830
831 assert_eq!(
832 fs::read_to_string(repo.root.join("init.txt")).unwrap(),
833 "modified content"
834 );
835
836 repo.clear_snapshot();
837 }
838
839 #[test]
840 fn snapshot_guard_restores_on_drop() {
841 let (_dir, repo) = temp_repo();
842
843 fs::write(repo.root.join("guarded.txt"), "important").unwrap();
844 repo.git(&["add", "guarded.txt"]).unwrap();
845
846 {
847 let _guard = SnapshotGuard::new(&repo).unwrap();
848 repo.reset_head().unwrap();
850 fs::remove_file(repo.root.join("guarded.txt")).ok();
851 }
853
854 assert!(repo.root.join("guarded.txt").exists());
856 assert_eq!(
857 fs::read_to_string(repo.root.join("guarded.txt")).unwrap(),
858 "important"
859 );
860 }
861
862 #[test]
863 fn snapshot_guard_clears_on_success() {
864 let (_dir, repo) = temp_repo();
865
866 fs::write(repo.root.join("ok.txt"), "data").unwrap();
867 repo.git(&["add", "ok.txt"]).unwrap();
868
869 let guard = SnapshotGuard::new(&repo).unwrap();
870 assert!(repo.has_snapshot());
871 guard.success();
872
873 assert!(!repo.has_snapshot());
875 }
876
877 #[test]
878 fn file_statuses_includes_both_sides_of_rename() {
879 let (_dir, repo) = temp_repo();
880
881 fs::write(repo.root.join("old_name.txt"), "content").unwrap();
883 repo.git(&["add", "old_name.txt"]).unwrap();
884 repo.git(&["commit", "-m", "add old_name"]).unwrap();
885
886 repo.git(&["mv", "old_name.txt", "new_name.txt"]).unwrap();
888
889 let statuses = repo.file_statuses().unwrap();
890
891 assert_eq!(
892 statuses.get("old_name.txt").copied(),
893 Some('D'),
894 "old path should appear as deleted"
895 );
896 assert_eq!(
897 statuses.get("new_name.txt").copied(),
898 Some('R'),
899 "new path should appear as renamed"
900 );
901 }
902
903 #[test]
908 fn stage_file_handles_many_moves_and_deletes_after_reset() {
909 let (_dir, repo) = temp_repo();
910
911 for i in 0..30 {
913 fs::write(
914 repo.root.join(format!("file_{i}.txt")),
915 format!("content {i}"),
916 )
917 .unwrap();
918 }
919 repo.git(&["add", "."]).unwrap();
920 repo.git(&["commit", "-m", "add files"]).unwrap();
921
922 fs::create_dir_all(repo.root.join("moved")).unwrap();
924 for i in 0..10 {
925 repo.git(&[
926 "mv",
927 &format!("file_{i}.txt"),
928 &format!("moved/file_{i}.txt"),
929 ])
930 .unwrap();
931 }
932
933 for i in 10..20 {
935 repo.git(&["rm", &format!("file_{i}.txt")]).unwrap();
936 }
937
938 for i in 20..30 {
940 fs::write(
941 repo.root.join(format!("file_{i}.txt")),
942 format!("modified {i}"),
943 )
944 .unwrap();
945 repo.git(&["add", &format!("file_{i}.txt")]).unwrap();
946 }
947
948 for i in 30..35 {
950 fs::write(repo.root.join(format!("new_{i}.txt")), format!("new {i}")).unwrap();
951 repo.git(&["add", &format!("new_{i}.txt")]).unwrap();
952 }
953
954 let statuses = repo.file_statuses().unwrap();
956 assert!(
957 statuses.len() >= 30,
958 "should have many file statuses, got {}",
959 statuses.len()
960 );
961
962 repo.reset_head().unwrap();
964
965 let mut failed = Vec::new();
967 for (file, status) in &statuses {
968 if file == "init.txt" {
969 continue;
970 }
971 let ok = repo.stage_file(file).unwrap();
972 if !ok {
973 failed.push((file.clone(), *status));
974 }
975 }
976
977 assert!(
978 failed.is_empty(),
979 "stage_file failed for {} files: {:?}",
980 failed.len(),
981 failed
982 );
983 }
984
985 #[test]
989 fn stage_file_handles_manual_moves_after_reset() {
990 let (_dir, repo) = temp_repo();
991
992 fs::create_dir_all(repo.root.join("old_dir")).unwrap();
994 for i in 0..10 {
995 fs::write(
996 repo.root.join(format!("old_dir/file_{i}.txt")),
997 format!("content {i}"),
998 )
999 .unwrap();
1000 }
1001 repo.git(&["add", "."]).unwrap();
1002 repo.git(&["commit", "-m", "add directory"]).unwrap();
1003
1004 fs::rename(repo.root.join("old_dir"), repo.root.join("new_dir")).unwrap();
1006
1007 repo.git(&["add", "-A"]).unwrap();
1009
1010 let statuses = repo.file_statuses().unwrap();
1012
1013 repo.reset_head().unwrap();
1015
1016 let mut failed = Vec::new();
1018 for (file, status) in &statuses {
1019 if file == "init.txt" {
1020 continue;
1021 }
1022 let ok = repo.stage_file(file).unwrap();
1023 if !ok {
1024 failed.push((file.clone(), *status));
1025 }
1026 }
1027
1028 assert!(
1029 failed.is_empty(),
1030 "stage_file failed for {} files after manual move: {:?}",
1031 failed.len(),
1032 failed
1033 );
1034 }
1035
1036 #[test]
1041 fn stage_file_handles_new_files_mixed_with_moves() {
1042 let (_dir, repo) = temp_repo();
1043
1044 for i in 0..5 {
1046 fs::write(
1047 repo.root.join(format!("existing_{i}.txt")),
1048 format!("existing {i}"),
1049 )
1050 .unwrap();
1051 }
1052 repo.git(&["add", "."]).unwrap();
1053 repo.git(&["commit", "-m", "add existing files"]).unwrap();
1054
1055 fs::create_dir_all(repo.root.join("moved")).unwrap();
1057 for i in 0..3 {
1058 repo.git(&[
1059 "mv",
1060 &format!("existing_{i}.txt"),
1061 &format!("moved/existing_{i}.txt"),
1062 ])
1063 .unwrap();
1064 }
1065
1066 repo.git(&["rm", "existing_3.txt"]).unwrap();
1068
1069 for i in 0..5 {
1071 fs::write(
1072 repo.root.join(format!("brand_new_{i}.txt")),
1073 format!("new {i}"),
1074 )
1075 .unwrap();
1076 }
1077 repo.git(&["add", "."]).unwrap();
1078
1079 let statuses = repo.file_statuses().unwrap();
1081
1082 repo.reset_head().unwrap();
1084
1085 let mut failed = Vec::new();
1087 for (file, status) in &statuses {
1088 if file == "init.txt" {
1089 continue;
1090 }
1091 let ok = repo.stage_file(file).unwrap();
1092 if !ok {
1093 failed.push((file.clone(), *status));
1094 }
1095 }
1096
1097 assert!(
1098 failed.is_empty(),
1099 "stage_file failed for {} files: {:?}",
1100 failed.len(),
1101 failed
1102 );
1103 }
1104
1105 #[test]
1110 fn stage_file_handles_quoted_paths_from_moves() {
1111 let (_dir, repo) = temp_repo();
1112
1113 fs::write(repo.root.join("old name.txt"), "content").unwrap();
1115 repo.git(&["add", "."]).unwrap();
1116 repo.git(&["commit", "-m", "add file with spaces"]).unwrap();
1117
1118 repo.git(&["mv", "old name.txt", "new name.txt"]).unwrap();
1120
1121 let statuses = repo.file_statuses().unwrap();
1123
1124 assert!(
1126 statuses.contains_key("old name.txt"),
1127 "old path should be unquoted; got keys: {:?}",
1128 statuses.keys().collect::<Vec<_>>()
1129 );
1130 assert!(
1131 statuses.contains_key("new name.txt"),
1132 "new path should be unquoted; got keys: {:?}",
1133 statuses.keys().collect::<Vec<_>>()
1134 );
1135
1136 repo.reset_head().unwrap();
1138
1139 let old_ok = repo.stage_file("old name.txt").unwrap();
1140 assert!(old_ok, "stage_file should succeed for old (deleted) path");
1141
1142 let new_ok = repo.stage_file("new name.txt").unwrap();
1143 assert!(new_ok, "stage_file should succeed for new (added) path");
1144 }
1145
1146 #[test]
1149 fn file_statuses_unquotes_paths_with_special_chars() {
1150 let (_dir, repo) = temp_repo();
1151
1152 fs::write(repo.root.join("my file.txt"), "content").unwrap();
1154 fs::write(repo.root.join("to delete.txt"), "delete me").unwrap();
1155 repo.git(&["add", "."]).unwrap();
1156 repo.git(&["commit", "-m", "add spaced files"]).unwrap();
1157
1158 fs::write(repo.root.join("my file.txt"), "modified").unwrap();
1160 repo.git(&["rm", "to delete.txt"]).unwrap();
1161 fs::write(repo.root.join("brand new file.txt"), "new").unwrap();
1162 repo.git(&["add", "."]).unwrap();
1163
1164 let statuses = repo.file_statuses().unwrap();
1165
1166 assert!(
1168 statuses.contains_key("my file.txt"),
1169 "modified file should be unquoted; keys: {:?}",
1170 statuses.keys().collect::<Vec<_>>()
1171 );
1172 assert!(
1173 statuses.contains_key("to delete.txt"),
1174 "deleted file should be unquoted; keys: {:?}",
1175 statuses.keys().collect::<Vec<_>>()
1176 );
1177 assert!(
1178 statuses.contains_key("brand new file.txt"),
1179 "new file should be unquoted; keys: {:?}",
1180 statuses.keys().collect::<Vec<_>>()
1181 );
1182 }
1183
1184 #[test]
1188 fn stage_file_works_across_sequential_commits_with_moves() {
1189 let (_dir, repo) = temp_repo();
1190
1191 for i in 0..10 {
1193 fs::write(
1194 repo.root.join(format!("src_{i}.txt")),
1195 format!("content {i}"),
1196 )
1197 .unwrap();
1198 }
1199 repo.git(&["add", "."]).unwrap();
1200 repo.git(&["commit", "-m", "add source files"]).unwrap();
1201
1202 fs::create_dir_all(repo.root.join("dst")).unwrap();
1204 for i in 0..10 {
1205 repo.git(&["mv", &format!("src_{i}.txt"), &format!("dst/src_{i}.txt")])
1206 .unwrap();
1207 }
1208
1209 let statuses = repo.file_statuses().unwrap();
1210 repo.reset_head().unwrap();
1211
1212 for i in 0..10 {
1214 let file = format!("dst/src_{i}.txt");
1215 let ok = repo.stage_file(&file).unwrap();
1216 assert!(ok, "should stage new path {file}");
1217 }
1218 repo.commit("feat: add new paths").unwrap();
1219
1220 let mut failed = Vec::new();
1223 for i in 0..10 {
1224 let file = format!("src_{i}.txt");
1225 if let Some(&status) = statuses.get(&file) {
1226 let ok = repo.stage_file(&file).unwrap();
1227 if !ok {
1228 failed.push((file, status));
1229 }
1230 }
1231 }
1232
1233 assert!(
1234 failed.is_empty(),
1235 "stage_file failed for old paths after prior commit: {:?}",
1236 failed
1237 );
1238 }
1239}