mi6_core/context/
git.rs

1//! Git branch tracking utilities for mi6.
2//!
3//! This module provides functionality to:
4//! - Capture the current git branch from a working directory
5//! - Parse branch names to extract PR/issue numbers
6//! - Detect git branch-changing commands in shell commands
7
8use std::path::Path;
9use std::process::Command;
10
11/// Result of parsing a git branch name for PR/issue numbers.
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct GitBranchInfo {
14    /// The branch name
15    pub branch: String,
16    /// PR number if detected in branch name
17    pub pr_number: Option<i32>,
18    /// Issue number if detected in branch name
19    pub issue_number: Option<i32>,
20}
21
22/// Get the current git branch for a directory.
23///
24/// This runs `git rev-parse --abbrev-ref HEAD` in the specified directory.
25/// Returns `None` if:
26/// - The directory is not a git repository
27/// - git is not installed
28/// - The command fails for any reason
29///
30/// This function is designed to be fast (~20ms) and never block.
31pub fn get_current_branch(cwd: &Path) -> Option<String> {
32    let output = Command::new("git")
33        .args(["rev-parse", "--abbrev-ref", "HEAD"])
34        .current_dir(cwd)
35        .output()
36        .ok()?;
37
38    if !output.status.success() {
39        return None;
40    }
41
42    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
43
44    // "HEAD" means detached HEAD state, which isn't useful
45    if branch.is_empty() || branch == "HEAD" {
46        return None;
47    }
48
49    Some(branch)
50}
51
52/// Parse a branch name to extract PR and issue numbers.
53///
54/// Recognizes common branch naming patterns:
55/// - `feature/issue-123` -> issue_number: 123
56/// - `fix/GH-456` -> issue_number: 456
57/// - `pr/789` -> pr_number: 789
58/// - `fix-123-some-description` -> issue_number: 123
59/// - `issue-42` -> issue_number: 42
60/// - `123-feature` -> issue_number: 123
61/// - `feature/pr-100` -> pr_number: 100
62pub fn parse_branch_info(branch: &str) -> GitBranchInfo {
63    let mut info = GitBranchInfo {
64        branch: branch.to_string(),
65        pr_number: None,
66        issue_number: None,
67    };
68
69    // Normalize branch name for parsing (lowercase, get last component after /)
70    let branch_lower = branch.to_lowercase();
71    let last_component = branch_lower.rsplit('/').next().unwrap_or(&branch_lower);
72
73    // Try to find PR number patterns
74    // pr-123, pr123, pr/123
75    if let Some(num) = extract_prefixed_number(last_component, "pr-")
76        .or_else(|| extract_prefixed_number(last_component, "pr"))
77    {
78        info.pr_number = Some(num);
79        return info;
80    }
81
82    // Try to find issue number patterns (in order of specificity)
83    // issue-123, issue123
84    if let Some(num) = extract_prefixed_number(last_component, "issue-")
85        .or_else(|| extract_prefixed_number(last_component, "issue"))
86    {
87        info.issue_number = Some(num);
88        return info;
89    }
90
91    // gh-123, GH-123
92    if let Some(num) = extract_prefixed_number(last_component, "gh-") {
93        info.issue_number = Some(num);
94        return info;
95    }
96
97    // fix-123, fix/123
98    if let Some(num) = extract_prefixed_number(last_component, "fix-")
99        .or_else(|| extract_prefixed_number(last_component, "fix/"))
100    {
101        info.issue_number = Some(num);
102        return info;
103    }
104
105    // Number at the start: 123-feature
106    if let Some(num) = extract_leading_number(last_component) {
107        info.issue_number = Some(num);
108        return info;
109    }
110
111    // Number at the end after dash: feature-123
112    if let Some(num) = extract_trailing_number(last_component) {
113        info.issue_number = Some(num);
114        return info;
115    }
116
117    info
118}
119
120/// Get git branch info for a directory.
121///
122/// Combines `get_current_branch` and `parse_branch_info` into a single call.
123pub fn get_branch_info(cwd: &Path) -> Option<GitBranchInfo> {
124    get_current_branch(cwd).map(|branch| parse_branch_info(&branch))
125}
126
127/// Check if a shell command might change the git branch.
128///
129/// Returns `true` for commands like:
130/// - `git checkout <branch>`
131/// - `git switch <branch>`
132/// - `git worktree add <path>`
133/// - `git merge <branch>` (in case of fast-forward)
134/// - `git rebase <branch>`
135///
136/// This is a heuristic and may have false positives/negatives.
137pub fn is_branch_changing_command(command: &str) -> bool {
138    let cmd_lower = command.to_lowercase();
139
140    // Split on common command separators to handle chained commands
141    let parts: Vec<&str> = cmd_lower.split(['&', '|', ';']).collect();
142
143    for part in parts {
144        let trimmed = part.trim();
145
146        // Must start with "git"
147        if !trimmed.starts_with("git ") && !trimmed.starts_with("git\t") {
148            continue;
149        }
150
151        // Check for branch-changing subcommands
152        let git_args = trimmed.strip_prefix("git").unwrap_or("").trim();
153
154        // git checkout (not -b which creates a branch without switching context significantly)
155        // but git checkout -b still changes branch, so include it
156        if git_args.starts_with("checkout ") || git_args.starts_with("checkout\t") {
157            return true;
158        }
159
160        // git switch
161        if git_args.starts_with("switch ") || git_args.starts_with("switch\t") {
162            return true;
163        }
164
165        // git worktree add (changes branch in new worktree context)
166        if git_args.starts_with("worktree ")
167            && (git_args.contains("add ") || git_args.contains("add\t"))
168        {
169            return true;
170        }
171
172        // git merge (can change branch via fast-forward)
173        if git_args.starts_with("merge ") || git_args.starts_with("merge\t") {
174            return true;
175        }
176
177        // git rebase (can change branch)
178        if git_args.starts_with("rebase ") || git_args.starts_with("rebase\t") {
179            return true;
180        }
181
182        // git pull (can change branch via merge/rebase)
183        if git_args.starts_with("pull ") || git_args.starts_with("pull\t") || git_args == "pull" {
184            return true;
185        }
186    }
187
188    false
189}
190
191/// Extract a number that follows a prefix.
192/// e.g., extract_prefixed_number("issue-123-fix", "issue-") -> Some(123)
193fn extract_prefixed_number(s: &str, prefix: &str) -> Option<i32> {
194    let after_prefix = s.strip_prefix(prefix)?;
195    extract_leading_number(after_prefix)
196}
197
198/// Extract a number at the start of a string.
199/// e.g., extract_leading_number("123-feature") -> Some(123)
200fn extract_leading_number(s: &str) -> Option<i32> {
201    let num_str: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
202    if num_str.is_empty() {
203        return None;
204    }
205    num_str.parse().ok()
206}
207
208/// Extract a number at the end of a string (after the last dash).
209/// e.g., extract_trailing_number("feature-123") -> Some(123)
210fn extract_trailing_number(s: &str) -> Option<i32> {
211    let last_part = s.rsplit('-').next()?;
212    // Only if the entire last part is a number
213    if last_part.chars().all(|c| c.is_ascii_digit()) && !last_part.is_empty() {
214        return last_part.parse().ok();
215    }
216    None
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_parse_branch_info_issue_patterns() {
225        // issue-N pattern
226        let info = parse_branch_info("feature/issue-123");
227        assert_eq!(info.issue_number, Some(123));
228        assert_eq!(info.pr_number, None);
229
230        // gh-N pattern
231        let info = parse_branch_info("fix/GH-456");
232        assert_eq!(info.issue_number, Some(456));
233
234        // fix-N pattern
235        let info = parse_branch_info("fix-789-description");
236        assert_eq!(info.issue_number, Some(789));
237
238        // Leading number
239        let info = parse_branch_info("123-add-feature");
240        assert_eq!(info.issue_number, Some(123));
241
242        // Trailing number
243        let info = parse_branch_info("feature-add-something-42");
244        assert_eq!(info.issue_number, Some(42));
245    }
246
247    #[test]
248    fn test_parse_branch_info_pr_patterns() {
249        // pr-N pattern
250        let info = parse_branch_info("pr-100");
251        assert_eq!(info.pr_number, Some(100));
252        assert_eq!(info.issue_number, None);
253
254        // With prefix
255        let info = parse_branch_info("feature/pr-200");
256        assert_eq!(info.pr_number, Some(200));
257    }
258
259    #[test]
260    fn test_parse_branch_info_no_number() {
261        let info = parse_branch_info("main");
262        assert_eq!(info.pr_number, None);
263        assert_eq!(info.issue_number, None);
264
265        let info = parse_branch_info("feature/add-new-thing");
266        assert_eq!(info.pr_number, None);
267        assert_eq!(info.issue_number, None);
268    }
269
270    #[test]
271    fn test_is_branch_changing_command() {
272        // Should detect
273        assert!(is_branch_changing_command("git checkout main"));
274        assert!(is_branch_changing_command("git checkout -b feature"));
275        assert!(is_branch_changing_command("git switch develop"));
276        assert!(is_branch_changing_command("git worktree add ../wt main"));
277        assert!(is_branch_changing_command("git merge feature"));
278        assert!(is_branch_changing_command("git rebase main"));
279        assert!(is_branch_changing_command("git pull"));
280        assert!(is_branch_changing_command("git pull origin main"));
281
282        // Chained commands
283        assert!(is_branch_changing_command("echo foo && git checkout main"));
284        assert!(is_branch_changing_command("git status; git checkout main"));
285
286        // Should not detect
287        assert!(!is_branch_changing_command("git status"));
288        assert!(!is_branch_changing_command("git add ."));
289        assert!(!is_branch_changing_command("git commit -m 'test'"));
290        assert!(!is_branch_changing_command("git push"));
291        assert!(!is_branch_changing_command("git log"));
292        assert!(!is_branch_changing_command("git diff"));
293        assert!(!is_branch_changing_command("git branch -a"));
294        assert!(!is_branch_changing_command("echo checkout"));
295        assert!(!is_branch_changing_command("cargo test"));
296    }
297
298    #[test]
299    fn test_get_current_branch_in_git_repo() {
300        // This test assumes we're running in a git repo
301        let Ok(cwd) = std::env::current_dir() else {
302            return; // Skip test if cwd is unavailable
303        };
304        let branch = get_current_branch(&cwd);
305
306        // Should return Some branch name in a git repo
307        // (may be None if running outside a git repo)
308        if let Some(ref b) = branch {
309            assert!(!b.is_empty());
310            assert_ne!(b, "HEAD");
311        }
312    }
313
314    #[test]
315    fn test_get_current_branch_not_git_repo() {
316        // Use a path that definitely doesn't exist to ensure it's not a git repo
317        let branch = get_current_branch(Path::new("/nonexistent/path/that/should/not/exist"));
318        assert!(branch.is_none());
319    }
320}