Skip to content

Commit a9e8bd5

Browse files
committed
feat: add git worktree support for branch detection
Adds resolveGitDir() to handle both normal repos (.git directory) and worktrees (.git file with gitdir pointer). All git utility functions now correctly resolve the actual git directory, enabling proper branch detection and HEAD path resolution in worktree checkouts.
1 parent 82f24e6 commit a9e8bd5

File tree

2 files changed

+176
-24
lines changed

2 files changed

+176
-24
lines changed

src/git/index.ts

Lines changed: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,63 @@ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
22
import * as path from "path";
33
import { execSync } from "child_process";
44

5+
/**
6+
* Resolves the actual git directory path.
7+
*
8+
* In a normal repo, `.git` is a directory containing HEAD, refs, etc.
9+
* In a worktree, `.git` is a file containing `gitdir: /path/to/actual/git/dir`.
10+
*
11+
* @returns The resolved git directory path, or null if not a git repo
12+
*/
13+
export function resolveGitDir(repoRoot: string): string | null {
14+
const gitPath = path.join(repoRoot, ".git");
15+
16+
if (!existsSync(gitPath)) {
17+
return null;
18+
}
19+
20+
try {
21+
const stat = statSync(gitPath);
22+
23+
if (stat.isDirectory()) {
24+
// Normal repo: .git is a directory
25+
return gitPath;
26+
}
27+
28+
if (stat.isFile()) {
29+
// Worktree: .git is a file with gitdir pointer
30+
const content = readFileSync(gitPath, "utf-8").trim();
31+
const match = content.match(/^gitdir:\s*(.+)$/);
32+
if (match) {
33+
const gitdir = match[1];
34+
// Handle relative paths
35+
const resolvedPath = path.isAbsolute(gitdir)
36+
? gitdir
37+
: path.resolve(repoRoot, gitdir);
38+
39+
if (existsSync(resolvedPath)) {
40+
return resolvedPath;
41+
}
42+
}
43+
}
44+
} catch {
45+
// Ignore errors (permission issues, etc.)
46+
}
47+
48+
return null;
49+
}
50+
551
export function isGitRepo(dir: string): boolean {
6-
return existsSync(path.join(dir, ".git"));
52+
return resolveGitDir(dir) !== null;
753
}
854

