Skip to main content

agent_code_lib/services/
git_ops.rs

1//! Git operation detection from command output.
2//!
3//! Parses bash tool output to detect git commits, PR creates,
4//! branch operations, and other git events. Used for tracking
5//! and telemetry.
6
7use regex::Regex;
8use std::sync::LazyLock;
9
10/// A detected git operation.
11#[derive(Debug, Clone)]
12pub struct GitOperation {
13    pub kind: GitOpKind,
14    /// Extracted data (commit SHA, branch name, PR URL, etc.)
15    pub detail: String,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum GitOpKind {
20    Commit,
21    Push,
22    PrCreate,
23    PrMerge,
24    BranchCreate,
25    BranchSwitch,
26    Merge,
27    Rebase,
28    Stash,
29    Tag,
30}
31
32static COMMIT_RE: LazyLock<Regex> =
33    LazyLock::new(|| Regex::new(r"\[(\S+)\s+([a-f0-9]{7,40})\]").unwrap());
34
35static PUSH_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:To\s+\S+|->)\s+(\S+)").unwrap());
36
37static PR_CREATE_RE: LazyLock<Regex> =
38    LazyLock::new(|| Regex::new(r"https://github\.com/[^\s]+/pull/(\d+)").unwrap());
39
40static GH_PR_CREATE_CMD: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r"gh\s+pr\s+create").unwrap());
42
43static GH_PR_MERGE_CMD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"gh\s+pr\s+merge").unwrap());
44
45static BRANCH_CREATE_RE: LazyLock<Regex> =
46    LazyLock::new(|| Regex::new(r"git\s+(?:checkout\s+-b|switch\s+-c)\s+(\S+)").unwrap());
47
48static BRANCH_SWITCH_RE: LazyLock<Regex> =
49    LazyLock::new(|| Regex::new(r"Switched to (?:a new )?branch '(\S+)'").unwrap());
50
51/// Detect git operations from a command string and its output.
52pub fn detect_git_ops(command: &str, output: &str) -> Vec<GitOperation> {
53    let mut ops = Vec::new();
54
55    // Commit detection (from output).
56    if let Some(cap) = COMMIT_RE.captures(output) {
57        ops.push(GitOperation {
58            kind: GitOpKind::Commit,
59            detail: cap
60                .get(2)
61                .map(|m| m.as_str().to_string())
62                .unwrap_or_default(),
63        });
64    }
65
66    // Push detection.
67    if command.contains("git push")
68        && let Some(cap) = PUSH_RE.captures(output)
69    {
70        ops.push(GitOperation {
71            kind: GitOpKind::Push,
72            detail: cap
73                .get(1)
74                .map(|m| m.as_str().to_string())
75                .unwrap_or_default(),
76        });
77    }
78
79    // PR create (from command or output).
80    if GH_PR_CREATE_CMD.is_match(command) {
81        let url = PR_CREATE_RE
82            .find(output)
83            .map(|m| m.as_str().to_string())
84            .unwrap_or_default();
85        ops.push(GitOperation {
86            kind: GitOpKind::PrCreate,
87            detail: url,
88        });
89    }
90
91    // PR merge.
92    if GH_PR_MERGE_CMD.is_match(command) {
93        ops.push(GitOperation {
94            kind: GitOpKind::PrMerge,
95            detail: String::new(),
96        });
97    }
98
99    // Branch create.
100    if let Some(cap) = BRANCH_CREATE_RE.captures(command) {
101        ops.push(GitOperation {
102            kind: GitOpKind::BranchCreate,
103            detail: cap
104                .get(1)
105                .map(|m| m.as_str().to_string())
106                .unwrap_or_default(),
107        });
108    }
109
110    // Branch switch (from output).
111    if let Some(cap) = BRANCH_SWITCH_RE.captures(output) {
112        ops.push(GitOperation {
113            kind: GitOpKind::BranchSwitch,
114            detail: cap
115                .get(1)
116                .map(|m| m.as_str().to_string())
117                .unwrap_or_default(),
118        });
119    }
120
121    // Merge.
122    if command.contains("git merge") {
123        ops.push(GitOperation {
124            kind: GitOpKind::Merge,
125            detail: String::new(),
126        });
127    }
128
129    // Rebase.
130    if command.contains("git rebase") {
131        ops.push(GitOperation {
132            kind: GitOpKind::Rebase,
133            detail: String::new(),
134        });
135    }
136
137    // Stash.
138    if command.contains("git stash") {
139        ops.push(GitOperation {
140            kind: GitOpKind::Stash,
141            detail: String::new(),
142        });
143    }
144
145    // Tag.
146    if command.contains("git tag") {
147        ops.push(GitOperation {
148            kind: GitOpKind::Tag,
149            detail: String::new(),
150        });
151    }
152
153    ops
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_detect_commit() {
162        let ops = detect_git_ops(
163            "git commit -m 'test'",
164            "[main abc1234] test commit\n 1 file changed",
165        );
166        assert_eq!(ops.len(), 1);
167        assert_eq!(ops[0].kind, GitOpKind::Commit);
168        assert_eq!(ops[0].detail, "abc1234");
169    }
170
171    #[test]
172    fn test_detect_pr_create() {
173        let ops = detect_git_ops(
174            "gh pr create --title 'fix'",
175            "https://github.com/owner/repo/pull/42",
176        );
177        assert_eq!(ops.len(), 1);
178        assert_eq!(ops[0].kind, GitOpKind::PrCreate);
179        assert!(ops[0].detail.contains("pull/42"));
180    }
181
182    #[test]
183    fn test_detect_branch_switch() {
184        let ops = detect_git_ops(
185            "git checkout -b feature",
186            "Switched to a new branch 'feature'",
187        );
188        assert!(ops.iter().any(|o| o.kind == GitOpKind::BranchCreate));
189        assert!(ops.iter().any(|o| o.kind == GitOpKind::BranchSwitch));
190    }
191
192    #[test]
193    fn test_detect_push() {
194        let ops = detect_git_ops(
195            "git push origin main",
196            "To github.com:owner/repo.git\n   abc123..def456  main -> main",
197        );
198        assert!(ops.iter().any(|o| o.kind == GitOpKind::Push));
199    }
200
201    #[test]
202    fn test_no_false_positives() {
203        let ops = detect_git_ops("ls -la", "total 42\ndrwxr-xr-x");
204        assert!(ops.is_empty());
205    }
206}