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}