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