1use std::io;
7use std::path::Path;
8use std::process::Command;
9
10#[derive(Debug, Clone)]
12pub struct AutoCommitResult {
13 pub committed: bool,
15
16 pub commit_sha: Option<String>,
18
19 pub files_staged: usize,
21}
22
23impl AutoCommitResult {
24 pub fn no_commit() -> Self {
26 Self {
27 committed: false,
28 commit_sha: None,
29 files_staged: 0,
30 }
31 }
32}
33
34#[derive(Debug, thiserror::Error)]
36pub enum GitOpsError {
37 #[error("IO error: {0}")]
39 Io(#[from] io::Error),
40
41 #[error("Git command failed: {0}")]
43 Git(String),
44
45 #[error("Git config missing: {0}")]
47 ConfigMissing(String),
48}
49
50pub fn has_uncommitted_changes(path: impl AsRef<Path>) -> Result<bool, GitOpsError> {
61 let path = path.as_ref();
62
63 let output = Command::new("git")
64 .args(["status", "--porcelain"])
65 .current_dir(path)
66 .output()?;
67
68 if !output.status.success() {
69 let stderr = String::from_utf8_lossy(&output.stderr);
70 return Err(GitOpsError::Git(stderr.to_string()));
71 }
72
73 let stdout = String::from_utf8_lossy(&output.stdout);
74 Ok(!stdout.trim().is_empty())
75}
76
77pub fn auto_commit_changes(
97 path: impl AsRef<Path>,
98 loop_id: &str,
99) -> Result<AutoCommitResult, GitOpsError> {
100 let path = path.as_ref();
101
102 if !has_uncommitted_changes(path)? {
104 return Ok(AutoCommitResult::no_commit());
105 }
106
107 let output = Command::new("git")
109 .args(["add", "-A"])
110 .current_dir(path)
111 .output()?;
112
113 if !output.status.success() {
114 let stderr = String::from_utf8_lossy(&output.stderr);
115 return Err(GitOpsError::Git(format!(
116 "Failed to stage changes: {}",
117 stderr
118 )));
119 }
120
121 let files_staged = count_staged_files(path)?;
123
124 if files_staged == 0 {
126 return Ok(AutoCommitResult::no_commit());
127 }
128
129 let commit_message = format!("chore: auto-commit before merge (loop {})", loop_id);
131
132 let output = Command::new("git")
133 .args(["commit", "-m", &commit_message])
134 .current_dir(path)
135 .output()?;
136
137 if !output.status.success() {
138 let stderr = String::from_utf8_lossy(&output.stderr);
139
140 if stderr.contains("user.email") || stderr.contains("user.name") {
142 return Err(GitOpsError::ConfigMissing(
143 "user.name or user.email not configured".to_string(),
144 ));
145 }
146
147 return Err(GitOpsError::Git(format!("Failed to commit: {}", stderr)));
148 }
149
150 let commit_sha = get_head_sha(path)?;
152
153 Ok(AutoCommitResult {
154 committed: true,
155 commit_sha: Some(commit_sha),
156 files_staged,
157 })
158}
159
160fn count_staged_files(path: &Path) -> Result<usize, GitOpsError> {
162 let output = Command::new("git")
163 .args(["diff", "--cached", "--name-only"])
164 .current_dir(path)
165 .output()?;
166
167 if !output.status.success() {
168 let stderr = String::from_utf8_lossy(&output.stderr);
169 return Err(GitOpsError::Git(stderr.to_string()));
170 }
171
172 let stdout = String::from_utf8_lossy(&output.stdout);
173 Ok(stdout.lines().filter(|line| !line.is_empty()).count())
174}
175
176pub fn get_head_sha(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
178 let path = path.as_ref();
179 let output = Command::new("git")
180 .args(["rev-parse", "HEAD"])
181 .current_dir(path)
182 .output()?;
183
184 if !output.status.success() {
185 let stderr = String::from_utf8_lossy(&output.stderr);
186 return Err(GitOpsError::Git(stderr.to_string()));
187 }
188
189 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
190}
191
192pub fn get_current_branch(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
201 let path = path.as_ref();
202 let output = Command::new("git")
203 .args(["rev-parse", "--abbrev-ref", "HEAD"])
204 .current_dir(path)
205 .output()?;
206
207 if !output.status.success() {
208 let stderr = String::from_utf8_lossy(&output.stderr);
209 return Err(GitOpsError::Git(stderr.to_string()));
210 }
211
212 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
213
214 if branch == "HEAD" {
216 return Err(GitOpsError::Git("Detached HEAD state".to_string()));
217 }
218
219 Ok(branch)
220}
221
222pub fn clean_stashes(path: impl AsRef<Path>) -> Result<usize, GitOpsError> {
235 let path = path.as_ref();
236
237 let output = Command::new("git")
239 .args(["stash", "list"])
240 .current_dir(path)
241 .output()?;
242
243 if !output.status.success() {
244 let stderr = String::from_utf8_lossy(&output.stderr);
245 return Err(GitOpsError::Git(stderr.to_string()));
246 }
247
248 let stash_count = String::from_utf8_lossy(&output.stdout)
249 .lines()
250 .filter(|line| !line.is_empty())
251 .count();
252
253 if stash_count == 0 {
254 return Ok(0);
255 }
256
257 let output = Command::new("git")
259 .args(["stash", "clear"])
260 .current_dir(path)
261 .output()?;
262
263 if !output.status.success() {
264 let stderr = String::from_utf8_lossy(&output.stderr);
265 return Err(GitOpsError::Git(format!(
266 "Failed to clear stashes: {}",
267 stderr
268 )));
269 }
270
271 Ok(stash_count)
272}
273
274pub fn prune_remote_refs(path: impl AsRef<Path>) -> Result<(), GitOpsError> {
283 let path = path.as_ref();
284
285 let output = Command::new("git")
287 .args(["remote"])
288 .current_dir(path)
289 .output()?;
290
291 if !output.status.success() {
292 let stderr = String::from_utf8_lossy(&output.stderr);
293 return Err(GitOpsError::Git(stderr.to_string()));
294 }
295
296 let remotes = String::from_utf8_lossy(&output.stdout);
297 if !remotes.lines().any(|r| r.trim() == "origin") {
298 return Ok(());
300 }
301
302 let output = Command::new("git")
303 .args(["remote", "prune", "origin"])
304 .current_dir(path)
305 .output()?;
306
307 if !output.status.success() {
308 let stderr = String::from_utf8_lossy(&output.stderr);
309 return Err(GitOpsError::Git(format!(
310 "Failed to prune remote refs: {}",
311 stderr
312 )));
313 }
314
315 Ok(())
316}
317
318pub fn is_working_tree_clean(path: impl AsRef<Path>) -> Result<bool, GitOpsError> {
330 has_uncommitted_changes(path).map(|has_changes| !has_changes)
331}
332
333pub fn get_commit_summary(path: impl AsRef<Path>) -> Result<String, GitOpsError> {
341 let path = path.as_ref();
342 let output = Command::new("git")
343 .args(["log", "-1", "--format=%h: %s"])
344 .current_dir(path)
345 .output()?;
346
347 if !output.status.success() {
348 let stderr = String::from_utf8_lossy(&output.stderr);
349 return Err(GitOpsError::Git(stderr.to_string()));
350 }
351
352 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
353}
354
355pub fn get_recent_files(path: impl AsRef<Path>, limit: usize) -> Result<Vec<String>, GitOpsError> {
364 let path = path.as_ref();
365
366 let ranges = ["HEAD~5..HEAD", "HEAD~2..HEAD", "HEAD~1..HEAD"];
369
370 for range in ranges {
371 let output = Command::new("git")
372 .args(["diff", "--name-only", range, "--"])
373 .current_dir(path)
374 .output()?;
375
376 if output.status.success() {
377 let files = String::from_utf8_lossy(&output.stdout);
378 let file_list: Vec<String> = files
379 .lines()
380 .filter(|line| !line.is_empty())
381 .take(limit)
382 .map(String::from)
383 .collect();
384
385 if !file_list.is_empty() {
386 return Ok(file_list);
387 }
388 }
389 }
390
391 let output = Command::new("git")
393 .args(["ls-files", "--"])
394 .current_dir(path)
395 .output()?;
396
397 if !output.status.success() {
398 return Ok(Vec::new());
399 }
400
401 let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
402 .lines()
403 .filter(|line| !line.is_empty())
404 .take(limit)
405 .map(String::from)
406 .collect();
407
408 Ok(files)
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use std::fs;
415 use tempfile::TempDir;
416
417 fn init_git_repo(dir: &Path) {
418 Command::new("git")
419 .args(["init", "--initial-branch=main"])
420 .current_dir(dir)
421 .output()
422 .unwrap();
423
424 Command::new("git")
425 .args(["config", "user.email", "test@test.local"])
426 .current_dir(dir)
427 .output()
428 .unwrap();
429
430 Command::new("git")
431 .args(["config", "user.name", "Test User"])
432 .current_dir(dir)
433 .output()
434 .unwrap();
435
436 fs::write(dir.join("README.md"), "# Test").unwrap();
438 Command::new("git")
439 .args(["add", "README.md"])
440 .current_dir(dir)
441 .output()
442 .unwrap();
443 Command::new("git")
444 .args(["commit", "-m", "Initial commit"])
445 .current_dir(dir)
446 .output()
447 .unwrap();
448 }
449
450 #[test]
451 fn test_has_uncommitted_changes_clean() {
452 let temp = TempDir::new().unwrap();
453 init_git_repo(temp.path());
454
455 assert!(!has_uncommitted_changes(temp.path()).unwrap());
456 }
457
458 #[test]
459 fn test_has_uncommitted_changes_untracked() {
460 let temp = TempDir::new().unwrap();
461 init_git_repo(temp.path());
462
463 fs::write(temp.path().join("new_file.txt"), "content").unwrap();
464
465 assert!(has_uncommitted_changes(temp.path()).unwrap());
466 }
467
468 #[test]
469 fn test_has_uncommitted_changes_staged() {
470 let temp = TempDir::new().unwrap();
471 init_git_repo(temp.path());
472
473 fs::write(temp.path().join("staged.txt"), "content").unwrap();
474 Command::new("git")
475 .args(["add", "staged.txt"])
476 .current_dir(temp.path())
477 .output()
478 .unwrap();
479
480 assert!(has_uncommitted_changes(temp.path()).unwrap());
481 }
482
483 #[test]
484 fn test_has_uncommitted_changes_modified() {
485 let temp = TempDir::new().unwrap();
486 init_git_repo(temp.path());
487
488 fs::write(temp.path().join("README.md"), "# Modified").unwrap();
489
490 assert!(has_uncommitted_changes(temp.path()).unwrap());
491 }
492
493 #[test]
494 fn test_auto_commit_no_changes() {
495 let temp = TempDir::new().unwrap();
496 init_git_repo(temp.path());
497
498 let result = auto_commit_changes(temp.path(), "test-loop").unwrap();
499
500 assert!(!result.committed);
501 assert!(result.commit_sha.is_none());
502 assert_eq!(result.files_staged, 0);
503 }
504
505 #[test]
506 fn test_auto_commit_untracked_files() {
507 let temp = TempDir::new().unwrap();
508 init_git_repo(temp.path());
509
510 fs::write(temp.path().join("feature.txt"), "new feature").unwrap();
511
512 let result = auto_commit_changes(temp.path(), "loop-123").unwrap();
513
514 assert!(result.committed);
515 assert!(result.commit_sha.is_some());
516 assert_eq!(result.files_staged, 1);
517
518 let output = Command::new("git")
520 .args(["log", "-1", "--pretty=%s"])
521 .current_dir(temp.path())
522 .output()
523 .unwrap();
524 let message = String::from_utf8_lossy(&output.stdout);
525 assert_eq!(
526 message.trim(),
527 "chore: auto-commit before merge (loop loop-123)"
528 );
529 }
530
531 #[test]
532 fn test_auto_commit_staged_changes() {
533 let temp = TempDir::new().unwrap();
534 init_git_repo(temp.path());
535
536 fs::write(temp.path().join("staged.txt"), "staged content").unwrap();
537 Command::new("git")
538 .args(["add", "staged.txt"])
539 .current_dir(temp.path())
540 .output()
541 .unwrap();
542
543 let result = auto_commit_changes(temp.path(), "loop-456").unwrap();
544
545 assert!(result.committed);
546 assert!(result.commit_sha.is_some());
547 assert_eq!(result.files_staged, 1);
548 }
549
550 #[test]
551 fn test_auto_commit_unstaged_modifications() {
552 let temp = TempDir::new().unwrap();
553 init_git_repo(temp.path());
554
555 fs::write(temp.path().join("README.md"), "# Modified content").unwrap();
556
557 let result = auto_commit_changes(temp.path(), "loop-789").unwrap();
558
559 assert!(result.committed);
560 assert!(result.commit_sha.is_some());
561 assert_eq!(result.files_staged, 1);
562 }
563
564 #[test]
565 fn test_auto_commit_mixed_changes() {
566 let temp = TempDir::new().unwrap();
567 init_git_repo(temp.path());
568
569 fs::write(temp.path().join("new.txt"), "new").unwrap();
571
572 fs::write(temp.path().join("staged.txt"), "staged").unwrap();
574 Command::new("git")
575 .args(["add", "staged.txt"])
576 .current_dir(temp.path())
577 .output()
578 .unwrap();
579
580 fs::write(temp.path().join("README.md"), "# Modified").unwrap();
582
583 let result = auto_commit_changes(temp.path(), "loop-mixed").unwrap();
584
585 assert!(result.committed);
586 assert!(result.commit_sha.is_some());
587 assert_eq!(result.files_staged, 3);
588 }
589
590 #[test]
591 fn test_auto_commit_working_tree_clean_after() {
592 let temp = TempDir::new().unwrap();
593 init_git_repo(temp.path());
594
595 fs::write(temp.path().join("feature.txt"), "feature").unwrap();
596
597 let result = auto_commit_changes(temp.path(), "loop-clean").unwrap();
598 assert!(result.committed);
599
600 assert!(!has_uncommitted_changes(temp.path()).unwrap());
602 }
603
604 #[test]
605 fn test_auto_commit_returns_correct_sha() {
606 let temp = TempDir::new().unwrap();
607 init_git_repo(temp.path());
608
609 fs::write(temp.path().join("file.txt"), "content").unwrap();
610
611 let result = auto_commit_changes(temp.path(), "loop-sha").unwrap();
612
613 let head_sha = get_head_sha(temp.path()).unwrap();
615 assert_eq!(result.commit_sha.unwrap(), head_sha);
616 }
617
618 #[test]
619 fn test_auto_commit_only_gitignored_files() {
620 let temp = TempDir::new().unwrap();
621 init_git_repo(temp.path());
622
623 fs::write(temp.path().join(".gitignore"), "*.log\n").unwrap();
625 Command::new("git")
626 .args(["add", ".gitignore"])
627 .current_dir(temp.path())
628 .output()
629 .unwrap();
630 Command::new("git")
631 .args(["commit", "-m", "Add gitignore"])
632 .current_dir(temp.path())
633 .output()
634 .unwrap();
635
636 fs::write(temp.path().join("debug.log"), "log content").unwrap();
638
639 assert!(!has_uncommitted_changes(temp.path()).unwrap());
641
642 let result = auto_commit_changes(temp.path(), "loop-ignored").unwrap();
643 assert!(!result.committed);
644 }
645
646 #[test]
647 fn test_get_current_branch() {
648 let temp = TempDir::new().unwrap();
649 init_git_repo(temp.path());
650
651 let branch = get_current_branch(temp.path()).unwrap();
652 assert_eq!(branch, "main");
653 }
654
655 #[test]
656 fn test_get_current_branch_custom() {
657 let temp = TempDir::new().unwrap();
658 init_git_repo(temp.path());
659
660 Command::new("git")
662 .args(["checkout", "-b", "feature-branch"])
663 .current_dir(temp.path())
664 .output()
665 .unwrap();
666
667 let branch = get_current_branch(temp.path()).unwrap();
668 assert_eq!(branch, "feature-branch");
669 }
670
671 #[test]
672 fn test_clean_stashes_empty() {
673 let temp = TempDir::new().unwrap();
674 init_git_repo(temp.path());
675
676 let cleared = clean_stashes(temp.path()).unwrap();
678 assert_eq!(cleared, 0);
679 }
680
681 #[test]
682 fn test_clean_stashes_with_stash() {
683 let temp = TempDir::new().unwrap();
684 init_git_repo(temp.path());
685
686 fs::write(temp.path().join("README.md"), "# Modified").unwrap();
688 Command::new("git")
689 .args(["stash", "push", "-m", "test stash"])
690 .current_dir(temp.path())
691 .output()
692 .unwrap();
693
694 fs::write(temp.path().join("README.md"), "# Modified again").unwrap();
696 Command::new("git")
697 .args(["stash", "push", "-m", "test stash 2"])
698 .current_dir(temp.path())
699 .output()
700 .unwrap();
701
702 let cleared = clean_stashes(temp.path()).unwrap();
704 assert_eq!(cleared, 2);
705
706 let cleared_again = clean_stashes(temp.path()).unwrap();
708 assert_eq!(cleared_again, 0);
709 }
710
711 #[test]
712 fn test_prune_remote_refs_no_origin() {
713 let temp = TempDir::new().unwrap();
714 init_git_repo(temp.path());
715
716 prune_remote_refs(temp.path()).unwrap();
718 }
719
720 #[test]
721 fn test_is_working_tree_clean() {
722 let temp = TempDir::new().unwrap();
723 init_git_repo(temp.path());
724
725 assert!(is_working_tree_clean(temp.path()).unwrap());
727
728 fs::write(temp.path().join("new_file.txt"), "content").unwrap();
730
731 assert!(!is_working_tree_clean(temp.path()).unwrap());
733 }
734
735 #[test]
736 fn test_get_commit_summary() {
737 let temp = TempDir::new().unwrap();
738 init_git_repo(temp.path());
739
740 let summary = get_commit_summary(temp.path()).unwrap();
741 assert!(summary.contains("Initial commit"), "Got: {}", summary);
742 }
743
744 #[test]
745 fn test_get_recent_files() {
746 let temp = TempDir::new().unwrap();
747 init_git_repo(temp.path());
748
749 fs::write(temp.path().join("feature.txt"), "content").unwrap();
751 Command::new("git")
752 .args(["add", "feature.txt"])
753 .current_dir(temp.path())
754 .output()
755 .unwrap();
756 Command::new("git")
757 .args(["commit", "-m", "Add feature"])
758 .current_dir(temp.path())
759 .output()
760 .unwrap();
761
762 let files = get_recent_files(temp.path(), 10).unwrap();
763 assert!(
764 files.contains(&"feature.txt".to_string()),
765 "Got: {:?}",
766 files
767 );
768 }
769}