955
export function getCurrentBranch(repoRoot: string): string | null {
10-
const headPath = path.join(repoRoot, ".git", "HEAD");
56+
const gitDir = resolveGitDir(repoRoot);
57+
if (!gitDir) {
58+
return null;
59+
}
60+
61+
const headPath = path.join(gitDir, "HEAD");
1162

1263
if (!existsSync(headPath)) {
1364
return null;
@@ -16,13 +67,11 @@ export function getCurrentBranch(repoRoot: string): string | null {
1667
try {
1768
const headContent = readFileSync(headPath, "utf-8").trim();
1869

19-
// Check if it's a symbolic reference (normal branch)
2070
const match = headContent.match(/^ref: refs\/heads\/(.+)$/);
2171
if (match) {
2272
return match[1];
2373
}
2474

25-
// Detached HEAD - return short commit hash
2675
if (/^[0-9a-f]{40}$/i.test(headContent)) {
2776
return headContent.slice(0, 7);
2877
}
@@ -47,30 +96,30 @@ export function getCurrentCommit(repoRoot: string): string | null {
4796
}
4897

4998
export function getBaseBranch(repoRoot: string): string {
50-
// Try to detect the default branch
99+
const gitDir = resolveGitDir(repoRoot);
51100
const candidates = ["main", "master", "develop", "trunk"];
52101

53-
for (const candidate of candidates) {
54-
const refPath = path.join(repoRoot, ".git", "refs", "heads", candidate);
55-
if (existsSync(refPath)) {
56-
return candidate;
57-
}
58-
59-
// Also check packed-refs
60-
const packedRefsPath = path.join(repoRoot, ".git", "packed-refs");
61-
if (existsSync(packedRefsPath)) {
62-
try {
63-
const content = readFileSync(packedRefsPath, "utf-8");
64-
if (content.includes(`refs/heads/${candidate}`)) {
65-
return candidate;
102+
if (gitDir) {
103+
for (const candidate of candidates) {
104+
const refPath = path.join(gitDir, "refs", "heads", candidate);
105+
if (existsSync(refPath)) {
106+
return candidate;
107+
}
108+
109+
const packedRefsPath = path.join(gitDir, "packed-refs");
110+
if (existsSync(packedRefsPath)) {
111+
try {
112+
const content = readFileSync(packedRefsPath, "utf-8");
113+
if (content.includes(`refs/heads/${candidate}`)) {
114+
return candidate;
115+
}
116+
} catch {
117+
// Ignore
66118
}
67-
} catch {
68-
// Ignore
69119
}
70120
}
71121
}
72122

73-
// Try git remote show origin
74123
try {
75124
const result = execSync("git remote show origin", {
76125
cwd: repoRoot,
@@ -85,13 +134,18 @@ export function getBaseBranch(repoRoot: string): string {
85134
// Ignore - remote might not exist
86135
}
87136

88-
// Fallback to current branch or "main"
89137
return getCurrentBranch(repoRoot) ?? "main";
90138
}
91139

92140
export function getAllBranches(repoRoot: string): string[] {
93141
const branches: string[] = [];
94-
const refsPath = path.join(repoRoot, ".git", "refs", "heads");
142+
const gitDir = resolveGitDir(repoRoot);
143+
144+
if (!gitDir) {
145+
return branches;
146+
}
147+
148+
const refsPath = path.join(gitDir, "refs", "heads");
95149

96150
if (!existsSync(refsPath)) {
97151
return branches;
@@ -111,7 +165,6 @@ export function getAllBranches(repoRoot: string): string[] {
111165
}
112166
}
113167
} catch {
114-
// Fallback: read refs directory
115168
try {
116169
const entries = readdirSync(refsPath);
117170
for (const entry of entries) {
@@ -158,5 +211,9 @@ export function getBranchOrDefault(repoRoot: string): string {
158211
}
159212

160213
export function getHeadPath(repoRoot: string): string {
214+
const gitDir = resolveGitDir(repoRoot);
215+
if (gitDir) {
216+
return path.join(gitDir, "HEAD");
217+
}
161218
return path.join(repoRoot, ".git", "HEAD");
162219
}

tests/git.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getAllBranches,
1010
getBranchOrDefault,
1111
getHeadPath,
12+
resolveGitDir,
1213
} from "../src/git/index.js";
1314

1415
describe("git utilities", () => {
@@ -160,4 +161,98 @@ describe("git utilities", () => {
160161
expect(headPath).toBe(path.join(tempDir, ".git", "HEAD"));
161162
});
162163
});
164+
165+
describe("resolveGitDir", () => {
166+
it("should return null for non-git directory", () => {
167+
expect(resolveGitDir(tempDir)).toBe(null);
168+
});
169+
170+
it("should return .git path for normal repo", () => {
171+
fs.mkdirSync(path.join(tempDir, ".git"));
172+
expect(resolveGitDir(tempDir)).toBe(path.join(tempDir, ".git"));
173+
});
174+
175+
it("should follow gitdir pointer for worktree with absolute path", () => {
176+
const mainGitDir = path.join(tempDir, "main-repo", ".git");
177+
const worktreeDir = path.join(tempDir, "worktree");
178+
const worktreeGitDir = path.join(mainGitDir, "worktrees", "feature-branch");
179+
180+
fs.mkdirSync(mainGitDir, { recursive: true });
181+
fs.mkdirSync(worktreeGitDir, { recursive: true });
182+
fs.mkdirSync(worktreeDir, { recursive: true });
183+
184+
fs.writeFileSync(
185+
path.join(worktreeDir, ".git"),
186+
`gitdir: ${worktreeGitDir}\n`
187+
);
188+
fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature-branch\n");
189+
190+
expect(resolveGitDir(worktreeDir)).toBe(worktreeGitDir);
191+
});
192+
193+
it("should follow gitdir pointer for worktree with relative path", () => {
194+
const worktreeDir = path.join(tempDir, "worktree");
195+
const relativeGitDir = "../main-repo/.git/worktrees/feature";
196+
const absoluteGitDir = path.resolve(worktreeDir, relativeGitDir);
197+
198+
fs.mkdirSync(absoluteGitDir, { recursive: true });
199+
fs.mkdirSync(worktreeDir, { recursive: true });
200+
201+
fs.writeFileSync(
202+
path.join(worktreeDir, ".git"),
203+
`gitdir: ${relativeGitDir}\n`
204+
);
205+
fs.writeFileSync(path.join(absoluteGitDir, "HEAD"), "ref: refs/heads/feature\n");
206+
207+
expect(resolveGitDir(worktreeDir)).toBe(absoluteGitDir);
208+
});
209+
210+
it("should return null for invalid gitdir pointer", () => {
211+
fs.writeFileSync(path.join(tempDir, ".git"), "gitdir: /nonexistent/path\n");
212+
expect(resolveGitDir(tempDir)).toBe(null);
213+
});
214+
215+
it("should return null for malformed .git file", () => {
216+
fs.writeFileSync(path.join(tempDir, ".git"), "invalid content\n");
217+
expect(resolveGitDir(tempDir)).toBe(null);
218+
});
219+
});
220+
221+
describe("worktree support", () => {
222+
let mainRepoDir: string;
223+
let worktreeDir: string;
224+
let worktreeGitDir: string;
225+
226+
beforeEach(() => {
227+
mainRepoDir = path.join(tempDir, "main-repo");
228+
worktreeDir = path.join(tempDir, "worktree-feature");
229+
worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature");
230+
231+
fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true });
232+
fs.mkdirSync(worktreeGitDir, { recursive: true });
233+
fs.mkdirSync(worktreeDir, { recursive: true });
234+
235+
fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n");
236+
fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "abc123");
237+
238+
fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`);
239+
fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n");
240+
});
241+
242+
it("isGitRepo should return true for worktree", () => {
243+
expect(isGitRepo(worktreeDir)).toBe(true);
244+
});
245+
246+
it("getCurrentBranch should work in worktree", () => {
247+
expect(getCurrentBranch(worktreeDir)).toBe("feature");
248+
});
249+
250+
it("getHeadPath should return worktree HEAD path", () => {
251+
expect(getHeadPath(worktreeDir)).toBe(path.join(worktreeGitDir, "HEAD"));
252+
});
253+
254+
it("getBranchOrDefault should work in worktree", () => {
255+
expect(getBranchOrDefault(worktreeDir)).toBe("feature");
256+
});
257+
});
163258
});

0 commit comments

Comments
 (0)