@@ -13,9 +13,28 @@ use crate::cli::{config, run};
1313static DEFAULT_BRANCH : std:: sync:: OnceLock < std:: sync:: Mutex < HashMap < PathBuf , String > > > =
1414 std:: sync:: OnceLock :: new ( ) ;
1515
16- /// Returns `true` if `workspace_root` contains a `.git` directory or file.
16+ /// Walks up the directory tree from `path` and returns the first ancestor
17+ /// (inclusive) that contains a `.git` entry, or `None` if none is found.
18+ ///
19+ /// This mirrors the discovery behaviour of the `git` binary itself: a
20+ /// workspace that lives inside a larger repository (i.e. `.git` is in a
21+ /// parent directory) is still considered part of that repository.
22+ pub fn find_git_root ( path : & Path ) -> Option < PathBuf > {
23+ let mut dir = path;
24+ loop {
25+ if dir. join ( ".git" ) . exists ( ) {
26+ return Some ( dir. to_path_buf ( ) ) ;
27+ }
28+ dir = dir. parent ( ) ?;
29+ }
30+ }
31+
32+ /// Returns `true` if `workspace_root` is inside a git repository.
33+ ///
34+ /// The `.git` directory may be in any ancestor of `workspace_root`, not
35+ /// necessarily in `workspace_root` itself.
1736pub fn is_git_repo ( workspace_root : & Path ) -> bool {
18- workspace_root . join ( ".git" ) . exists ( )
37+ find_git_root ( workspace_root ) . is_some ( )
1938}
2039
2140/// Initialises a git repository at `workspace_root` if one does not already
@@ -62,23 +81,121 @@ pub async fn has_remote(workspace_root: &Path) -> bool {
6281
6382/// Resolves the actual git directory for `root`.
6483///
65- /// For a regular repo, this is `root/.git/`.
66- /// For a git worktree, `root/.git` is a file containing `gitdir: <path>` —
67- /// we read that path so callers can find worktree-specific state.
84+ /// For a regular repo, this is `<git-root>/.git/` where `<git-root>` may be
85+ /// `root` itself or any ancestor (workspaces in a repo subfolder).
86+ /// For a git worktree, `<git-root>/.git` is a file containing `gitdir: <path>` —
87+ /// we follow the pointer so callers find the per-worktree state directory.
88+ /// This covers both "worktree at root" and "subfolder of a worktree".
6889pub ( crate ) fn resolve_git_dir ( root : & Path ) -> PathBuf {
69- let dot_git = root. join ( ".git" ) ;
70- if dot_git. is_file ( )
71- && let Ok ( content) = std:: fs:: read_to_string ( & dot_git)
72- && let Some ( rel) = content. trim ( ) . strip_prefix ( "gitdir: " )
73- {
74- let resolved = root. join ( rel) ;
75- if let Ok ( canonical) = resolved. canonicalize ( ) {
76- return canonical;
90+ let Some ( git_root) = find_git_root ( root) else {
91+ return root. join ( ".git" ) ;
92+ } ;
93+ let dot_git = git_root. join ( ".git" ) ;
94+ follow_dot_git ( & git_root, dot_git)
95+ }
96+
97+ /// Given a `<dir>/.git` path, returns the actual git object directory.
98+ ///
99+ /// For a linked worktree, `.git` is a file containing `gitdir: <rel>` —
100+ /// the pointer is followed. For a regular checkout, `.git` is a directory
101+ /// and is returned unchanged.
102+ fn follow_dot_git ( dir : & Path , dot_git : PathBuf ) -> PathBuf {
103+ if dot_git. is_file ( ) {
104+ if let Ok ( content) = std:: fs:: read_to_string ( & dot_git) {
105+ if let Some ( rel) = content. trim ( ) . strip_prefix ( "gitdir: " ) {
106+ let resolved = dir. join ( rel) ;
107+ if let Ok ( canonical) = resolved. canonicalize ( ) {
108+ return canonical;
109+ }
110+ }
77111 }
78112 }
79113 dot_git
80114}
81115
116+ #[ cfg( test) ]
117+ mod tests {
118+ use super :: * ;
119+ use std:: fs;
120+ use tempfile:: TempDir ;
121+
122+ #[ test]
123+ fn find_git_root_directly_at_path ( ) {
124+ let dir = TempDir :: new ( ) . unwrap ( ) ;
125+ fs:: create_dir ( dir. path ( ) . join ( ".git" ) ) . unwrap ( ) ;
126+ assert_eq ! ( find_git_root( dir. path( ) ) , Some ( dir. path( ) . to_path_buf( ) ) ) ;
127+ }
128+
129+ #[ test]
130+ fn find_git_root_in_subfolder ( ) {
131+ let dir = TempDir :: new ( ) . unwrap ( ) ;
132+ fs:: create_dir ( dir. path ( ) . join ( ".git" ) ) . unwrap ( ) ;
133+ let sub = dir. path ( ) . join ( "workspace" ) . join ( "nested" ) ;
134+ fs:: create_dir_all ( & sub) . unwrap ( ) ;
135+ assert_eq ! ( find_git_root( & sub) , Some ( dir. path( ) . to_path_buf( ) ) ) ;
136+ }
137+
138+ #[ test]
139+ fn find_git_root_no_repo ( ) {
140+ let dir = TempDir :: new ( ) . unwrap ( ) ;
141+ assert_eq ! ( find_git_root( dir. path( ) ) , None ) ;
142+ }
143+
144+ #[ test]
145+ fn is_git_repo_in_subfolder ( ) {
146+ let dir = TempDir :: new ( ) . unwrap ( ) ;
147+ fs:: create_dir ( dir. path ( ) . join ( ".git" ) ) . unwrap ( ) ;
148+ let sub = dir. path ( ) . join ( "oxy-workspace" ) ;
149+ fs:: create_dir ( & sub) . unwrap ( ) ;
150+ assert ! ( is_git_repo( & sub) ) ;
151+ }
152+
153+ #[ test]
154+ fn is_git_repo_false_outside_repo ( ) {
155+ // Parent of a temp dir that has no .git anywhere in the chain
156+ // (the temp dir itself has no .git either).
157+ let dir = TempDir :: new ( ) . unwrap ( ) ;
158+ assert ! ( !is_git_repo( dir. path( ) ) ) ;
159+ }
160+
161+ #[ test]
162+ fn resolve_git_dir_follows_worktree_file_in_subfolder ( ) {
163+ // Layout: repo/.git/ (real dir)
164+ // repo/.worktrees/feat/.git → file pointing to real gitdir
165+ // repo/.worktrees/feat/sub/workspace (subfolder of linked worktree)
166+ let dir = TempDir :: new ( ) . unwrap ( ) ;
167+ let real_gitdir = dir. path ( ) . join ( ".git" ) ;
168+ let worktree_gitdir = real_gitdir. join ( "worktrees" ) . join ( "feat" ) ;
169+ fs:: create_dir_all ( & worktree_gitdir) . unwrap ( ) ;
170+
171+ let worktree_root = dir. path ( ) . join ( ".worktrees" ) . join ( "feat" ) ;
172+ fs:: create_dir_all ( & worktree_root) . unwrap ( ) ;
173+ // .git file uses a relative path back to the real gitdir
174+ let pointer_content = format ! (
175+ "gitdir: {}" ,
176+ worktree_gitdir
177+ . strip_prefix( & worktree_root)
178+ . unwrap_or( & worktree_gitdir)
179+ . display( )
180+ ) ;
181+ // Use the absolute path for simplicity in the test
182+ fs:: write (
183+ worktree_root. join ( ".git" ) ,
184+ format ! ( "gitdir: {}" , worktree_gitdir. display( ) ) ,
185+ )
186+ . unwrap ( ) ;
187+ let _ = pointer_content;
188+
189+ let sub = worktree_root. join ( "sub" ) . join ( "workspace" ) ;
190+ fs:: create_dir_all ( & sub) . unwrap ( ) ;
191+
192+ // resolve_git_dir on the subfolder must follow the gitdir pointer,
193+ // not return the .git file path directly.
194+ let resolved = resolve_git_dir ( & sub) ;
195+ assert_eq ! ( resolved, worktree_gitdir) ;
196+ }
197+ }
198+
82199/// Returns the default branch name for `workspace_root`.
83200///
84201/// Resolution order:
0 commit comments