1use anyhow::{Context, Result, bail};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::process::Command;
5
6fn git_unquote(s: &str) -> String {
11 let s = s.trim();
12 if !(s.starts_with('"') && s.ends_with('"')) {
13 return s.to_string();
14 }
15 let inner = &s[1..s.len() - 1];
17 let mut out = Vec::new();
18 let bytes = inner.as_bytes();
19 let mut i = 0;
20 while i < bytes.len() {
21 if bytes[i] == b'\\' && i + 1 < bytes.len() {
22 i += 1;
23 match bytes[i] {
24 b'\\' => out.push(b'\\'),
25 b'"' => out.push(b'"'),
26 b'n' => out.push(b'\n'),
27 b't' => out.push(b'\t'),
28 b'r' => out.push(b'\r'),
29 b'a' => out.push(0x07),
30 b'b' => out.push(0x08),
31 b'f' => out.push(0x0C),
32 b'v' => out.push(0x0B),
33 b'0'..=b'3' => {
35 let mut val = (bytes[i] - b'0') as u16;
36 for _ in 0..2 {
37 if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
38 i += 1;
39 val = val * 8 + (bytes[i] - b'0') as u16;
40 } else {
41 break;
42 }
43 }
44 out.push(val as u8);
45 }
46 other => {
47 out.push(b'\\');
48 out.push(other);
49 }
50 }
51 } else {
52 out.push(bytes[i]);
53 }
54 i += 1;
55 }
56 String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).to_string())
57}
58
59pub struct GitRepo {
60 root: PathBuf,
61}
62
63#[allow(dead_code)]
64impl GitRepo {
65 pub fn discover() -> Result<Self> {
66 let output = Command::new("git")
67 .args(["rev-parse", "--show-toplevel"])
68 .output()
69 .context("failed to run git")?;
70
71 if !output.status.success() {
72 bail!(crate::error::SrAiError::NotAGitRepo);
73 }
74
75 let root = String::from_utf8(output.stdout)
76 .context("invalid utf-8 from git")?
77 .trim()
78 .into();
79
80 Ok(Self { root })
81 }
82
83 pub fn root(&self) -> &PathBuf {
84 &self.root
85 }
86
87 fn git(&self, args: &[&str]) -> Result<String> {
88 let output = Command::new("git")
89 .args(["-C", self.root.to_str().unwrap()])
90 .args(args)
91 .output()
92 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
93
94 if !output.status.success() {
95 let stderr = String::from_utf8_lossy(&output.stderr);
96 bail!(crate::error::SrAiError::GitCommand(format!(
97 "git {} failed: {}",
98 args.join(" "),
99 stderr.trim()
100 )));
101 }
102
103 Ok(String::from_utf8_lossy(&output.stdout).to_string())
104 }
105
106 fn git_allow_failure(&self, args: &[&str]) -> Result<(bool, String)> {
107 let output = Command::new("git")
108 .args(["-C", self.root.to_str().unwrap()])
109 .args(args)
110 .output()
111 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
112
113 Ok((
114 output.status.success(),
115 String::from_utf8_lossy(&output.stdout).to_string(),
116 ))
117 }
118
119 pub fn has_staged_changes(&self) -> Result<bool> {
120 let out = self.git(&["diff", "--cached", "--name-only"])?;
121 Ok(!out.trim().is_empty())
122 }
123
124 pub fn has_any_changes(&self) -> Result<bool> {
125 let out = self.git(&["status", "--porcelain"])?;
126 Ok(!out.trim().is_empty())
127 }
128
129 pub fn has_head(&self) -> Result<bool> {
130 let (ok, _) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
131 Ok(ok)
132 }
133
134 pub fn reset_head(&self) -> Result<()> {
135 if self.has_head()? {
136 self.git(&["reset", "HEAD", "--quiet"])?;
137 } else {
138 let _ = self.git_allow_failure(&["rm", "--cached", "-r", ".", "--quiet"]);
140 }
141 Ok(())
142 }
143
144 pub fn stage_file(&self, file: &str) -> Result<bool> {
145 let (ok, _) = self.git_allow_failure(&["add", "--", file])?;
153 Ok(ok)
154 }
155
156 pub fn has_staged_after_add(&self) -> Result<bool> {
157 self.has_staged_changes()
158 }
159
160 pub fn commit(&self, message: &str) -> Result<()> {
161 let output = Command::new("git")
162 .args(["-C", self.root.to_str().unwrap()])
163 .args(["commit", "-F", "-"])
164 .stdin(std::process::Stdio::piped())
165 .stdout(std::process::Stdio::piped())
166 .stderr(std::process::Stdio::piped())
167 .spawn()
168 .context("failed to spawn git commit")?;
169
170 use std::io::Write;
171 let mut child = output;
172 if let Some(mut stdin) = child.stdin.take() {
173 stdin.write_all(message.as_bytes())?;
174 }
175
176 let out = child.wait_with_output()?;
177 if !out.status.success() {
178 let stderr = String::from_utf8_lossy(&out.stderr);
179 bail!(crate::error::SrAiError::GitCommand(format!(
180 "git commit failed: {}",
181 stderr.trim()
182 )));
183 }
184
185 Ok(())
186 }
187
188 pub fn recent_commits(&self, count: usize) -> Result<String> {
189 self.git(&["--no-pager", "log", "--oneline", &format!("-{count}")])
190 }
191
192 pub fn diff_cached(&self) -> Result<String> {
193 self.git(&["diff", "--cached"])
194 }
195
196 pub fn diff_cached_stat(&self) -> Result<String> {
197 self.git(&["diff", "--cached", "--stat"])
198 }
199
200 pub fn diff_head(&self) -> Result<String> {
201 let (ok, out) = self.git_allow_failure(&["diff", "HEAD"])?;
202 if ok { Ok(out) } else { self.git(&["diff"]) }
203 }
204
205 pub fn status_porcelain(&self) -> Result<String> {
206 self.git(&["status", "--porcelain"])
207 }
208
209 pub fn untracked_files(&self) -> Result<String> {
210 self.git(&["ls-files", "--others", "--exclude-standard"])
211 }
212
213 pub fn show(&self, rev: &str) -> Result<String> {
214 self.git(&["show", rev])
215 }
216
217 pub fn log_range(&self, base: &str, count: Option<usize>) -> Result<String> {
218 let mut args = vec!["--no-pager", "log", "--oneline"];
219 let count_str;
220 if let Some(n) = count {
221 count_str = format!("-{n}");
222 args.push(&count_str);
223 }
224 args.push(base);
225 self.git(&args)
226 }
227
228 pub fn diff_range(&self, base: &str) -> Result<String> {
229 self.git(&["diff", base])
230 }
231
232 pub fn current_branch(&self) -> Result<String> {
233 let out = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
234 Ok(out.trim().to_string())
235 }
236
237 pub fn head_short(&self) -> Result<String> {
238 let out = self.git(&["rev-parse", "--short", "HEAD"])?;
239 Ok(out.trim().to_string())
240 }
241
242 pub fn commits_since_last_tag(&self) -> Result<usize> {
244 let (ok, tag) = self.git_allow_failure(&["describe", "--tags", "--abbrev=0"])?;
246 let tag = tag.trim();
247
248 let out = if ok && !tag.is_empty() {
249 self.git(&["rev-list", &format!("{tag}..HEAD"), "--count"])?
250 } else {
251 self.git(&["rev-list", "HEAD", "--count"])?
252 };
253
254 out.trim()
255 .parse::<usize>()
256 .context("failed to parse commit count")
257 }
258
259 pub fn log_detailed(&self, count: usize) -> Result<String> {
261 let out = self.git(&[
262 "--no-pager",
263 "log",
264 "--reverse",
265 &format!("-{count}"),
266 "--format=%h %s%n%b%n---",
267 ])?;
268 Ok(out)
269 }
270
271 pub fn file_statuses(&self) -> Result<HashMap<String, char>> {
272 let out = self.git(&["status", "--porcelain"])?;
273 let mut map = HashMap::new();
274 for line in out.lines() {
275 if line.len() < 3 {
276 continue;
277 }
278 let xy = &line.as_bytes()[..2];
279 let path = line[3..].to_string();
280 let (x, y) = (xy[0], xy[1]);
281 let is_rename = matches!((x, y), (b'R', _) | (_, b'R'));
282 if is_rename {
283 if let Some(pos) = path.find(" -> ") {
284 let old_path = git_unquote(&path[..pos]);
285 let new_path = git_unquote(&path[pos + 4..]);
286 map.insert(old_path, 'D');
287 map.insert(new_path, 'R');
288 } else {
289 map.insert(git_unquote(&path), 'R');
290 }
291 } else {
292 let status = match (x, y) {
293 (b'?', b'?') => 'A',
294 (b'A', _) | (_, b'A') => 'A',
295 (b'D', _) | (_, b'D') => 'D',
296 (b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => 'M',
297 _ => '~',
298 };
299 map.insert(git_unquote(&path), status);
300 }
301 }
302 Ok(map)
303 }
304
305 pub fn snapshot_working_tree(&self) -> Result<PathBuf> {
318 let snapshot_dir = snapshot_dir_for(&self.root)
319 .context("failed to resolve snapshot directory (no data directory available)")?;
320 if snapshot_dir.exists() {
322 std::fs::remove_dir_all(&snapshot_dir).ok();
323 }
324 std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot directory")?;
325
326 let files_dir = snapshot_dir.join("files");
327 std::fs::create_dir_all(&files_dir)?;
328
329 std::fs::write(
331 snapshot_dir.join("repo_root"),
332 self.root.to_string_lossy().as_bytes(),
333 )
334 .context("failed to write repo_root")?;
335
336 let (has_head, head_ref) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
338 if has_head {
339 std::fs::write(snapshot_dir.join("head_ref"), head_ref.trim())
340 .context("failed to write head_ref")?;
341 }
342
343 let porcelain = self.git(&["status", "--porcelain"])?;
346 let staged_names = self.git(&["diff", "--cached", "--name-only", "-z"])?;
347 let staged_set: std::collections::HashSet<String> = staged_names
348 .split('\0')
349 .map(|l| l.trim().to_string())
350 .filter(|l| !l.is_empty())
351 .collect();
352
353 #[derive(serde::Serialize, serde::Deserialize)]
354 struct ManifestEntry {
355 path: String,
356 index_status: char,
358 worktree_status: char,
360 staged: bool,
362 has_content: bool,
364 }
365
366 let mut manifest: Vec<ManifestEntry> = Vec::new();
367
368 for line in porcelain.lines() {
369 if line.len() < 3 {
370 continue;
371 }
372 let bytes = line.as_bytes();
373 let x = bytes[0] as char;
374 let y = bytes[1] as char;
375 let raw = line[3..].to_string();
376 let path = if let Some(pos) = raw.find(" -> ") {
378 git_unquote(&raw[pos + 4..])
379 } else {
380 git_unquote(&raw)
381 };
382
383 let src = self.root.join(&path);
384 let has_content = src.exists() && src.is_file();
385
386 if has_content {
387 let dest = files_dir.join(&path);
388 if let Some(parent) = dest.parent() {
389 std::fs::create_dir_all(parent).ok();
390 }
391 if let Err(e) = std::fs::copy(&src, &dest) {
392 eprintln!("warning: failed to snapshot {path}: {e}");
393 }
394 }
395
396 manifest.push(ManifestEntry {
397 staged: staged_set.contains(path.as_str()),
398 path,
399 index_status: x,
400 worktree_status: y,
401 has_content,
402 });
403 }
404
405 let manifest_json =
406 serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")?;
407 std::fs::write(snapshot_dir.join("manifest.json"), manifest_json)
408 .context("failed to write manifest.json")?;
409
410 let now = std::time::SystemTime::now()
412 .duration_since(std::time::UNIX_EPOCH)
413 .unwrap_or_default()
414 .as_secs();
415 std::fs::write(snapshot_dir.join("timestamp"), now.to_string())
416 .context("failed to write timestamp")?;
417
418 Ok(snapshot_dir)
419 }
420
421 pub fn restore_snapshot(&self) -> Result<()> {
431 let snapshot_dir = self.snapshot_dir()?;
432 if !snapshot_dir.join("timestamp").exists() {
433 bail!("no valid snapshot found");
434 }
435
436 let files_dir = snapshot_dir.join("files");
437
438 let head_ref_path = snapshot_dir.join("head_ref");
440 if head_ref_path.exists() {
441 let original_head = std::fs::read_to_string(&head_ref_path)?;
442 let original_head = original_head.trim();
443 if !original_head.is_empty() {
444 let _ = self.git_allow_failure(&["reset", "--soft", original_head]);
445 }
446 }
447
448 self.reset_head()?;
450
451 let manifest_path = snapshot_dir.join("manifest.json");
453 if !manifest_path.exists() {
454 bail!("snapshot manifest.json missing — cannot restore");
455 }
456
457 #[derive(serde::Deserialize)]
458 struct ManifestEntry {
459 path: String,
460 index_status: char,
461 worktree_status: char,
462 staged: bool,
463 has_content: bool,
464 }
465
466 let manifest_data = std::fs::read_to_string(&manifest_path)?;
467 let manifest: Vec<ManifestEntry> =
468 serde_json::from_str(&manifest_data).context("failed to parse snapshot manifest")?;
469
470 let mut restored = 0usize;
471 let mut failed = 0usize;
472
473 for entry in &manifest {
474 let dest = self.root.join(&entry.path);
475
476 if entry.has_content {
477 let src = files_dir.join(&entry.path);
479 if src.exists() {
480 if let Some(parent) = dest.parent() {
481 std::fs::create_dir_all(parent).ok();
482 }
483 match std::fs::copy(&src, &dest) {
484 Ok(_) => restored += 1,
485 Err(e) => {
486 eprintln!("warning: failed to restore {}: {e}", entry.path);
487 failed += 1;
488 }
489 }
490 } else {
491 eprintln!("warning: snapshot missing content for {}", entry.path);
492 failed += 1;
493 }
494 } else if entry.index_status == 'D' || entry.worktree_status == 'D' {
495 if dest.exists() {
497 std::fs::remove_file(&dest).ok();
498 }
499 }
500
501 if entry.staged {
503 let _ = self.git_allow_failure(&["add", "--", &entry.path]);
504 }
505 }
506
507 if failed > 0 {
508 eprintln!("sr: restored {restored} files, {failed} failed");
509 }
510
511 Ok(())
512 }
513
514 pub fn clear_snapshot(&self) {
516 if let Ok(dir) = self.snapshot_dir() {
517 let _ = std::fs::remove_dir_all(&dir);
518 }
519 }
520
521 pub fn snapshot_dir(&self) -> Result<PathBuf> {
523 snapshot_dir_for(&self.root)
524 .context("failed to resolve snapshot directory (no data directory available)")
525 }
526
527 pub fn has_snapshot(&self) -> bool {
529 self.snapshot_dir()
530 .map(|d| d.join("timestamp").exists())
531 .unwrap_or(false)
532 }
533}
534
535fn snapshot_dir_for(repo_root: &std::path::Path) -> Option<PathBuf> {
538 let base = dirs::data_local_dir()?;
539 let repo_id =
540 &crate::cache::fingerprint::sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
541 Some(base.join("sr").join("snapshots").join(repo_id))
542}
543
544pub struct SnapshotGuard<'a> {
547 repo: &'a GitRepo,
548 succeeded: bool,
549}
550
551impl<'a> SnapshotGuard<'a> {
552 pub fn new(repo: &'a GitRepo) -> Result<Self> {
554 repo.snapshot_working_tree()?;
555 Ok(Self {
556 repo,
557 succeeded: false,
558 })
559 }
560
561 pub fn success(mut self) {
563 self.succeeded = true;
564 self.repo.clear_snapshot();
565 }
566}
567
568impl Drop for SnapshotGuard<'_> {
569 fn drop(&mut self) {
570 if !self.succeeded && self.repo.has_snapshot() {
571 eprintln!("sr: operation failed, restoring working tree from snapshot...");
572 if let Err(e) = self.repo.restore_snapshot() {
573 eprintln!("sr: warning: snapshot restore failed: {e}");
574 if let Ok(dir) = self.repo.snapshot_dir() {
575 eprintln!(
576 "sr: snapshot preserved at {} for manual recovery",
577 dir.display()
578 );
579 }
580 } else {
581 self.repo.clear_snapshot();
582 }
583 }
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use std::fs;
591
592 fn temp_repo() -> (tempfile::TempDir, GitRepo) {
594 let dir = tempfile::tempdir().unwrap();
595 let root = dir.path().to_path_buf();
596
597 let git = |args: &[&str]| {
598 Command::new("git")
599 .args(["-C", root.to_str().unwrap()])
600 .args(args)
601 .output()
602 .unwrap()
603 };
604
605 git(&["init"]);
606 git(&["config", "user.email", "test@test.com"]);
607 git(&["config", "user.name", "Test"]);
608 fs::write(root.join("init.txt"), "init").unwrap();
610 git(&["add", "init.txt"]);
611 git(&["commit", "-m", "initial"]);
612
613 let repo = GitRepo { root };
614 (dir, repo)
615 }
616
617 #[test]
618 fn snapshot_creates_manifest_with_staged_files() {
619 let (_dir, repo) = temp_repo();
620
621 fs::write(repo.root.join("new.go"), "package main").unwrap();
623 repo.git(&["add", "new.go"]).unwrap();
624
625 let snap_dir = repo.snapshot_working_tree().unwrap();
626
627 let manifest_path = snap_dir.join("manifest.json");
629 assert!(manifest_path.exists(), "manifest.json should exist");
630
631 let data = fs::read_to_string(&manifest_path).unwrap();
632 assert!(data.contains("new.go"), "manifest should list new.go");
633 assert!(
634 data.contains("\"staged\": true"),
635 "new.go should be marked staged"
636 );
637
638 assert!(
640 snap_dir.join("files/new.go").exists(),
641 "file content should be copied"
642 );
643 assert_eq!(
644 fs::read_to_string(snap_dir.join("files/new.go")).unwrap(),
645 "package main"
646 );
647
648 assert!(snap_dir.join("head_ref").exists());
650
651 repo.clear_snapshot();
652 }
653
654 #[test]
655 fn snapshot_restore_recovers_staged_new_files() {
656 let (_dir, repo) = temp_repo();
657
658 fs::write(repo.root.join("a.go"), "package a").unwrap();
660 fs::write(repo.root.join("b.go"), "package b").unwrap();
661 repo.git(&["add", "a.go", "b.go"]).unwrap();
662
663 repo.snapshot_working_tree().unwrap();
664
665 repo.reset_head().unwrap();
667 repo.git(&["add", "a.go"]).unwrap();
668 repo.git(&["commit", "-m", "partial"]).unwrap();
669
670 repo.restore_snapshot().unwrap();
672
673 assert!(repo.root.join("a.go").exists());
675 assert!(repo.root.join("b.go").exists());
676 assert_eq!(
677 fs::read_to_string(repo.root.join("a.go")).unwrap(),
678 "package a"
679 );
680 assert_eq!(
681 fs::read_to_string(repo.root.join("b.go")).unwrap(),
682 "package b"
683 );
684
685 let staged = repo.git(&["diff", "--cached", "--name-only"]).unwrap();
687 assert!(staged.contains("a.go"), "a.go should be re-staged");
688 assert!(staged.contains("b.go"), "b.go should be re-staged");
689
690 let log = repo.git(&["log", "--oneline"]).unwrap();
692 assert!(
693 !log.contains("partial"),
694 "partial commit should be undone by HEAD reset"
695 );
696
697 repo.clear_snapshot();
698 }
699
700 #[test]
701 fn snapshot_restore_with_dirty_index_does_not_conflict() {
702 let (_dir, repo) = temp_repo();
703
704 fs::write(repo.root.join("file.rs"), "fn main() {}").unwrap();
706 repo.git(&["add", "file.rs"]).unwrap();
707
708 repo.snapshot_working_tree().unwrap();
709
710 repo.reset_head().unwrap();
712 repo.git(&["add", "file.rs"]).unwrap();
713 let result = repo.restore_snapshot();
717 assert!(
718 result.is_ok(),
719 "restore should succeed with dirty index: {result:?}"
720 );
721
722 assert_eq!(
723 fs::read_to_string(repo.root.join("file.rs")).unwrap(),
724 "fn main() {}"
725 );
726
727 repo.clear_snapshot();
728 }
729
730 #[test]
731 fn snapshot_handles_modified_files() {
732 let (_dir, repo) = temp_repo();
733
734 fs::write(repo.root.join("init.txt"), "modified content").unwrap();
736 repo.git(&["add", "init.txt"]).unwrap();
737
738 repo.snapshot_working_tree().unwrap();
739
740 repo.reset_head().unwrap();
742 fs::write(repo.root.join("init.txt"), "wrong content").unwrap();
743
744 repo.restore_snapshot().unwrap();
746
747 assert_eq!(
748 fs::read_to_string(repo.root.join("init.txt")).unwrap(),
749 "modified content"
750 );
751
752 repo.clear_snapshot();
753 }
754
755 #[test]
756 fn snapshot_guard_restores_on_drop() {
757 let (_dir, repo) = temp_repo();
758
759 fs::write(repo.root.join("guarded.txt"), "important").unwrap();
760 repo.git(&["add", "guarded.txt"]).unwrap();
761
762 {
763 let _guard = SnapshotGuard::new(&repo).unwrap();
764 repo.reset_head().unwrap();
766 fs::remove_file(repo.root.join("guarded.txt")).ok();
767 }
769
770 assert!(repo.root.join("guarded.txt").exists());
772 assert_eq!(
773 fs::read_to_string(repo.root.join("guarded.txt")).unwrap(),
774 "important"
775 );
776 }
777
778 #[test]
779 fn snapshot_guard_clears_on_success() {
780 let (_dir, repo) = temp_repo();
781
782 fs::write(repo.root.join("ok.txt"), "data").unwrap();
783 repo.git(&["add", "ok.txt"]).unwrap();
784
785 let guard = SnapshotGuard::new(&repo).unwrap();
786 assert!(repo.has_snapshot());
787 guard.success();
788
789 assert!(!repo.has_snapshot());
791 }
792
793 #[test]
794 fn file_statuses_includes_both_sides_of_rename() {
795 let (_dir, repo) = temp_repo();
796
797 fs::write(repo.root.join("old_name.txt"), "content").unwrap();
799 repo.git(&["add", "old_name.txt"]).unwrap();
800 repo.git(&["commit", "-m", "add old_name"]).unwrap();
801
802 repo.git(&["mv", "old_name.txt", "new_name.txt"]).unwrap();
804
805 let statuses = repo.file_statuses().unwrap();
806
807 assert_eq!(
808 statuses.get("old_name.txt").copied(),
809 Some('D'),
810 "old path should appear as deleted"
811 );
812 assert_eq!(
813 statuses.get("new_name.txt").copied(),
814 Some('R'),
815 "new path should appear as renamed"
816 );
817 }
818
819 #[test]
824 fn stage_file_handles_many_moves_and_deletes_after_reset() {
825 let (_dir, repo) = temp_repo();
826
827 for i in 0..30 {
829 fs::write(
830 repo.root.join(format!("file_{i}.txt")),
831 format!("content {i}"),
832 )
833 .unwrap();
834 }
835 repo.git(&["add", "."]).unwrap();
836 repo.git(&["commit", "-m", "add files"]).unwrap();
837
838 fs::create_dir_all(repo.root.join("moved")).unwrap();
840 for i in 0..10 {
841 repo.git(&[
842 "mv",
843 &format!("file_{i}.txt"),
844 &format!("moved/file_{i}.txt"),
845 ])
846 .unwrap();
847 }
848
849 for i in 10..20 {
851 repo.git(&["rm", &format!("file_{i}.txt")]).unwrap();
852 }
853
854 for i in 20..30 {
856 fs::write(
857 repo.root.join(format!("file_{i}.txt")),
858 format!("modified {i}"),
859 )
860 .unwrap();
861 repo.git(&["add", &format!("file_{i}.txt")]).unwrap();
862 }
863
864 for i in 30..35 {
866 fs::write(repo.root.join(format!("new_{i}.txt")), format!("new {i}")).unwrap();
867 repo.git(&["add", &format!("new_{i}.txt")]).unwrap();
868 }
869
870 let statuses = repo.file_statuses().unwrap();
872 assert!(
873 statuses.len() >= 30,
874 "should have many file statuses, got {}",
875 statuses.len()
876 );
877
878 repo.reset_head().unwrap();
880
881 let mut failed = Vec::new();
883 for (file, status) in &statuses {
884 if file == "init.txt" {
885 continue;
886 }
887 let ok = repo.stage_file(file).unwrap();
888 if !ok {
889 failed.push((file.clone(), *status));
890 }
891 }
892
893 assert!(
894 failed.is_empty(),
895 "stage_file failed for {} files: {:?}",
896 failed.len(),
897 failed
898 );
899 }
900
901 #[test]
905 fn stage_file_handles_manual_moves_after_reset() {
906 let (_dir, repo) = temp_repo();
907
908 fs::create_dir_all(repo.root.join("old_dir")).unwrap();
910 for i in 0..10 {
911 fs::write(
912 repo.root.join(format!("old_dir/file_{i}.txt")),
913 format!("content {i}"),
914 )
915 .unwrap();
916 }
917 repo.git(&["add", "."]).unwrap();
918 repo.git(&["commit", "-m", "add directory"]).unwrap();
919
920 fs::rename(repo.root.join("old_dir"), repo.root.join("new_dir")).unwrap();
922
923 repo.git(&["add", "-A"]).unwrap();
925
926 let statuses = repo.file_statuses().unwrap();
928
929 repo.reset_head().unwrap();
931
932 let mut failed = Vec::new();
934 for (file, status) in &statuses {
935 if file == "init.txt" {
936 continue;
937 }
938 let ok = repo.stage_file(file).unwrap();
939 if !ok {
940 failed.push((file.clone(), *status));
941 }
942 }
943
944 assert!(
945 failed.is_empty(),
946 "stage_file failed for {} files after manual move: {:?}",
947 failed.len(),
948 failed
949 );
950 }
951
952 #[test]
957 fn stage_file_handles_new_files_mixed_with_moves() {
958 let (_dir, repo) = temp_repo();
959
960 for i in 0..5 {
962 fs::write(
963 repo.root.join(format!("existing_{i}.txt")),
964 format!("existing {i}"),
965 )
966 .unwrap();
967 }
968 repo.git(&["add", "."]).unwrap();
969 repo.git(&["commit", "-m", "add existing files"]).unwrap();
970
971 fs::create_dir_all(repo.root.join("moved")).unwrap();
973 for i in 0..3 {
974 repo.git(&[
975 "mv",
976 &format!("existing_{i}.txt"),
977 &format!("moved/existing_{i}.txt"),
978 ])
979 .unwrap();
980 }
981
982 repo.git(&["rm", "existing_3.txt"]).unwrap();
984
985 for i in 0..5 {
987 fs::write(
988 repo.root.join(format!("brand_new_{i}.txt")),
989 format!("new {i}"),
990 )
991 .unwrap();
992 }
993 repo.git(&["add", "."]).unwrap();
994
995 let statuses = repo.file_statuses().unwrap();
997
998 repo.reset_head().unwrap();
1000
1001 let mut failed = Vec::new();
1003 for (file, status) in &statuses {
1004 if file == "init.txt" {
1005 continue;
1006 }
1007 let ok = repo.stage_file(file).unwrap();
1008 if !ok {
1009 failed.push((file.clone(), *status));
1010 }
1011 }
1012
1013 assert!(
1014 failed.is_empty(),
1015 "stage_file failed for {} files: {:?}",
1016 failed.len(),
1017 failed
1018 );
1019 }
1020
1021 #[test]
1026 fn stage_file_handles_quoted_paths_from_moves() {
1027 let (_dir, repo) = temp_repo();
1028
1029 fs::write(repo.root.join("old name.txt"), "content").unwrap();
1031 repo.git(&["add", "."]).unwrap();
1032 repo.git(&["commit", "-m", "add file with spaces"]).unwrap();
1033
1034 repo.git(&["mv", "old name.txt", "new name.txt"]).unwrap();
1036
1037 let statuses = repo.file_statuses().unwrap();
1039
1040 assert!(
1042 statuses.contains_key("old name.txt"),
1043 "old path should be unquoted; got keys: {:?}",
1044 statuses.keys().collect::<Vec<_>>()
1045 );
1046 assert!(
1047 statuses.contains_key("new name.txt"),
1048 "new path should be unquoted; got keys: {:?}",
1049 statuses.keys().collect::<Vec<_>>()
1050 );
1051
1052 repo.reset_head().unwrap();
1054
1055 let old_ok = repo.stage_file("old name.txt").unwrap();
1056 assert!(old_ok, "stage_file should succeed for old (deleted) path");
1057
1058 let new_ok = repo.stage_file("new name.txt").unwrap();
1059 assert!(new_ok, "stage_file should succeed for new (added) path");
1060 }
1061
1062 #[test]
1065 fn file_statuses_unquotes_paths_with_special_chars() {
1066 let (_dir, repo) = temp_repo();
1067
1068 fs::write(repo.root.join("my file.txt"), "content").unwrap();
1070 fs::write(repo.root.join("to delete.txt"), "delete me").unwrap();
1071 repo.git(&["add", "."]).unwrap();
1072 repo.git(&["commit", "-m", "add spaced files"]).unwrap();
1073
1074 fs::write(repo.root.join("my file.txt"), "modified").unwrap();
1076 repo.git(&["rm", "to delete.txt"]).unwrap();
1077 fs::write(repo.root.join("brand new file.txt"), "new").unwrap();
1078 repo.git(&["add", "."]).unwrap();
1079
1080 let statuses = repo.file_statuses().unwrap();
1081
1082 assert!(
1084 statuses.contains_key("my file.txt"),
1085 "modified file should be unquoted; keys: {:?}",
1086 statuses.keys().collect::<Vec<_>>()
1087 );
1088 assert!(
1089 statuses.contains_key("to delete.txt"),
1090 "deleted file should be unquoted; keys: {:?}",
1091 statuses.keys().collect::<Vec<_>>()
1092 );
1093 assert!(
1094 statuses.contains_key("brand new file.txt"),
1095 "new file should be unquoted; keys: {:?}",
1096 statuses.keys().collect::<Vec<_>>()
1097 );
1098 }
1099
1100 #[test]
1104 fn stage_file_works_across_sequential_commits_with_moves() {
1105 let (_dir, repo) = temp_repo();
1106
1107 for i in 0..10 {
1109 fs::write(
1110 repo.root.join(format!("src_{i}.txt")),
1111 format!("content {i}"),
1112 )
1113 .unwrap();
1114 }
1115 repo.git(&["add", "."]).unwrap();
1116 repo.git(&["commit", "-m", "add source files"]).unwrap();
1117
1118 fs::create_dir_all(repo.root.join("dst")).unwrap();
1120 for i in 0..10 {
1121 repo.git(&["mv", &format!("src_{i}.txt"), &format!("dst/src_{i}.txt")])
1122 .unwrap();
1123 }
1124
1125 let statuses = repo.file_statuses().unwrap();
1126 repo.reset_head().unwrap();
1127
1128 for i in 0..10 {
1130 let file = format!("dst/src_{i}.txt");
1131 let ok = repo.stage_file(&file).unwrap();
1132 assert!(ok, "should stage new path {file}");
1133 }
1134 repo.commit("feat: add new paths").unwrap();
1135
1136 let mut failed = Vec::new();
1139 for i in 0..10 {
1140 let file = format!("src_{i}.txt");
1141 if let Some(&status) = statuses.get(&file) {
1142 let ok = repo.stage_file(&file).unwrap();
1143 if !ok {
1144 failed.push((file, status));
1145 }
1146 }
1147 }
1148
1149 assert!(
1150 failed.is_empty(),
1151 "stage_file failed for old paths after prior commit: {:?}",
1152 failed
1153 );
1154 }
1155}