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//!
8//! # Branch Name Parsing
9//!
10//! The [`parse_branch_info`] function extracts issue and PR numbers from branch names
11//! using common naming conventions.
12//!
13//! ## PR Number Patterns
14//!
15//! | Pattern | Example | Extracted |
16//! |---------|---------|-----------|
17//! | `pr-N` | `pr-100` | pr: 100 |
18//! | `prN` | `feature/pr200` | pr: 200 |
19//!
20//! ## Issue Number Patterns (in priority order)
21//!
22//! | Pattern | Example | Extracted |
23//! |---------|---------|-----------|
24//! | `issue-N` | `feature/issue-123` | issue: 123 |
25//! | `issueN` | `feature/issue456` | issue: 456 |
26//! | `gh-N` | `fix/GH-456` | issue: 456 |
27//! | `fix-N` | `fix-789-description` | issue: 789 |
28//! | Leading number | `123-add-feature` | issue: 123 |
29//! | Trailing number | `feature-something-42` | issue: 42 |
30//!
31//! **Notes:**
32//! - Matching is case-insensitive
33//! - Only the last path component after `/` is parsed (e.g., `feature/issue-123` parses `issue-123`)
34//! - PR patterns are checked before issue patterns
35//! - First matching pattern wins
36//! - Branches like `fix/123` work via the "leading number" pattern (the `fix/` prefix is stripped
37//!   when extracting the last component)
38//!
39//! ## Examples
40//!
41//! ```text
42//! main                        -> (no extraction)
43//! feature/issue-123           -> issue: 123
44//! fix/GH-456                  -> issue: 456
45//! pr-100                      -> pr: 100
46//! feature/pr-200              -> pr: 200
47//! fix-789-description         -> issue: 789
48//! fix/123-some-bug            -> issue: 123 (via leading number)
49//! 123-add-feature             -> issue: 123
50//! feature-add-something-42    -> issue: 42
51//! feature/add-new-thing       -> (no extraction)
52//! ```
53//!
54//! # Branch-Changing Command Detection
55//!
56//! The [`is_branch_changing_command`] function detects git commands that change the
57//! current branch, triggering re-capture of branch info.
58//!
59//! ## Detected Commands
60//!
61//! | Command | Description |
62//! |---------|-------------|
63//! | `git checkout <branch>` | Switch branches |
64//! | `git checkout -b <branch>` | Create and switch |
65//! | `git switch <branch>` | Modern branch switch |
66//! | `git worktree add <path>` | Create worktree |
67//! | `git merge <branch>` | Merge (may fast-forward) |
68//! | `git rebase <branch>` | Rebase onto branch |
69//! | `git pull` | Pull (may merge/rebase) |
70//!
71//! ## Chained Command Support
72//!
73//! Commands are split on `&`, `|`, and `;` to detect branch changes in compound commands:
74//!
75//! ```text
76//! echo foo && git checkout main   -> detected
77//! git status; git checkout main   -> detected
78//! cargo build && git switch dev   -> detected
79//! ```
80//!
81//! # Limitations
82//!
83//! 1. **Branch parsing is heuristic** - May not match all naming conventions
84//! 2. **Chained commands** - Simple split on delimiters; doesn't handle quoted delimiters
85
86use std::fs;
87use std::path::Path;
88
89use super::github::split_chained_commands;
90
91/// Result of parsing a git branch name for PR/issue numbers.
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
93pub struct GitBranchInfo {
94    /// The branch name
95    pub branch: String,
96    /// PR number if detected in branch name
97    pub pr_number: Option<i32>,
98    /// Issue number if detected in branch name
99    pub issue_number: Option<i32>,
100}
101
102/// Get the current git branch for a directory.
103///
104/// Uses direct file system access for speed (~30µs vs ~650µs with libgit2).
105/// Reads the .git/HEAD file and parses the branch reference.
106///
107/// Returns `None` if:
108/// - The directory is not a git repository
109/// - HEAD is detached (not pointing to a branch)
110/// - Any error occurs
111pub fn get_current_branch(cwd: &Path) -> Option<String> {
112    // Find the .git directory (handles worktrees)
113    let git_dir = find_worktree_git_dir(cwd)?;
114
115    // Read HEAD file
116    let head_path = git_dir.join("HEAD");
117    let head_content = fs::read_to_string(head_path).ok()?;
118    let head_content = head_content.trim();
119
120    // Parse HEAD - it's either "ref: refs/heads/branch" or a raw SHA (detached)
121    if let Some(ref_path) = head_content.strip_prefix("ref: ") {
122        // Extract branch name from refs/heads/branch
123        ref_path.strip_prefix("refs/heads/").map(|s| s.to_string())
124    } else {
125        // Detached HEAD - no branch
126        None
127    }
128}
129
130/// Find the git directory for worktrees (used by [`get_current_branch`]).
131///
132/// For worktrees, this returns the **worktree-specific** .git directory
133/// (e.g., `.git/worktrees/branch-name`) which contains the HEAD file
134/// for that worktree.
135///
136/// # Why This Exists Separately from [`find_git_dir`]
137///
138/// This function and [`find_git_dir`] look similar but serve different purposes:
139///
140/// - **This function** (`find_worktree_git_dir`): Returns the worktree-specific
141///   `.git/worktrees/<name>` directory, used to read the worktree's HEAD file.
142///
143/// - **[`find_git_dir`]**: Returns the **main** `.git` directory (going up two
144///   levels from the worktree path), used to read the shared `config` file.
145///
146/// For a normal (non-worktree) repository, both return the same `.git` directory.
147/// For worktrees, they intentionally return different paths. Do not consolidate
148/// these functions without understanding this distinction.
149fn find_worktree_git_dir(cwd: &Path) -> Option<std::path::PathBuf> {
150    let mut current = cwd;
151    loop {
152        let git_path = current.join(".git");
153        if git_path.exists() {
154            if git_path.is_file() {
155                // Worktree: .git file contains "gitdir: /path/to/.git/worktrees/name"
156                let content = fs::read_to_string(&git_path).ok()?;
157                let gitdir = content.strip_prefix("gitdir: ")?.trim();
158                return Some(std::path::PathBuf::from(gitdir));
159            }
160            return Some(git_path);
161        }
162        current = current.parent()?;
163    }
164}
165
166/// Parse a branch name to extract PR and issue numbers.
167///
168/// Recognizes common branch naming patterns:
169/// - `feature/issue-123` -> issue_number: 123
170/// - `fix/GH-456` -> issue_number: 456
171/// - `pr/789` -> pr_number: 789
172/// - `fix-123-some-description` -> issue_number: 123
173/// - `issue-42` -> issue_number: 42
174/// - `123-feature` -> issue_number: 123
175/// - `feature/pr-100` -> pr_number: 100
176pub fn parse_branch_info(branch: &str) -> GitBranchInfo {
177    let mut info = GitBranchInfo {
178        branch: branch.to_string(),
179        pr_number: None,
180        issue_number: None,
181    };
182
183    // Normalize branch name for parsing (lowercase, get last component after /)
184    let branch_lower = branch.to_lowercase();
185    let last_component = branch_lower.rsplit('/').next().unwrap_or(&branch_lower);
186
187    // Try to find PR number patterns
188    // pr-123, pr123, pr/123
189    if let Some(num) = extract_prefixed_number(last_component, "pr-")
190        .or_else(|| extract_prefixed_number(last_component, "pr"))
191    {
192        info.pr_number = Some(num);
193        return info;
194    }
195
196    // Try to find issue number patterns (in order of specificity)
197    // issue-123, issue123
198    if let Some(num) = extract_prefixed_number(last_component, "issue-")
199        .or_else(|| extract_prefixed_number(last_component, "issue"))
200    {
201        info.issue_number = Some(num);
202        return info;
203    }
204
205    // gh-123, GH-123
206    if let Some(num) = extract_prefixed_number(last_component, "gh-") {
207        info.issue_number = Some(num);
208        return info;
209    }
210
211    // fix-123
212    if let Some(num) = extract_prefixed_number(last_component, "fix-") {
213        info.issue_number = Some(num);
214        return info;
215    }
216
217    // Number at the start: 123-feature
218    if let Some(num) = extract_leading_number(last_component) {
219        info.issue_number = Some(num);
220        return info;
221    }
222
223    // Number at the end after dash: feature-123
224    if let Some(num) = extract_trailing_number(last_component) {
225        info.issue_number = Some(num);
226        return info;
227    }
228
229    info
230}
231
232/// Get git branch info for a directory.
233///
234/// Combines `get_current_branch` and `parse_branch_info` into a single call.
235pub fn get_branch_info(cwd: &Path) -> Option<GitBranchInfo> {
236    get_current_branch(cwd).map(|branch| parse_branch_info(&branch))
237}
238
239/// Get the absolute path to the .git directory for a working directory.
240///
241/// Uses direct file system access for speed (~15µs vs ~640µs with libgit2).
242/// Walks up the directory tree to find `.git`, handling both normal repos
243/// and worktrees.
244///
245/// Returns `None` if:
246/// - The directory is not a git repository
247/// - Any error occurs
248///
249/// # Example
250///
251/// ```ignore
252/// use std::path::Path;
253/// use mi6_core::context::get_local_git_dir;
254///
255/// // In a normal repo: returns "/path/to/repo/.git"
256/// let git_dir = get_local_git_dir(Path::new("/path/to/repo"));
257///
258/// // In a worktree: returns "/path/to/main/.git/worktrees/branch-name"
259/// let git_dir = get_local_git_dir(Path::new("/path/to/worktree"));
260/// ```
261pub fn get_local_git_dir(cwd: &Path) -> Option<String> {
262    // Walk up the directory tree to find .git
263    let mut current = cwd;
264    loop {
265        let git_path = current.join(".git");
266        if git_path.exists() {
267            if git_path.is_file() {
268                // Worktree: .git file contains "gitdir: /path/to/.git/worktrees/name"
269                // The path may be relative, so resolve it against the .git file's directory
270                let content = fs::read_to_string(&git_path).ok()?;
271                let gitdir = content.strip_prefix("gitdir: ")?.trim();
272                let gitdir_path = Path::new(gitdir);
273
274                // Resolve relative paths against the directory containing the .git file
275                let resolved = if gitdir_path.is_relative() {
276                    current.join(gitdir_path)
277                } else {
278                    gitdir_path.to_path_buf()
279                };
280
281                // Canonicalize for consistency with normal repos
282                return resolved
283                    .canonicalize()
284                    .ok()
285                    .map(|p| p.to_string_lossy().to_string());
286            }
287            // Normal repo: .git is a directory
288            return git_path
289                .canonicalize()
290                .ok()
291                .map(|p| p.to_string_lossy().to_string());
292        }
293        current = current.parent()?;
294    }
295}
296
297/// Get the worktree root path if the current directory is within a git worktree.
298///
299/// Returns the absolute path to the worktree root (the directory containing
300/// the `.git` file that points to the worktree's git directory).
301///
302/// Returns `None` if:
303/// - The directory is not in a git repository
304/// - The directory is in a normal git repository (not a worktree)
305///
306/// # Example
307///
308/// ```ignore
309/// use std::path::Path;
310/// use mi6_core::context::get_worktree_root;
311///
312/// // In a worktree: returns "/path/to/worktree"
313/// let root = get_worktree_root(Path::new("/path/to/worktree/subdir"));
314///
315/// // In a normal repo: returns None
316/// let root = get_worktree_root(Path::new("/path/to/normal/repo"));
317/// ```
318pub fn get_worktree_root(cwd: &Path) -> Option<String> {
319    let mut current = cwd;
320    loop {
321        let git_path = current.join(".git");
322        if git_path.exists() {
323            if git_path.is_file() {
324                // Worktree: .git is a file, return the directory containing it
325                return current
326                    .canonicalize()
327                    .ok()
328                    .map(|p| p.to_string_lossy().to_string());
329            }
330            // Normal repo: .git is a directory, not a worktree
331            return None;
332        }
333        current = current.parent()?;
334    }
335}
336
337/// Get the GitHub repository from git remote origin.
338///
339/// Uses direct file system access for speed (~30µs vs ~1344µs with libgit2).
340/// Reads the git config file and parses the origin remote URL.
341///
342/// Returns `None` if:
343/// - The directory is not a git repository
344/// - No origin remote is configured
345/// - The origin remote is not a GitHub URL
346///
347/// # Supported URL formats
348///
349/// - SSH: `git@github.com:owner/repo.git`
350/// - HTTPS: `https://github.com/owner/repo.git` or `https://github.com/owner/repo`
351/// - SSH URL: `ssh://git@github.com/owner/repo.git`
352///
353/// # Example
354///
355/// ```ignore
356/// use std::path::Path;
357/// use mi6_core::context::get_github_repo;
358///
359/// let repo = get_github_repo(Path::new("/path/to/git/repo"));
360/// // Returns Some("owner/repo") if valid GitHub remote
361/// ```
362pub fn get_github_repo(cwd: &Path) -> Option<String> {
363    // Find the .git directory (handles worktrees)
364    let git_dir = find_git_dir(cwd)?;
365
366    // Read config file
367    let config_path = git_dir.join("config");
368    let config = fs::read_to_string(config_path).ok()?;
369
370    // Parse for origin URL
371    // Git config is case-insensitive for section/key names but case-sensitive for remote names
372    let mut in_origin = false;
373    for line in config.lines() {
374        let trimmed = line.trim();
375        // Case-insensitive match for section header, but "origin" remote name is case-sensitive
376        if trimmed.eq_ignore_ascii_case("[remote \"origin\"]") {
377            in_origin = true;
378        } else if trimmed.starts_with('[') {
379            in_origin = false;
380        } else if in_origin {
381            // Case-insensitive key match, handle both "url = value" and "url=value"
382            let lower = trimmed.to_ascii_lowercase();
383            if lower.starts_with("url") {
384                // Find the value after "url" and optional whitespace/equals
385                if let Some(eq_pos) = trimmed.find('=') {
386                    let url = trimmed[eq_pos + 1..].trim();
387                    // Strip surrounding quotes if present
388                    let url = url
389                        .strip_prefix('"')
390                        .and_then(|s| s.strip_suffix('"'))
391                        .unwrap_or(url);
392                    return parse_github_repo_from_remote_url(url);
393                }
394            }
395        }
396    }
397
398    None
399}
400
401/// Find the **main** .git directory for a working directory (used by [`get_github_repo`]).
402///
403/// This returns the path to the repository's main `.git` directory, where the
404/// shared `config` file lives. For worktrees, this goes up from the worktree-specific
405/// `.git/worktrees/<name>` to the parent `.git` directory.
406///
407/// # Why This Exists Separately from [`find_worktree_git_dir`]
408///
409/// This function and [`find_worktree_git_dir`] look similar but serve different purposes:
410///
411/// - **This function** (`find_git_dir`): Returns the **main** `.git` directory
412///   (going up two levels from the worktree path), used to read the shared `config` file.
413///
414/// - **[`find_worktree_git_dir`]**: Returns the worktree-specific
415///   `.git/worktrees/<name>` directory, used to read the worktree's HEAD file.
416///
417/// For a normal (non-worktree) repository, both return the same `.git` directory.
418/// For worktrees, they intentionally return different paths. Do not consolidate
419/// these functions without understanding this distinction.
420fn find_git_dir(cwd: &Path) -> Option<std::path::PathBuf> {
421    let mut current = cwd;
422    loop {
423        let git_path = current.join(".git");
424        if git_path.exists() {
425            if git_path.is_file() {
426                // Worktree: .git file contains "gitdir: /path/to/.git/worktrees/name"
427                // The path may be relative, so resolve it against the .git file's directory
428                let content = fs::read_to_string(&git_path).ok()?;
429                let gitdir = content.strip_prefix("gitdir: ")?.trim();
430                let gitdir_path = Path::new(gitdir);
431
432                // Resolve relative paths against the directory containing the .git file
433                let resolved = if gitdir_path.is_relative() {
434                    current.join(gitdir_path)
435                } else {
436                    gitdir_path.to_path_buf()
437                };
438
439                // For worktrees, config is in the main .git directory
440                // The worktree .git points to .git/worktrees/name, we need .git
441                // Go up two levels: .git/worktrees/name -> .git
442                return resolved.parent()?.parent().map(|p| p.to_path_buf());
443            }
444            return Some(git_path);
445        }
446        current = current.parent()?;
447    }
448}
449
450/// Parse GitHub repo from a git remote URL.
451///
452/// Extracts the `owner/repo` format from various GitHub URL formats.
453/// Returns `None` if the URL is not a recognized GitHub format.
454///
455/// # Supported formats
456///
457/// - SSH: `git@github.com:owner/repo.git` -> `owner/repo`
458/// - HTTPS: `https://github.com/owner/repo.git` -> `owner/repo`
459/// - HTTPS (no .git): `https://github.com/owner/repo` -> `owner/repo`
460/// - SSH URL: `ssh://git@github.com/owner/repo.git` -> `owner/repo`
461///
462/// # Example
463///
464/// ```
465/// use mi6_core::context::parse_github_repo_from_remote_url;
466///
467/// assert_eq!(
468///     parse_github_repo_from_remote_url("git@github.com:owner/repo.git"),
469///     Some("owner/repo".to_string())
470/// );
471/// assert_eq!(
472///     parse_github_repo_from_remote_url("https://github.com/owner/repo"),
473///     Some("owner/repo".to_string())
474/// );
475/// ```
476pub fn parse_github_repo_from_remote_url(url: &str) -> Option<String> {
477    // SSH format: git@github.com:owner/repo.git
478    if let Some(rest) = url.strip_prefix("git@github.com:") {
479        let repo = rest.trim_end_matches(".git");
480        return Some(repo.to_string());
481    }
482
483    // HTTPS format: https://github.com/owner/repo.git or https://github.com/owner/repo
484    if let Some(rest) = url
485        .strip_prefix("https://github.com/")
486        .or_else(|| url.strip_prefix("http://github.com/"))
487    {
488        let repo = rest.trim_end_matches(".git");
489        return Some(repo.to_string());
490    }
491
492    // SSH URL format: ssh://git@github.com/owner/repo.git
493    if let Some(rest) = url.strip_prefix("ssh://git@github.com/") {
494        let repo = rest.trim_end_matches(".git");
495        return Some(repo.to_string());
496    }
497
498    None
499}
500
501/// Check if a shell command might change the git branch.
502///
503/// Returns `true` for commands like:
504/// - `git checkout <branch>`
505/// - `git switch <branch>`
506/// - `git worktree add <path>`
507/// - `git merge <branch>` (in case of fast-forward)
508/// - `git rebase <branch>`
509///
510/// This is a heuristic and may have false positives/negatives.
511pub fn is_branch_changing_command(command: &str) -> bool {
512    let cmd_lower = command.to_lowercase();
513
514    for part in split_chained_commands(&cmd_lower) {
515        let trimmed = part.trim();
516
517        // Must start with "git"
518        if !trimmed.starts_with("git ") && !trimmed.starts_with("git\t") {
519            continue;
520        }
521
522        // Check for branch-changing subcommands
523        let git_args = trimmed.strip_prefix("git").unwrap_or("").trim();
524
525        // git checkout (not -b which creates a branch without switching context significantly)
526        // but git checkout -b still changes branch, so include it
527        if git_args.starts_with("checkout ") || git_args.starts_with("checkout\t") {
528            return true;
529        }
530
531        // git switch
532        if git_args.starts_with("switch ") || git_args.starts_with("switch\t") {
533            return true;
534        }
535
536        // git worktree add (changes branch in new worktree context)
537        if git_args.starts_with("worktree ")
538            && (git_args.contains("add ") || git_args.contains("add\t"))
539        {
540            return true;
541        }
542
543        // git merge (can change branch via fast-forward)
544        if git_args.starts_with("merge ") || git_args.starts_with("merge\t") {
545            return true;
546        }
547
548        // git rebase (can change branch)
549        if git_args.starts_with("rebase ") || git_args.starts_with("rebase\t") {
550            return true;
551        }
552
553        // git pull (can change branch via merge/rebase)
554        if git_args.starts_with("pull ") || git_args.starts_with("pull\t") || git_args == "pull" {
555            return true;
556        }
557    }
558
559    false
560}
561
562/// Extract a number that follows a prefix.
563/// e.g., extract_prefixed_number("issue-123-fix", "issue-") -> Some(123)
564fn extract_prefixed_number(s: &str, prefix: &str) -> Option<i32> {
565    let after_prefix = s.strip_prefix(prefix)?;
566    extract_leading_number(after_prefix)
567}
568
569/// Extract a number at the start of a string.
570/// e.g., extract_leading_number("123-feature") -> Some(123)
571fn extract_leading_number(s: &str) -> Option<i32> {
572    let num_str: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
573    if num_str.is_empty() {
574        return None;
575    }
576    num_str.parse().ok()
577}
578
579/// Extract a number at the end of a string (after the last dash).
580/// e.g., extract_trailing_number("feature-123") -> Some(123)
581fn extract_trailing_number(s: &str) -> Option<i32> {
582    let last_part = s.rsplit('-').next()?;
583    // Only if the entire last part is a number
584    if last_part.chars().all(|c| c.is_ascii_digit()) && !last_part.is_empty() {
585        return last_part.parse().ok();
586    }
587    None
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn test_parse_branch_info_issue_patterns() {
596        // issue-N pattern
597        let info = parse_branch_info("feature/issue-123");
598        assert_eq!(info.issue_number, Some(123));
599        assert_eq!(info.pr_number, None);
600
601        // gh-N pattern
602        let info = parse_branch_info("fix/GH-456");
603        assert_eq!(info.issue_number, Some(456));
604
605        // fix-N pattern
606        let info = parse_branch_info("fix-789-description");
607        assert_eq!(info.issue_number, Some(789));
608
609        // fix/N pattern (works via leading number after splitting on /)
610        let info = parse_branch_info("fix/123-some-bug");
611        assert_eq!(info.issue_number, Some(123));
612
613        // Leading number
614        let info = parse_branch_info("123-add-feature");
615        assert_eq!(info.issue_number, Some(123));
616
617        // Trailing number
618        let info = parse_branch_info("feature-add-something-42");
619        assert_eq!(info.issue_number, Some(42));
620    }
621
622    #[test]
623    fn test_parse_branch_info_pr_patterns() {
624        // pr-N pattern
625        let info = parse_branch_info("pr-100");
626        assert_eq!(info.pr_number, Some(100));
627        assert_eq!(info.issue_number, None);
628
629        // With prefix
630        let info = parse_branch_info("feature/pr-200");
631        assert_eq!(info.pr_number, Some(200));
632    }
633
634    #[test]
635    fn test_parse_branch_info_no_number() {
636        let info = parse_branch_info("main");
637        assert_eq!(info.pr_number, None);
638        assert_eq!(info.issue_number, None);
639
640        let info = parse_branch_info("feature/add-new-thing");
641        assert_eq!(info.pr_number, None);
642        assert_eq!(info.issue_number, None);
643    }
644
645    #[test]
646    fn test_is_branch_changing_command() {
647        // Should detect
648        assert!(is_branch_changing_command("git checkout main"));
649        assert!(is_branch_changing_command("git checkout -b feature"));
650        assert!(is_branch_changing_command("git switch develop"));
651        assert!(is_branch_changing_command("git worktree add ../wt main"));
652        assert!(is_branch_changing_command("git merge feature"));
653        assert!(is_branch_changing_command("git rebase main"));
654        assert!(is_branch_changing_command("git pull"));
655        assert!(is_branch_changing_command("git pull origin main"));
656
657        // Chained commands
658        assert!(is_branch_changing_command("echo foo && git checkout main"));
659        assert!(is_branch_changing_command("git status; git checkout main"));
660
661        // Should not detect
662        assert!(!is_branch_changing_command("git status"));
663        assert!(!is_branch_changing_command("git add ."));
664        assert!(!is_branch_changing_command("git commit -m 'test'"));
665        assert!(!is_branch_changing_command("git push"));
666        assert!(!is_branch_changing_command("git log"));
667        assert!(!is_branch_changing_command("git diff"));
668        assert!(!is_branch_changing_command("git branch -a"));
669        assert!(!is_branch_changing_command("echo checkout"));
670        assert!(!is_branch_changing_command("cargo test"));
671    }
672
673    #[test]
674    fn test_get_current_branch_in_git_repo() {
675        // This test assumes we're running in a git repo
676        let Ok(cwd) = std::env::current_dir() else {
677            return; // Skip test if cwd is unavailable
678        };
679        let branch = get_current_branch(&cwd);
680
681        // Should return Some branch name in a git repo
682        // (may be None if running outside a git repo)
683        if let Some(ref b) = branch {
684            assert!(!b.is_empty());
685            assert_ne!(b, "HEAD");
686        }
687    }
688
689    #[test]
690    fn test_get_current_branch_not_git_repo() {
691        // Use a path that definitely doesn't exist to ensure it's not a git repo
692        let branch = get_current_branch(Path::new("/nonexistent/path/that/should/not/exist"));
693        assert!(branch.is_none());
694    }
695
696    #[test]
697    fn test_parse_github_repo_ssh() {
698        assert_eq!(
699            parse_github_repo_from_remote_url("git@github.com:owner/repo.git"),
700            Some("owner/repo".to_string())
701        );
702        assert_eq!(
703            parse_github_repo_from_remote_url("git@github.com:paradigmxyz/mi6.git"),
704            Some("paradigmxyz/mi6".to_string())
705        );
706    }
707
708    #[test]
709    fn test_parse_github_repo_https() {
710        assert_eq!(
711            parse_github_repo_from_remote_url("https://github.com/owner/repo.git"),
712            Some("owner/repo".to_string())
713        );
714        assert_eq!(
715            parse_github_repo_from_remote_url("https://github.com/owner/repo"),
716            Some("owner/repo".to_string())
717        );
718        assert_eq!(
719            parse_github_repo_from_remote_url("http://github.com/owner/repo.git"),
720            Some("owner/repo".to_string())
721        );
722    }
723
724    #[test]
725    fn test_parse_github_repo_ssh_url() {
726        assert_eq!(
727            parse_github_repo_from_remote_url("ssh://git@github.com/owner/repo.git"),
728            Some("owner/repo".to_string())
729        );
730        assert_eq!(
731            parse_github_repo_from_remote_url("ssh://git@github.com/owner/repo"),
732            Some("owner/repo".to_string())
733        );
734    }
735
736    #[test]
737    fn test_parse_github_repo_non_github() {
738        // Non-GitHub URLs should return None
739        assert_eq!(
740            parse_github_repo_from_remote_url("git@gitlab.com:owner/repo.git"),
741            None
742        );
743        assert_eq!(
744            parse_github_repo_from_remote_url("https://gitlab.com/owner/repo"),
745            None
746        );
747        assert_eq!(
748            parse_github_repo_from_remote_url("git@bitbucket.org:owner/repo.git"),
749            None
750        );
751    }
752
753    #[test]
754    fn test_get_github_repo_in_git_repo() {
755        // This test assumes we're running in a git repo with GitHub origin
756        let Ok(cwd) = std::env::current_dir() else {
757            return; // Skip test if cwd is unavailable
758        };
759        let repo = get_github_repo(&cwd);
760
761        // Should return Some repo name if in a GitHub repo
762        // May return None if not in a git repo or no GitHub origin
763        if let Some(ref r) = repo {
764            assert!(!r.is_empty());
765            assert!(r.contains('/'), "repo should be in owner/repo format");
766        }
767    }
768
769    #[test]
770    fn test_get_github_repo_not_git_repo() {
771        // Use a path that definitely doesn't exist to ensure it's not a git repo
772        let repo = get_github_repo(Path::new("/nonexistent/path/that/should/not/exist"));
773        assert!(repo.is_none());
774    }
775
776    #[test]
777    fn test_get_local_git_dir_in_git_repo() {
778        // This test assumes we're running in a git repo
779        let Ok(cwd) = std::env::current_dir() else {
780            return; // Skip test if cwd is unavailable
781        };
782        let git_dir = get_local_git_dir(&cwd);
783
784        // Should return Some path if in a git repo
785        if let Some(ref path) = git_dir {
786            assert!(!path.is_empty());
787            // Should be an absolute path
788            assert!(
789                Path::new(path).is_absolute(),
790                "git_dir should be absolute: {}",
791                path
792            );
793            // Should contain ".git" in the path
794            assert!(
795                path.contains(".git"),
796                "git_dir should contain .git: {}",
797                path
798            );
799        }
800    }
801
802    #[test]
803    fn test_get_local_git_dir_not_git_repo() {
804        // Use a path that definitely doesn't exist to ensure it's not a git repo
805        let git_dir = get_local_git_dir(Path::new("/nonexistent/path/that/should/not/exist"));
806        assert!(git_dir.is_none());
807    }
808
809    #[test]
810    fn test_get_worktree_root_not_git_repo() {
811        // Use a path that definitely doesn't exist to ensure it's not a git repo
812        let root = get_worktree_root(Path::new("/nonexistent/path/that/should/not/exist"));
813        assert!(root.is_none());
814    }
815
816    #[test]
817    fn test_get_worktree_root_normal_repo() {
818        // In a normal git repo (not a worktree), should return None
819        // This test assumes we're running in a git repo
820        let Ok(cwd) = std::env::current_dir() else {
821            return; // Skip test if cwd is unavailable
822        };
823
824        // First check if we're in a git repo at all
825        let git_dir = get_local_git_dir(&cwd);
826        if git_dir.is_none() {
827            return; // Skip test if not in a git repo
828        }
829
830        // Check if cwd's .git is a directory (normal repo) or file (worktree)
831        // We need to find the .git entry to determine which type we're in
832        let mut current = cwd.as_path();
833        loop {
834            let git_path = current.join(".git");
835            if git_path.exists() {
836                if git_path.is_dir() {
837                    // Normal repo - get_worktree_root should return None
838                    assert!(
839                        get_worktree_root(&cwd).is_none(),
840                        "get_worktree_root should return None in normal repo"
841                    );
842                } else {
843                    // Worktree - get_worktree_root should return Some
844                    let root = get_worktree_root(&cwd);
845                    assert!(
846                        root.is_some(),
847                        "get_worktree_root should return Some in worktree"
848                    );
849                    let root_path = root.unwrap();
850                    assert!(
851                        Path::new(&root_path).is_absolute(),
852                        "worktree root should be absolute: {}",
853                        root_path
854                    );
855                }
856                break;
857            }
858            match current.parent() {
859                Some(parent) => current = parent,
860                None => break,
861            }
862        }
863    }
864}