Skip to main content

chant/operations/
commits.rs

1//! Commit tracking and detection for spec finalization.
2//!
3//! Handles finding commits associated with a spec by searching for the
4//! `chant(spec-id): description` pattern in commit messages.
5
6use anyhow::{Context, Result};
7use colored::Colorize;
8
9/// Enum to distinguish between different commit retrieval scenarios
10#[derive(Debug)]
11pub enum CommitError {
12    /// Git command failed (e.g., not in a git repository)
13    GitCommandFailed(String),
14    /// Git log succeeded but found no matching commits
15    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
29/// Get commits for a spec, failing if no commits match the pattern.
30pub fn get_commits_for_spec(spec_id: &str) -> Result<Vec<String>> {
31    get_commits_for_spec_internal(spec_id, None, false)
32}
33
34/// Get commits for a spec with branch context for better error messages.
35/// If spec_branch is provided, searches that branch first before current branch.
36pub 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
43/// Get commits for a spec, using HEAD as fallback if no commits match.
44pub 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
48/// Get commits for a spec with branch context, allowing no commits (HEAD fallback).
49/// If spec_branch is provided, searches that branch first before current branch.
50pub 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
57/// Search for commits on a specific branch matching the spec pattern.
58/// Returns Ok(commits) if found, Err if not found or git command failed.
59fn 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        // Branch might not exist or other git error
69        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    // Look for all commits with the chant(spec_id): pattern
91    // Include colon and optional space to match the actual commit message format
92    let pattern = format!("chant({}):", spec_id);
93
94    eprintln!(
95        "{} Searching for commits matching pattern: '{}'",
96        "→".cyan(),
97        pattern
98    );
99
100    // If a spec branch is specified, check that branch first
101    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    // Check if git command itself failed
126    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    // Parse commits from successful output
137    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 no matching commits found, decide what to do based on flag
155    if commits.is_empty() {
156        if allow_no_commits {
157            // Fallback behavior: use HEAD with warning
158            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            // Default behavior: fail loudly with actionable message
188            // Check if commits exist on the spec's branch to provide better error message
189            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
215/// Known AI agent signatures in Co-Authored-By trailer format.
216/// These patterns are used to detect agent-assisted commits.
217const 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    // Add more agent signatures as needed
229];
230
231/// Result of agent detection for a commit.
232#[derive(Debug, Clone)]
233pub struct AgentDetectionResult {
234    /// The commit hash that was checked. Kept for debugging and future tooling.
235    #[allow(dead_code)]
236    pub commit_hash: String,
237    /// Whether an agent co-authorship was detected
238    pub has_agent: bool,
239    /// The agent signature found (if any)
240    pub agent_signature: Option<String>,
241}
242
243/// Check if a single commit has agent co-authorship.
244/// Returns the detection result with details about what was found.
245pub fn detect_agent_in_commit(commit_hash: &str) -> Result<AgentDetectionResult> {
246    // Get the full commit message including trailers
247    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    // Check for known agent signatures
264    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    // Also check for partial matches (case-insensitive) for "Co-Authored-By:" trailer
275    let lower_message = commit_message.to_lowercase();
276    if lower_message.contains("co-authored-by:") {
277        // Extract the co-authored-by line
278        for line in commit_message.lines() {
279            let lower_line = line.to_lowercase();
280            if lower_line.starts_with("co-authored-by:") {
281                // Check if this mentions any AI-related terms
282                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/// Check if any commits for a spec have agent co-authorship.
314/// Returns a list of all commits that have agent signatures.
315/// Designed for future approval workflow integration.
316#[allow(dead_code)]
317pub fn detect_agents_in_spec_commits(spec_id: &str) -> Result<Vec<AgentDetectionResult>> {
318    // Get commits for this spec (allowing no commits)
319    let commits = match get_commits_for_spec_allow_no_commits(spec_id) {
320        Ok(c) => c,
321        Err(_) => return Ok(vec![]), // No commits found, no agents
322    };
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                // No agent found in this commit, continue
332            }
333            Err(e) => {
334                // Log warning but continue checking other commits
335                eprintln!(
336                    "Warning: Failed to check commit {} for agent: {}",
337                    commit, e
338                );
339            }
340        }
341    }
342
343    Ok(results)
344}
345
346/// Check if any commits for a spec have agent co-authorship.
347/// Simplified helper that returns just a boolean.
348/// Designed for future approval workflow integration.
349#[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    /// Helper to set up a test git repository
364    fn setup_test_repo(repo_dir: &std::path::Path, commits: &[(String, String)]) -> Result<()> {
365        // Ensure repo directory exists
366        std::fs::create_dir_all(repo_dir).context("Failed to create repo directory")?;
367
368        // Initialize repo
369        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        // Configure git
382        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        // Create commits
407        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        // Only test exact format - git grep doesn't match variations
485        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        // Should find all commits with standard chant(spec_id): pattern
510        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    // =========================================================================
679    // AGENT DETECTION TESTS
680    // =========================================================================
681
682    /// Helper to create a test repository with specific commit messages
683    fn setup_test_repo_with_messages(
684        repo_dir: &std::path::Path,
685        messages: &[&str],
686    ) -> Result<Vec<String>> {
687        // Ensure repo directory exists
688        std::fs::create_dir_all(repo_dir).context("Failed to create repo directory")?;
689
690        // Initialize repo
691        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        // Configure git
704        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        // Create commits and collect hashes
717        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            // Get the commit hash
736            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        // Verify our constant list has the expected patterns
856        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}