1use anyhow::{Context, Result};
7use colored::Colorize;
8
9#[derive(Debug)]
11pub enum CommitError {
12 GitCommandFailed(String),
14 NoMatchingCommits,
16}
17
18impl std::fmt::Display for CommitError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 CommitError::GitCommandFailed(err) => write!(f, "Git command failed: {}", err),
22 CommitError::NoMatchingCommits => write!(f, "No matching commits found"),
23 }
24 }
25}
26
27impl std::error::Error for CommitError {}
28
29pub fn get_commits_for_spec(spec_id: &str) -> Result<Vec<String>> {
31 get_commits_for_spec_internal(spec_id, None, false)
32}
33
34pub fn get_commits_for_spec_with_branch(
37 spec_id: &str,
38 spec_branch: Option<&str>,
39) -> Result<Vec<String>> {
40 get_commits_for_spec_internal(spec_id, spec_branch, false)
41}
42
43pub fn get_commits_for_spec_allow_no_commits(spec_id: &str) -> Result<Vec<String>> {
45 get_commits_for_spec_internal(spec_id, None, true)
46}
47
48pub fn get_commits_for_spec_with_branch_allow_no_commits(
51 spec_id: &str,
52 spec_branch: Option<&str>,
53) -> Result<Vec<String>> {
54 get_commits_for_spec_internal(spec_id, spec_branch, true)
55}
56
57fn find_commits_on_branch(branch: &str, spec_id: &str) -> Result<Vec<String>> {
60 let pattern = format!("chant({}):", spec_id);
61
62 let output = std::process::Command::new("git")
63 .args(["log", branch, "--oneline", "--grep", &pattern, "--reverse"])
64 .output()
65 .context("Failed to execute git log command")?;
66
67 if !output.status.success() {
68 return Ok(vec![]);
70 }
71
72 let mut commits = Vec::new();
73 let stdout = String::from_utf8_lossy(&output.stdout);
74 for line in stdout.lines() {
75 if let Some(hash) = line.split_whitespace().next() {
76 if !hash.is_empty() {
77 commits.push(hash.to_string());
78 }
79 }
80 }
81
82 Ok(commits)
83}
84
85fn get_commits_for_spec_internal(
86 spec_id: &str,
87 spec_branch: Option<&str>,
88 allow_no_commits: bool,
89) -> Result<Vec<String>> {
90 let pattern = format!("chant({}):", spec_id);
93
94 eprintln!(
95 "{} Searching for commits matching pattern: '{}'",
96 "→".cyan(),
97 pattern
98 );
99
100 if let Some(branch) = spec_branch {
102 eprintln!(
103 "{} Checking spec branch '{}' for commits",
104 "→".cyan(),
105 branch
106 );
107 if let Ok(branch_commits) = find_commits_on_branch(branch, spec_id) {
108 if !branch_commits.is_empty() {
109 eprintln!(
110 "{} Found {} commit(s) on branch '{}'",
111 "→".cyan(),
112 branch_commits.len(),
113 branch
114 );
115 return Ok(branch_commits);
116 }
117 }
118 }
119
120 let output = std::process::Command::new("git")
121 .args(["log", "--oneline", "--grep", &pattern, "--reverse"])
122 .output()
123 .context("Failed to execute git log command")?;
124
125 if !output.status.success() {
127 let stderr = String::from_utf8_lossy(&output.stderr);
128 let error_msg = format!(
129 "git log command failed for pattern '{}': {}",
130 pattern, stderr
131 );
132 eprintln!("{} {}", "✗".red(), error_msg);
133 return Err(anyhow::anyhow!(CommitError::GitCommandFailed(error_msg)));
134 }
135
136 let mut commits = Vec::new();
138 let stdout = String::from_utf8_lossy(&output.stdout);
139 for line in stdout.lines() {
140 if let Some(hash) = line.split_whitespace().next() {
141 if !hash.is_empty() {
142 commits.push(hash.to_string());
143 }
144 }
145 }
146
147 eprintln!(
148 "{} Found {} commit(s) matching pattern '{}'",
149 "→".cyan(),
150 commits.len(),
151 pattern
152 );
153
154 if commits.is_empty() {
156 if allow_no_commits {
157 eprintln!(
159 "{} No commits found with pattern '{}'. Attempting to use HEAD as fallback.",
160 "⚠".yellow(),
161 pattern
162 );
163
164 let head_output = std::process::Command::new("git")
165 .args(["rev-parse", "--short=7", "HEAD"])
166 .output()
167 .context("Failed to execute git rev-parse command")?;
168
169 if head_output.status.success() {
170 let head_hash = String::from_utf8_lossy(&head_output.stdout)
171 .trim()
172 .to_string();
173 if !head_hash.is_empty() {
174 eprintln!("{} Using HEAD commit: {}", "⚠".yellow(), head_hash);
175 commits.push(head_hash);
176 }
177 } else {
178 let stderr = String::from_utf8_lossy(&head_output.stderr);
179 let error_msg = format!(
180 "Could not find any commit for spec '{}' and HEAD fallback failed: {}",
181 spec_id, stderr
182 );
183 eprintln!("{} {}", "✗".red(), error_msg);
184 return Err(anyhow::anyhow!(CommitError::NoMatchingCommits));
185 }
186 } else {
187 let default_branch = format!("chant/{}", spec_id);
190 let branch_to_check = spec_branch.unwrap_or(&default_branch);
191 if let Ok(branch_commits) = find_commits_on_branch(branch_to_check, spec_id) {
192 if !branch_commits.is_empty() {
193 let error_msg = format!(
194 "No matching commits found on main\n\
195 Found {} commit(s) on branch {}\n\
196 Run 'chant merge {}' to merge the branch first",
197 branch_commits.len(),
198 branch_to_check,
199 spec_id
200 );
201 eprintln!("{} {}", "✗".red(), error_msg);
202 return Err(anyhow::anyhow!(CommitError::NoMatchingCommits));
203 }
204 }
205 let error_msg =
206 crate::merge_errors::no_commits_found(spec_id, &format!("chant/{}", spec_id));
207 eprintln!("{} {}", "✗".red(), error_msg);
208 return Err(anyhow::anyhow!(CommitError::NoMatchingCommits));
209 }
210 }
211
212 Ok(commits)
213}
214
215const KNOWN_AGENT_SIGNATURES: &[&str] = &[
218 "Co-Authored-By: Claude",
219 "Co-authored-by: Claude",
220 "Co-Authored-By: GPT",
221 "Co-authored-by: GPT",
222 "Co-Authored-By: Copilot",
223 "Co-authored-by: Copilot",
224 "Co-Authored-By: Gemini",
225 "Co-authored-by: Gemini",
226 "Co-Authored-By: Cursor",
227 "Co-authored-by: Cursor",
228 ];
230
231#[derive(Debug, Clone)]
233pub struct AgentDetectionResult {
234 #[allow(dead_code)]
236 pub commit_hash: String,
237 pub has_agent: bool,
239 pub agent_signature: Option<String>,
241}
242
243pub fn detect_agent_in_commit(commit_hash: &str) -> Result<AgentDetectionResult> {
246 let output = std::process::Command::new("git")
248 .args(["log", "-1", "--format=%B", commit_hash])
249 .output()
250 .context("Failed to execute git log command")?;
251
252 if !output.status.success() {
253 let stderr = String::from_utf8_lossy(&output.stderr);
254 anyhow::bail!(
255 "Failed to get commit message for {}: {}",
256 commit_hash,
257 stderr
258 );
259 }
260
261 let commit_message = String::from_utf8_lossy(&output.stdout);
262
263 for signature in KNOWN_AGENT_SIGNATURES {
265 if commit_message.contains(signature) {
266 return Ok(AgentDetectionResult {
267 commit_hash: commit_hash.to_string(),
268 has_agent: true,
269 agent_signature: Some(signature.to_string()),
270 });
271 }
272 }
273
274 let lower_message = commit_message.to_lowercase();
276 if lower_message.contains("co-authored-by:") {
277 for line in commit_message.lines() {
279 let lower_line = line.to_lowercase();
280 if lower_line.starts_with("co-authored-by:") {
281 let ai_terms = [
283 "claude",
284 "gpt",
285 "copilot",
286 "gemini",
287 "cursor",
288 "anthropic",
289 "openai",
290 "ai",
291 "assistant",
292 ];
293 for term in ai_terms {
294 if lower_line.contains(term) {
295 return Ok(AgentDetectionResult {
296 commit_hash: commit_hash.to_string(),
297 has_agent: true,
298 agent_signature: Some(line.trim().to_string()),
299 });
300 }
301 }
302 }
303 }
304 }
305
306 Ok(AgentDetectionResult {
307 commit_hash: commit_hash.to_string(),
308 has_agent: false,
309 agent_signature: None,
310 })
311}
312
313#[allow(dead_code)]
317pub fn detect_agents_in_spec_commits(spec_id: &str) -> Result<Vec<AgentDetectionResult>> {
318 let commits = match get_commits_for_spec_allow_no_commits(spec_id) {
320 Ok(c) => c,
321 Err(_) => return Ok(vec![]), };
323
324 let mut results = Vec::new();
325 for commit in commits {
326 match detect_agent_in_commit(&commit) {
327 Ok(result) if result.has_agent => {
328 results.push(result);
329 }
330 Ok(_) => {
331 }
333 Err(e) => {
334 eprintln!(
336 "Warning: Failed to check commit {} for agent: {}",
337 commit, e
338 );
339 }
340 }
341 }
342
343 Ok(results)
344}
345
346#[allow(dead_code)]
350pub fn has_agent_coauthorship(spec_id: &str) -> bool {
351 match detect_agents_in_spec_commits(spec_id) {
352 Ok(results) => !results.is_empty(),
353 Err(_) => false,
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use std::process::Command;
361 use tempfile::TempDir;
362
363 fn setup_test_repo(repo_dir: &std::path::Path, commits: &[(String, String)]) -> Result<()> {
365 std::fs::create_dir_all(repo_dir).context("Failed to create repo directory")?;
367
368 let init = Command::new("git")
370 .args(["init"])
371 .current_dir(repo_dir)
372 .output()
373 .context("Failed to git init")?;
374 if !init.status.success() {
375 return Err(anyhow::anyhow!(
376 "git init failed: {}",
377 String::from_utf8_lossy(&init.stderr)
378 ));
379 }
380
381 let email = Command::new("git")
383 .args(["config", "user.email", "test@example.com"])
384 .current_dir(repo_dir)
385 .output()
386 .context("Failed to set git user.email")?;
387 if !email.status.success() {
388 return Err(anyhow::anyhow!(
389 "git config user.email failed: {}",
390 String::from_utf8_lossy(&email.stderr)
391 ));
392 }
393
394 let name = Command::new("git")
395 .args(["config", "user.name", "Test User"])
396 .current_dir(repo_dir)
397 .output()
398 .context("Failed to set git user.name")?;
399 if !name.status.success() {
400 return Err(anyhow::anyhow!(
401 "git config user.name failed: {}",
402 String::from_utf8_lossy(&name.stderr)
403 ));
404 }
405
406 for (msg, file_content) in commits {
408 let file_path = repo_dir.join("test_file.txt");
409 std::fs::write(&file_path, file_content).context("Failed to write test file")?;
410
411 let add = Command::new("git")
412 .args(["add", "test_file.txt"])
413 .current_dir(repo_dir)
414 .output()
415 .context("Failed to git add")?;
416 if !add.status.success() {
417 return Err(anyhow::anyhow!(
418 "git add failed: {}",
419 String::from_utf8_lossy(&add.stderr)
420 ));
421 }
422
423 let commit = Command::new("git")
424 .args(["commit", "-m", msg])
425 .current_dir(repo_dir)
426 .output()
427 .context("Failed to git commit")?;
428 if !commit.status.success() {
429 return Err(anyhow::anyhow!(
430 "git commit failed: {}",
431 String::from_utf8_lossy(&commit.stderr)
432 ));
433 }
434 }
435
436 Ok(())
437 }
438
439 #[test]
440 #[serial_test::serial]
441 fn test_commit_pattern_matches_full_spec_id() -> Result<()> {
442 let repo_dir = TempDir::new()?.path().to_path_buf();
443 let spec_id = "2026-01-27-001-abc";
444
445 let commits_to_make = vec![
446 (format!("chant({}):", spec_id), "content 1".to_string()),
447 (
448 format!("chant({}): Fix bug", spec_id),
449 "content 2".to_string(),
450 ),
451 (
452 format!("chant({}): Add tests", spec_id),
453 "content 3".to_string(),
454 ),
455 ];
456
457 setup_test_repo(&repo_dir, &commits_to_make)?;
458
459 let original_dir = std::env::current_dir().ok();
460 std::env::set_current_dir(&repo_dir)?;
461
462 let result = get_commits_for_spec(spec_id);
463
464 if let Some(dir) = original_dir {
465 let _ = std::env::set_current_dir(&dir);
466 }
467
468 let commits = result?;
469 assert_eq!(
470 commits.len(),
471 3,
472 "Should find all 3 commits matching full spec ID"
473 );
474
475 Ok(())
476 }
477
478 #[test]
479 #[serial_test::serial]
480 fn test_commit_pattern_with_extra_whitespace() -> Result<()> {
481 let repo_dir = TempDir::new()?.path().to_path_buf();
482 let spec_id = "2026-01-27-007-xyz";
483
484 let commits_to_make = vec![
486 (format!("chant({}):", spec_id), "content 1".to_string()),
487 (
488 format!("chant({}): Fix with standard format", spec_id),
489 "content 2".to_string(),
490 ),
491 (
492 format!("chant({}): Add more tests", spec_id),
493 "content 3".to_string(),
494 ),
495 ];
496
497 setup_test_repo(&repo_dir, &commits_to_make)?;
498
499 let original_dir = std::env::current_dir().ok();
500 let _ = std::env::set_current_dir(&repo_dir);
501
502 let result = get_commits_for_spec(spec_id);
503
504 if let Some(dir) = original_dir {
505 let _ = std::env::set_current_dir(&dir);
506 }
507
508 let commits = result?;
509 assert_eq!(
511 commits.len(),
512 3,
513 "Should find all 3 commits with standard pattern"
514 );
515
516 Ok(())
517 }
518
519 #[test]
520 #[serial_test::serial]
521 fn test_commit_pattern_no_match_returns_error() -> Result<()> {
522 let repo_dir = TempDir::new()?.path().to_path_buf();
523 let spec_id = "2026-01-27-003-ghi";
524 let unrelated_spec = "2026-01-27-999-zzz";
525
526 let commits_to_make = vec![
527 (
528 format!("chant({}):", unrelated_spec),
529 "content 1".to_string(),
530 ),
531 ("Some other commit".to_string(), "content 2".to_string()),
532 ];
533
534 setup_test_repo(&repo_dir, &commits_to_make)?;
535
536 let original_dir = std::env::current_dir().ok();
537 std::env::set_current_dir(&repo_dir)?;
538
539 let result = get_commits_for_spec(spec_id);
540
541 if let Some(dir) = original_dir {
542 let _ = std::env::set_current_dir(&dir);
543 }
544
545 assert!(
546 result.is_err(),
547 "Should return error when no commits match the pattern"
548 );
549
550 Ok(())
551 }
552
553 #[test]
554 #[serial_test::serial]
555 fn test_commit_pattern_with_description() -> Result<()> {
556 let repo_dir = TempDir::new()?.path().to_path_buf();
557 let spec_id = "2026-01-27-004-jkl";
558
559 let commits_to_make = vec![
560 (
561 format!("chant({}): Implement feature", spec_id),
562 "content 1".to_string(),
563 ),
564 (
565 format!("chant({}): Fix unit tests", spec_id),
566 "content 2".to_string(),
567 ),
568 (
569 format!("chant({}): Update documentation", spec_id),
570 "content 3".to_string(),
571 ),
572 ];
573
574 setup_test_repo(&repo_dir, &commits_to_make)?;
575
576 let original_dir = std::env::current_dir().ok();
577 std::env::set_current_dir(&repo_dir)?;
578
579 let result = get_commits_for_spec(spec_id);
580
581 if let Some(dir) = original_dir {
582 let _ = std::env::set_current_dir(&dir);
583 }
584
585 let commits = result?;
586 assert_eq!(
587 commits.len(),
588 3,
589 "Should find all commits with descriptions"
590 );
591
592 Ok(())
593 }
594
595 #[test]
596 #[serial_test::serial]
597 fn test_get_commits_for_spec_allow_no_commits_with_fallback() -> Result<()> {
598 let repo_dir = TempDir::new()?.path().to_path_buf();
599 let spec_id = "2026-01-27-005-mno";
600 let unrelated_spec = "2026-01-27-999-xxx";
601
602 let commits_to_make = vec![(
603 format!("chant({}):", unrelated_spec),
604 "content 1".to_string(),
605 )];
606
607 setup_test_repo(&repo_dir, &commits_to_make)?;
608
609 let original_dir = std::env::current_dir().ok();
610 std::env::set_current_dir(&repo_dir)?;
611
612 let result = get_commits_for_spec_allow_no_commits(spec_id);
613
614 if let Some(dir) = original_dir {
615 let _ = std::env::set_current_dir(&dir);
616 }
617
618 let commits = result?;
619 assert_eq!(
620 commits.len(),
621 1,
622 "Should fallback to HEAD when no commits match"
623 );
624
625 Ok(())
626 }
627
628 #[test]
629 #[serial_test::serial]
630 fn test_commit_pattern_multiple_commits_different_dates() -> Result<()> {
631 let repo_dir = TempDir::new()?.path().to_path_buf();
632 let spec_id = "2026-01-27-006-pqr";
633
634 let commits_to_make = vec![
635 (
636 format!("chant({}): First commit", spec_id),
637 "v1".to_string(),
638 ),
639 (
640 format!("chant({}): Second commit", spec_id),
641 "v2".to_string(),
642 ),
643 (
644 format!("chant({}): Third commit", spec_id),
645 "v3".to_string(),
646 ),
647 (
648 "unrelated: Some other work".to_string(),
649 "other".to_string(),
650 ),
651 (
652 format!("chant({}): Fourth commit", spec_id),
653 "v4".to_string(),
654 ),
655 ];
656
657 setup_test_repo(&repo_dir, &commits_to_make)?;
658
659 let original_dir = std::env::current_dir().ok();
660 std::env::set_current_dir(&repo_dir)?;
661
662 let result = get_commits_for_spec(spec_id);
663
664 if let Some(dir) = original_dir {
665 let _ = std::env::set_current_dir(&dir);
666 }
667
668 let commits = result?;
669 assert_eq!(
670 commits.len(),
671 4,
672 "Should find all 4 commits for spec ID, excluding unrelated ones"
673 );
674
675 Ok(())
676 }
677
678 fn setup_test_repo_with_messages(
684 repo_dir: &std::path::Path,
685 messages: &[&str],
686 ) -> Result<Vec<String>> {
687 std::fs::create_dir_all(repo_dir).context("Failed to create repo directory")?;
689
690 let init = Command::new("git")
692 .args(["init"])
693 .current_dir(repo_dir)
694 .output()
695 .context("Failed to git init")?;
696 if !init.status.success() {
697 return Err(anyhow::anyhow!(
698 "git init failed: {}",
699 String::from_utf8_lossy(&init.stderr)
700 ));
701 }
702
703 Command::new("git")
705 .args(["config", "user.email", "test@example.com"])
706 .current_dir(repo_dir)
707 .output()
708 .context("Failed to set git user.email")?;
709
710 Command::new("git")
711 .args(["config", "user.name", "Test User"])
712 .current_dir(repo_dir)
713 .output()
714 .context("Failed to set git user.name")?;
715
716 let mut commit_hashes = Vec::new();
718 for (i, message) in messages.iter().enumerate() {
719 let file_path = repo_dir.join("test_file.txt");
720 std::fs::write(&file_path, format!("content {}", i))
721 .context("Failed to write test file")?;
722
723 Command::new("git")
724 .args(["add", "test_file.txt"])
725 .current_dir(repo_dir)
726 .output()
727 .context("Failed to git add")?;
728
729 Command::new("git")
730 .args(["commit", "-m", message])
731 .current_dir(repo_dir)
732 .output()
733 .context("Failed to git commit")?;
734
735 let hash_output = Command::new("git")
737 .args(["rev-parse", "--short=7", "HEAD"])
738 .current_dir(repo_dir)
739 .output()
740 .context("Failed to get commit hash")?;
741 let hash = String::from_utf8_lossy(&hash_output.stdout)
742 .trim()
743 .to_string();
744 commit_hashes.push(hash);
745 }
746
747 Ok(commit_hashes)
748 }
749
750 #[test]
751 #[serial_test::serial]
752 fn test_detect_agent_claude_co_authored_by() -> Result<()> {
753 let repo_dir = TempDir::new()?.path().to_path_buf();
754 let message = "chant(test-spec): Fix bug\n\nCo-Authored-By: Claude <noreply@anthropic.com>";
755 let hashes = setup_test_repo_with_messages(&repo_dir, &[message])?;
756
757 let original_dir = std::env::current_dir().ok();
758 std::env::set_current_dir(&repo_dir)?;
759
760 let result = detect_agent_in_commit(&hashes[0]);
761
762 if let Some(dir) = original_dir {
763 let _ = std::env::set_current_dir(&dir);
764 }
765
766 let detection = result?;
767 assert!(detection.has_agent, "Should detect Claude co-authorship");
768 assert!(
769 detection.agent_signature.is_some(),
770 "Should capture agent signature"
771 );
772
773 Ok(())
774 }
775
776 #[test]
777 #[serial_test::serial]
778 fn test_detect_agent_gpt_co_authored_by() -> Result<()> {
779 let repo_dir = TempDir::new()?.path().to_path_buf();
780 let message = "chant(test-spec): Add feature\n\nCo-authored-by: GPT-4 <noreply@openai.com>";
781 let hashes = setup_test_repo_with_messages(&repo_dir, &[message])?;
782
783 let original_dir = std::env::current_dir().ok();
784 std::env::set_current_dir(&repo_dir)?;
785
786 let result = detect_agent_in_commit(&hashes[0]);
787
788 if let Some(dir) = original_dir {
789 let _ = std::env::set_current_dir(&dir);
790 }
791
792 let detection = result?;
793 assert!(detection.has_agent, "Should detect GPT co-authorship");
794
795 Ok(())
796 }
797
798 #[test]
799 #[serial_test::serial]
800 fn test_no_agent_detected_for_human_commit() -> Result<()> {
801 let repo_dir = TempDir::new()?.path().to_path_buf();
802 let message = "chant(test-spec): Human-only commit\n\nThis is a regular commit.";
803 let hashes = setup_test_repo_with_messages(&repo_dir, &[message])?;
804
805 let original_dir = std::env::current_dir().ok();
806 std::env::set_current_dir(&repo_dir)?;
807
808 let result = detect_agent_in_commit(&hashes[0]);
809
810 if let Some(dir) = original_dir {
811 let _ = std::env::set_current_dir(&dir);
812 }
813
814 let detection = result?;
815 assert!(
816 !detection.has_agent,
817 "Should not detect agent in human commit"
818 );
819 assert!(
820 detection.agent_signature.is_none(),
821 "Should have no agent signature"
822 );
823
824 Ok(())
825 }
826
827 #[test]
828 #[serial_test::serial]
829 fn test_detect_agent_case_insensitive() -> Result<()> {
830 let repo_dir = TempDir::new()?.path().to_path_buf();
831 let message =
832 "chant(test-spec): Test\n\nco-authored-by: claude opus 4.5 <noreply@anthropic.com>";
833 let hashes = setup_test_repo_with_messages(&repo_dir, &[message])?;
834
835 let original_dir = std::env::current_dir().ok();
836 std::env::set_current_dir(&repo_dir)?;
837
838 let result = detect_agent_in_commit(&hashes[0]);
839
840 if let Some(dir) = original_dir {
841 let _ = std::env::set_current_dir(&dir);
842 }
843
844 let detection = result?;
845 assert!(
846 detection.has_agent,
847 "Should detect agent with case-insensitive matching"
848 );
849
850 Ok(())
851 }
852
853 #[test]
854 fn test_known_agent_signatures_constant() {
855 assert!(KNOWN_AGENT_SIGNATURES.contains(&"Co-Authored-By: Claude"));
857 assert!(KNOWN_AGENT_SIGNATURES.contains(&"Co-authored-by: Claude"));
858 assert!(KNOWN_AGENT_SIGNATURES.contains(&"Co-Authored-By: GPT"));
859 assert!(KNOWN_AGENT_SIGNATURES.contains(&"Co-Authored-By: Copilot"));
860 assert!(KNOWN_AGENT_SIGNATURES.contains(&"Co-Authored-By: Gemini"));
861 }
862}