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}