1use anyhow::{Context, Result, bail};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::process::Command;
5
6pub struct GitRepo {
7 root: PathBuf,
8}
9
10#[allow(dead_code)]
11impl GitRepo {
12 pub fn discover() -> Result<Self> {
13 let output = Command::new("git")
14 .args(["rev-parse", "--show-toplevel"])
15 .output()
16 .context("failed to run git")?;
17
18 if !output.status.success() {
19 bail!(crate::error::SrAiError::NotAGitRepo);
20 }
21
22 let root = String::from_utf8(output.stdout)
23 .context("invalid utf-8 from git")?
24 .trim()
25 .into();
26
27 Ok(Self { root })
28 }
29
30 pub fn root(&self) -> &PathBuf {
31 &self.root
32 }
33
34 fn git(&self, args: &[&str]) -> Result<String> {
35 let output = Command::new("git")
36 .args(["-C", self.root.to_str().unwrap()])
37 .args(args)
38 .output()
39 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
40
41 if !output.status.success() {
42 let stderr = String::from_utf8_lossy(&output.stderr);
43 bail!(crate::error::SrAiError::GitCommand(format!(
44 "git {} failed: {}",
45 args.join(" "),
46 stderr.trim()
47 )));
48 }
49
50 Ok(String::from_utf8_lossy(&output.stdout).to_string())
51 }
52
53 fn git_allow_failure(&self, args: &[&str]) -> Result<(bool, String)> {
54 let output = Command::new("git")
55 .args(["-C", self.root.to_str().unwrap()])
56 .args(args)
57 .output()
58 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
59
60 Ok((
61 output.status.success(),
62 String::from_utf8_lossy(&output.stdout).to_string(),
63 ))
64 }
65
66 pub fn has_staged_changes(&self) -> Result<bool> {
67 let out = self.git(&["diff", "--cached", "--name-only"])?;
68 Ok(!out.trim().is_empty())
69 }
70
71 pub fn has_any_changes(&self) -> Result<bool> {
72 let out = self.git(&["status", "--porcelain"])?;
73 Ok(!out.trim().is_empty())
74 }
75
76 pub fn has_head(&self) -> Result<bool> {
77 let (ok, _) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
78 Ok(ok)
79 }
80
81 pub fn reset_head(&self) -> Result<()> {
82 if self.has_head()? {
83 self.git(&["reset", "HEAD", "--quiet"])?;
84 } else {
85 let _ = self.git_allow_failure(&["rm", "--cached", "-r", ".", "--quiet"]);
87 }
88 Ok(())
89 }
90
91 pub fn stage_file(&self, file: &str) -> Result<bool> {
92 let full_path = self.root.join(file);
93 let exists = full_path.exists();
94
95 if !exists {
96 let out = self.git(&["ls-files", "--deleted"])?;
98 let is_deleted = out.lines().any(|l| l.trim() == file);
99 if !is_deleted {
100 return Ok(false);
101 }
102 }
103
104 let (ok, _) = self.git_allow_failure(&["add", "--", file])?;
105 Ok(ok)
106 }
107
108 pub fn has_staged_after_add(&self) -> Result<bool> {
109 self.has_staged_changes()
110 }
111
112 pub fn commit(&self, message: &str) -> Result<()> {
113 let output = Command::new("git")
114 .args(["-C", self.root.to_str().unwrap()])
115 .args(["commit", "-F", "-"])
116 .stdin(std::process::Stdio::piped())
117 .stdout(std::process::Stdio::piped())
118 .stderr(std::process::Stdio::piped())
119 .spawn()
120 .context("failed to spawn git commit")?;
121
122 use std::io::Write;
123 let mut child = output;
124 if let Some(mut stdin) = child.stdin.take() {
125 stdin.write_all(message.as_bytes())?;
126 }
127
128 let out = child.wait_with_output()?;
129 if !out.status.success() {
130 let stderr = String::from_utf8_lossy(&out.stderr);
131 bail!(crate::error::SrAiError::GitCommand(format!(
132 "git commit failed: {}",
133 stderr.trim()
134 )));
135 }
136
137 Ok(())
138 }
139
140 pub fn recent_commits(&self, count: usize) -> Result<String> {
141 self.git(&["--no-pager", "log", "--oneline", &format!("-{count}")])
142 }
143
144 pub fn diff_cached(&self) -> Result<String> {
145 self.git(&["diff", "--cached"])
146 }
147
148 pub fn diff_cached_stat(&self) -> Result<String> {
149 self.git(&["diff", "--cached", "--stat"])
150 }
151
152 pub fn diff_head(&self) -> Result<String> {
153 let (ok, out) = self.git_allow_failure(&["diff", "HEAD"])?;
154 if ok { Ok(out) } else { self.git(&["diff"]) }
155 }
156
157 pub fn status_porcelain(&self) -> Result<String> {
158 self.git(&["status", "--porcelain"])
159 }
160
161 pub fn untracked_files(&self) -> Result<String> {
162 self.git(&["ls-files", "--others", "--exclude-standard"])
163 }
164
165 pub fn show(&self, rev: &str) -> Result<String> {
166 self.git(&["show", rev])
167 }
168
169 pub fn log_range(&self, base: &str, count: Option<usize>) -> Result<String> {
170 let mut args = vec!["--no-pager", "log", "--oneline"];
171 let count_str;
172 if let Some(n) = count {
173 count_str = format!("-{n}");
174 args.push(&count_str);
175 }
176 args.push(base);
177 self.git(&args)
178 }
179
180 pub fn diff_range(&self, base: &str) -> Result<String> {
181 self.git(&["diff", base])
182 }
183
184 pub fn current_branch(&self) -> Result<String> {
185 let out = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
186 Ok(out.trim().to_string())
187 }
188
189 pub fn head_short(&self) -> Result<String> {
190 let out = self.git(&["rev-parse", "--short", "HEAD"])?;
191 Ok(out.trim().to_string())
192 }
193
194 pub fn commits_since_last_tag(&self) -> Result<usize> {
196 let (ok, tag) = self.git_allow_failure(&["describe", "--tags", "--abbrev=0"])?;
198 let tag = tag.trim();
199
200 let out = if ok && !tag.is_empty() {
201 self.git(&["rev-list", &format!("{tag}..HEAD"), "--count"])?
202 } else {
203 self.git(&["rev-list", "HEAD", "--count"])?
204 };
205
206 out.trim()
207 .parse::<usize>()
208 .context("failed to parse commit count")
209 }
210
211 pub fn log_detailed(&self, count: usize) -> Result<String> {
213 let out = self.git(&[
214 "--no-pager",
215 "log",
216 "--reverse",
217 &format!("-{count}"),
218 "--format=%h %s%n%b%n---",
219 ])?;
220 Ok(out)
221 }
222
223 pub fn file_statuses(&self) -> Result<HashMap<String, char>> {
224 let out = self.git(&["status", "--porcelain"])?;
225 let mut map = HashMap::new();
226 for line in out.lines() {
227 if line.len() < 3 {
228 continue;
229 }
230 let xy = &line.as_bytes()[..2];
231 let path = line[3..].to_string();
232 let (x, y) = (xy[0], xy[1]);
233 let is_rename = matches!((x, y), (b'R', _) | (_, b'R'));
234 if is_rename {
235 if let Some(pos) = path.find(" -> ") {
236 let old_path = path[..pos].to_string();
237 let new_path = path[pos + 4..].to_string();
238 map.insert(old_path, 'D');
239 map.insert(new_path, 'R');
240 } else {
241 map.insert(path, 'R');
242 }
243 } else {
244 let status = match (x, y) {
245 (b'?', b'?') => 'A',
246 (b'A', _) | (_, b'A') => 'A',
247 (b'D', _) | (_, b'D') => 'D',
248 (b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => 'M',
249 _ => '~',
250 };
251 map.insert(path, status);
252 }
253 }
254 Ok(map)
255 }
256
257 pub fn snapshot_working_tree(&self) -> Result<PathBuf> {
270 let snapshot_dir = snapshot_dir_for(&self.root)
271 .context("failed to resolve snapshot directory (no data directory available)")?;
272 if snapshot_dir.exists() {
274 std::fs::remove_dir_all(&snapshot_dir).ok();
275 }
276 std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot directory")?;
277
278 let files_dir = snapshot_dir.join("files");
279 std::fs::create_dir_all(&files_dir)?;
280
281 std::fs::write(
283 snapshot_dir.join("repo_root"),
284 self.root.to_string_lossy().as_bytes(),
285 )
286 .context("failed to write repo_root")?;
287
288 let (has_head, head_ref) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
290 if has_head {
291 std::fs::write(snapshot_dir.join("head_ref"), head_ref.trim())
292 .context("failed to write head_ref")?;
293 }
294
295 let porcelain = self.git(&["status", "--porcelain"])?;
298 let staged_names = self.git(&["diff", "--cached", "--name-only"])?;
299 let staged_set: std::collections::HashSet<&str> = staged_names
300 .lines()
301 .map(|l| l.trim())
302 .filter(|l| !l.is_empty())
303 .collect();
304
305 #[derive(serde::Serialize, serde::Deserialize)]
306 struct ManifestEntry {
307 path: String,
308 index_status: char,
310 worktree_status: char,
312 staged: bool,
314 has_content: bool,
316 }
317
318 let mut manifest: Vec<ManifestEntry> = Vec::new();
319
320 for line in porcelain.lines() {
321 if line.len() < 3 {
322 continue;
323 }
324 let bytes = line.as_bytes();
325 let x = bytes[0] as char;
326 let y = bytes[1] as char;
327 let mut path = line[3..].to_string();
328 if let Some(pos) = path.find(" -> ") {
330 path = path[pos + 4..].to_string();
331 }
332
333 let src = self.root.join(&path);
334 let has_content = src.exists() && src.is_file();
335
336 if has_content {
337 let dest = files_dir.join(&path);
338 if let Some(parent) = dest.parent() {
339 std::fs::create_dir_all(parent).ok();
340 }
341 if let Err(e) = std::fs::copy(&src, &dest) {
342 eprintln!("warning: failed to snapshot {path}: {e}");
343 }
344 }
345
346 manifest.push(ManifestEntry {
347 staged: staged_set.contains(path.as_str()),
348 path,
349 index_status: x,
350 worktree_status: y,
351 has_content,
352 });
353 }
354
355 let manifest_json =
356 serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")?;
357 std::fs::write(snapshot_dir.join("manifest.json"), manifest_json)
358 .context("failed to write manifest.json")?;
359
360 let now = std::time::SystemTime::now()
362 .duration_since(std::time::UNIX_EPOCH)
363 .unwrap_or_default()
364 .as_secs();
365 std::fs::write(snapshot_dir.join("timestamp"), now.to_string())
366 .context("failed to write timestamp")?;
367
368 Ok(snapshot_dir)
369 }
370
371 pub fn restore_snapshot(&self) -> Result<()> {
381 let snapshot_dir = self.snapshot_dir()?;
382 if !snapshot_dir.join("timestamp").exists() {
383 bail!("no valid snapshot found");
384 }
385
386 let files_dir = snapshot_dir.join("files");
387
388 let head_ref_path = snapshot_dir.join("head_ref");
390 if head_ref_path.exists() {
391 let original_head = std::fs::read_to_string(&head_ref_path)?;
392 let original_head = original_head.trim();
393 if !original_head.is_empty() {
394 let _ = self.git_allow_failure(&["reset", "--soft", original_head]);
395 }
396 }
397
398 self.reset_head()?;
400
401 let manifest_path = snapshot_dir.join("manifest.json");
403 if !manifest_path.exists() {
404 bail!("snapshot manifest.json missing — cannot restore");
405 }
406
407 #[derive(serde::Deserialize)]
408 struct ManifestEntry {
409 path: String,
410 index_status: char,
411 worktree_status: char,
412 staged: bool,
413 has_content: bool,
414 }
415
416 let manifest_data = std::fs::read_to_string(&manifest_path)?;
417 let manifest: Vec<ManifestEntry> =
418 serde_json::from_str(&manifest_data).context("failed to parse snapshot manifest")?;
419
420 let mut restored = 0usize;
421 let mut failed = 0usize;
422
423 for entry in &manifest {
424 let dest = self.root.join(&entry.path);
425
426 if entry.has_content {
427 let src = files_dir.join(&entry.path);
429 if src.exists() {
430 if let Some(parent) = dest.parent() {
431 std::fs::create_dir_all(parent).ok();
432 }
433 match std::fs::copy(&src, &dest) {
434 Ok(_) => restored += 1,
435 Err(e) => {
436 eprintln!("warning: failed to restore {}: {e}", entry.path);
437 failed += 1;
438 }
439 }
440 } else {
441 eprintln!("warning: snapshot missing content for {}", entry.path);
442 failed += 1;
443 }
444 } else if entry.index_status == 'D' || entry.worktree_status == 'D' {
445 if dest.exists() {
447 std::fs::remove_file(&dest).ok();
448 }
449 }
450
451 if entry.staged {
453 let _ = self.git_allow_failure(&["add", "--", &entry.path]);
454 }
455 }
456
457 if failed > 0 {
458 eprintln!("sr: restored {restored} files, {failed} failed");
459 }
460
461 Ok(())
462 }
463
464 pub fn clear_snapshot(&self) {
466 if let Ok(dir) = self.snapshot_dir() {
467 let _ = std::fs::remove_dir_all(&dir);
468 }
469 }
470
471 pub fn snapshot_dir(&self) -> Result<PathBuf> {
473 snapshot_dir_for(&self.root)
474 .context("failed to resolve snapshot directory (no data directory available)")
475 }
476
477 pub fn has_snapshot(&self) -> bool {
479 self.snapshot_dir()
480 .map(|d| d.join("timestamp").exists())
481 .unwrap_or(false)
482 }
483}
484
485fn snapshot_dir_for(repo_root: &std::path::Path) -> Option<PathBuf> {
488 let base = dirs::data_local_dir()?;
489 let repo_id =
490 &crate::cache::fingerprint::sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
491 Some(base.join("sr").join("snapshots").join(repo_id))
492}
493
494pub struct SnapshotGuard<'a> {
497 repo: &'a GitRepo,
498 succeeded: bool,
499}
500
501impl<'a> SnapshotGuard<'a> {
502 pub fn new(repo: &'a GitRepo) -> Result<Self> {
504 repo.snapshot_working_tree()?;
505 Ok(Self {
506 repo,
507 succeeded: false,
508 })
509 }
510
511 pub fn success(mut self) {
513 self.succeeded = true;
514 self.repo.clear_snapshot();
515 }
516}
517
518impl Drop for SnapshotGuard<'_> {
519 fn drop(&mut self) {
520 if !self.succeeded && self.repo.has_snapshot() {
521 eprintln!("sr: operation failed, restoring working tree from snapshot...");
522 if let Err(e) = self.repo.restore_snapshot() {
523 eprintln!("sr: warning: snapshot restore failed: {e}");
524 if let Ok(dir) = self.repo.snapshot_dir() {
525 eprintln!(
526 "sr: snapshot preserved at {} for manual recovery",
527 dir.display()
528 );
529 }
530 } else {
531 self.repo.clear_snapshot();
532 }
533 }
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use std::fs;
541
542 fn temp_repo() -> (tempfile::TempDir, GitRepo) {
544 let dir = tempfile::tempdir().unwrap();
545 let root = dir.path().to_path_buf();
546
547 let git = |args: &[&str]| {
548 Command::new("git")
549 .args(["-C", root.to_str().unwrap()])
550 .args(args)
551 .output()
552 .unwrap()
553 };
554
555 git(&["init"]);
556 git(&["config", "user.email", "test@test.com"]);
557 git(&["config", "user.name", "Test"]);
558 fs::write(root.join("init.txt"), "init").unwrap();
560 git(&["add", "init.txt"]);
561 git(&["commit", "-m", "initial"]);
562
563 let repo = GitRepo { root };
564 (dir, repo)
565 }
566
567 #[test]
568 fn snapshot_creates_manifest_with_staged_files() {
569 let (_dir, repo) = temp_repo();
570
571 fs::write(repo.root.join("new.go"), "package main").unwrap();
573 repo.git(&["add", "new.go"]).unwrap();
574
575 let snap_dir = repo.snapshot_working_tree().unwrap();
576
577 let manifest_path = snap_dir.join("manifest.json");
579 assert!(manifest_path.exists(), "manifest.json should exist");
580
581 let data = fs::read_to_string(&manifest_path).unwrap();
582 assert!(data.contains("new.go"), "manifest should list new.go");
583 assert!(
584 data.contains("\"staged\": true"),
585 "new.go should be marked staged"
586 );
587
588 assert!(
590 snap_dir.join("files/new.go").exists(),
591 "file content should be copied"
592 );
593 assert_eq!(
594 fs::read_to_string(snap_dir.join("files/new.go")).unwrap(),
595 "package main"
596 );
597
598 assert!(snap_dir.join("head_ref").exists());
600
601 repo.clear_snapshot();
602 }
603
604 #[test]
605 fn snapshot_restore_recovers_staged_new_files() {
606 let (_dir, repo) = temp_repo();
607
608 fs::write(repo.root.join("a.go"), "package a").unwrap();
610 fs::write(repo.root.join("b.go"), "package b").unwrap();
611 repo.git(&["add", "a.go", "b.go"]).unwrap();
612
613 repo.snapshot_working_tree().unwrap();
614
615 repo.reset_head().unwrap();
617 repo.git(&["add", "a.go"]).unwrap();
618 repo.git(&["commit", "-m", "partial"]).unwrap();
619
620 repo.restore_snapshot().unwrap();
622
623 assert!(repo.root.join("a.go").exists());
625 assert!(repo.root.join("b.go").exists());
626 assert_eq!(
627 fs::read_to_string(repo.root.join("a.go")).unwrap(),
628 "package a"
629 );
630 assert_eq!(
631 fs::read_to_string(repo.root.join("b.go")).unwrap(),
632 "package b"
633 );
634
635 let staged = repo.git(&["diff", "--cached", "--name-only"]).unwrap();
637 assert!(staged.contains("a.go"), "a.go should be re-staged");
638 assert!(staged.contains("b.go"), "b.go should be re-staged");
639
640 let log = repo.git(&["log", "--oneline"]).unwrap();
642 assert!(
643 !log.contains("partial"),
644 "partial commit should be undone by HEAD reset"
645 );
646
647 repo.clear_snapshot();
648 }
649
650 #[test]
651 fn snapshot_restore_with_dirty_index_does_not_conflict() {
652 let (_dir, repo) = temp_repo();
653
654 fs::write(repo.root.join("file.rs"), "fn main() {}").unwrap();
656 repo.git(&["add", "file.rs"]).unwrap();
657
658 repo.snapshot_working_tree().unwrap();
659
660 repo.reset_head().unwrap();
662 repo.git(&["add", "file.rs"]).unwrap();
663 let result = repo.restore_snapshot();
667 assert!(
668 result.is_ok(),
669 "restore should succeed with dirty index: {result:?}"
670 );
671
672 assert_eq!(
673 fs::read_to_string(repo.root.join("file.rs")).unwrap(),
674 "fn main() {}"
675 );
676
677 repo.clear_snapshot();
678 }
679
680 #[test]
681 fn snapshot_handles_modified_files() {
682 let (_dir, repo) = temp_repo();
683
684 fs::write(repo.root.join("init.txt"), "modified content").unwrap();
686 repo.git(&["add", "init.txt"]).unwrap();
687
688 repo.snapshot_working_tree().unwrap();
689
690 repo.reset_head().unwrap();
692 fs::write(repo.root.join("init.txt"), "wrong content").unwrap();
693
694 repo.restore_snapshot().unwrap();
696
697 assert_eq!(
698 fs::read_to_string(repo.root.join("init.txt")).unwrap(),
699 "modified content"
700 );
701
702 repo.clear_snapshot();
703 }
704
705 #[test]
706 fn snapshot_guard_restores_on_drop() {
707 let (_dir, repo) = temp_repo();
708
709 fs::write(repo.root.join("guarded.txt"), "important").unwrap();
710 repo.git(&["add", "guarded.txt"]).unwrap();
711
712 {
713 let _guard = SnapshotGuard::new(&repo).unwrap();
714 repo.reset_head().unwrap();
716 fs::remove_file(repo.root.join("guarded.txt")).ok();
717 }
719
720 assert!(repo.root.join("guarded.txt").exists());
722 assert_eq!(
723 fs::read_to_string(repo.root.join("guarded.txt")).unwrap(),
724 "important"
725 );
726 }
727
728 #[test]
729 fn snapshot_guard_clears_on_success() {
730 let (_dir, repo) = temp_repo();
731
732 fs::write(repo.root.join("ok.txt"), "data").unwrap();
733 repo.git(&["add", "ok.txt"]).unwrap();
734
735 let guard = SnapshotGuard::new(&repo).unwrap();
736 assert!(repo.has_snapshot());
737 guard.success();
738
739 assert!(!repo.has_snapshot());
741 }
742
743 #[test]
744 fn file_statuses_includes_both_sides_of_rename() {
745 let (_dir, repo) = temp_repo();
746
747 fs::write(repo.root.join("old_name.txt"), "content").unwrap();
749 repo.git(&["add", "old_name.txt"]).unwrap();
750 repo.git(&["commit", "-m", "add old_name"]).unwrap();
751
752 repo.git(&["mv", "old_name.txt", "new_name.txt"]).unwrap();
754
755 let statuses = repo.file_statuses().unwrap();
756
757 assert_eq!(
758 statuses.get("old_name.txt").copied(),
759 Some('D'),
760 "old path should appear as deleted"
761 );
762 assert_eq!(
763 statuses.get("new_name.txt").copied(),
764 Some('R'),
765 "new path should appear as renamed"
766 );
767 }
768}