Skip to content

Commit e443228

Browse files
crrowbotcrrow
authored andcommitted
Fix separate-git-dir worktree entry detection
1 parent c7b10cb commit e443228

2 files changed

Lines changed: 139 additions & 11 deletions

File tree

internal/worktree/git.go

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ func List() ([]Entry, error) {
8383

8484
// Detect current working directory to mark the active worktree
8585
cwd, _ := os.Getwd()
86+
currentGitDir := currentWorktreeGitDir()
8687

8788
var entries []Entry
8889
var cur Entry
@@ -91,10 +92,11 @@ func List() ([]Entry, error) {
9192

9293
// finalizeEntry fills computed fields and returns the entry ready for collection.
9394
finalizeEntry := func(e Entry) Entry {
94-
e.IsMain = isMainWorktree(e.Path)
95+
entryGitDir := entryGitDir(e.Path)
96+
e.IsMain = entryGitDir != "" && !isLinkedGitDir(entryGitDir)
9597
e.Prunable = prunable
9698
e.Locked = locked
97-
e.IsCurrent = isSameOrChild(cwd, e.Path)
99+
e.IsCurrent = isSameOrChild(cwd, e.Path) || samePath(currentGitDir, entryGitDir)
98100
e.Status = classifyEntry(&e, merged)
99101
// Populate computed fields for non-prunable entries with existing paths
100102
if !e.Prunable {
@@ -168,12 +170,15 @@ func lastActiveTime(path string) time.Time {
168170
var latest time.Time
169171

170172
// Resolve the actual git directory (handles both main checkout and linked worktrees)
171-
gitDir := resolveGitDir(path)
172-
candidates := []string{
173-
filepath.Join(gitDir, "HEAD"),
174-
filepath.Join(gitDir, "index"),
175-
filepath.Join(path, ".git"), // mtime of .git itself (file or dir)
173+
gitDir := entryGitDir(path)
174+
var candidates []string
175+
if gitDir != "" {
176+
candidates = append(candidates,
177+
filepath.Join(gitDir, "HEAD"),
178+
filepath.Join(gitDir, "index"),
179+
)
176180
}
181+
candidates = append(candidates, filepath.Join(path, ".git")) // mtime of .git itself (file or dir)
177182
for _, c := range candidates {
178183
if info, err := os.Stat(c); err == nil {
179184
if info.ModTime().After(latest) {
@@ -190,6 +195,31 @@ func lastActiveTime(path string) time.Time {
190195
return latest
191196
}
192197

198+
func currentWorktreeGitDir() string {
199+
out, err := exec.CommandContext(context.Background(), "git", "rev-parse", "--absolute-git-dir").Output()
200+
if err != nil {
201+
return ""
202+
}
203+
return strings.TrimSpace(string(out))
204+
}
205+
206+
func entryGitDir(path string) string {
207+
if isGitDir(path) {
208+
return filepath.Clean(path)
209+
}
210+
dotGit := filepath.Join(path, ".git")
211+
if _, err := os.Stat(dotGit); err != nil {
212+
return ""
213+
}
214+
return resolveGitDir(path)
215+
}
216+
217+
func isGitDir(path string) bool {
218+
headInfo, headErr := os.Stat(filepath.Join(path, "HEAD"))
219+
configInfo, configErr := os.Stat(filepath.Join(path, "config"))
220+
return headErr == nil && !headInfo.IsDir() && configErr == nil && !configInfo.IsDir()
221+
}
222+
193223
// resolveGitDir returns the path to the actual git directory for a worktree.
194224
// The worktree's .git metadata may be either a directory or a file containing
195225
// "gitdir: <path>" that points to the actual git metadata directory.
@@ -219,10 +249,11 @@ func resolveGitDir(worktreePath string) string {
219249
}
220250

221251
func isMainWorktree(worktreePath string) bool {
222-
if _, err := os.Stat(filepath.Join(worktreePath, ".git")); err != nil {
252+
gitDir := entryGitDir(worktreePath)
253+
if gitDir == "" {
223254
return false
224255
}
225-
return !isLinkedGitDir(resolveGitDir(worktreePath))
256+
return !isLinkedGitDir(gitDir)
226257
}
227258

228259
func isLinkedGitDir(gitDir string) bool {
@@ -253,6 +284,18 @@ func isSameOrChild(child, parent string) bool {
253284
return c == p || strings.HasPrefix(c, p+string(os.PathSeparator))
254285
}
255286

287+
func samePath(a, b string) bool {
288+
if a == "" || b == "" {
289+
return false
290+
}
291+
x, err1 := filepath.EvalSymlinks(a)
292+
y, err2 := filepath.EvalSymlinks(b)
293+
if err1 != nil || err2 != nil {
294+
return filepath.Clean(a) == filepath.Clean(b)
295+
}
296+
return x == y
297+
}
298+
256299
// MergedBranches returns branch names that are fully merged into main.
257300
func MergedBranches() (map[string]bool, error) {
258301
ctx, cancel := gitContext()

internal/worktree/git_test.go

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package worktree
22

33
import (
44
"os"
5+
"os/exec"
56
"path/filepath"
7+
"strings"
68
"testing"
79
"time"
810
)
@@ -202,8 +204,6 @@ func TestEntryProtected(t *testing.T) {
202204
}
203205

204206
func TestIsMainWorktree(t *testing.T) {
205-
t.Parallel()
206-
207207
root := t.TempDir()
208208
mainPath := filepath.Join(root, "main")
209209
separateMainPath := filepath.Join(root, "separate-main")
@@ -224,6 +224,12 @@ func TestIsMainWorktree(t *testing.T) {
224224
if err := os.MkdirAll(linkedGitDir, 0o750); err != nil {
225225
t.Fatalf("mkdir linked gitdir: %v", err)
226226
}
227+
if err := os.WriteFile(filepath.Join(separateGitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o600); err != nil {
228+
t.Fatalf("write separate gitdir HEAD: %v", err)
229+
}
230+
if err := os.WriteFile(filepath.Join(separateGitDir, "config"), []byte("[core]\n"), 0o600); err != nil {
231+
t.Fatalf("write separate gitdir config: %v", err)
232+
}
227233
if err := os.WriteFile(filepath.Join(separateMainPath, ".git"), []byte("gitdir: "+separateGitDir+"\n"), 0o600); err != nil {
228234
t.Fatalf("write separate main .git file: %v", err)
229235
}
@@ -237,10 +243,89 @@ func TestIsMainWorktree(t *testing.T) {
237243
if !isMainWorktree(separateMainPath) {
238244
t.Fatalf("expected %q with separate git dir to be detected as the main worktree", separateMainPath)
239245
}
246+
if !isMainWorktree(separateGitDir) {
247+
t.Fatalf("expected %q gitdir path to be detected as the main worktree entry", separateGitDir)
248+
}
240249
if isMainWorktree(linkedPath) {
241250
t.Fatalf("expected %q to be detected as a linked worktree", linkedPath)
242251
}
243252
if isMainWorktree(missingPath) {
244253
t.Fatalf("expected %q without .git metadata to be non-main", missingPath)
245254
}
246255
}
256+
257+
func TestListProtectsSeparateGitDirMainWorktree(t *testing.T) {
258+
root := t.TempDir()
259+
mainPath := filepath.Join(root, "main")
260+
gitDir := filepath.Join(root, "repo.git")
261+
linkedPath := filepath.Join(root, "feature")
262+
263+
runGit(t, root, "init", "-b", "main", "--separate-git-dir", gitDir, mainPath)
264+
runGit(t, mainPath, "config", "user.name", "Test User")
265+
runGit(t, mainPath, "config", "user.email", "test@example.com")
266+
267+
if err := os.WriteFile(filepath.Join(mainPath, "README.md"), []byte("hello\n"), 0o600); err != nil {
268+
t.Fatalf("write README.md: %v", err)
269+
}
270+
runGit(t, mainPath, "add", "README.md")
271+
runGit(t, mainPath, "commit", "-m", "initial commit")
272+
runGit(t, mainPath, "worktree", "add", "-b", "feature", linkedPath, "HEAD")
273+
274+
t.Chdir(mainPath)
275+
276+
entries, err := List()
277+
if err != nil {
278+
t.Fatalf("List(): %v", err)
279+
}
280+
281+
var mainEntry *Entry
282+
var linkedEntry *Entry
283+
for i := range entries {
284+
e := &entries[i]
285+
switch {
286+
case e.IsMain:
287+
mainEntry = e
288+
case samePath(e.Path, linkedPath):
289+
linkedEntry = e
290+
}
291+
}
292+
293+
if mainEntry == nil {
294+
t.Fatalf("expected a main worktree entry, got %+v", entries)
295+
}
296+
if !samePath(mainEntry.Path, gitDir) {
297+
t.Fatalf("expected main entry path %q from git porcelain, got %q", gitDir, mainEntry.Path)
298+
}
299+
if !mainEntry.IsCurrent {
300+
t.Fatalf("expected separate-git-dir main entry to be current: %+v", *mainEntry)
301+
}
302+
if !mainEntry.Protected() {
303+
t.Fatalf("expected separate-git-dir main entry to be protected: %+v", *mainEntry)
304+
}
305+
merged := map[string]bool{mainEntry.Branch: true}
306+
if shouldCleanEntry(*mainEntry, merged) {
307+
t.Fatalf("expected main entry to be skipped by clean: %+v", *mainEntry)
308+
}
309+
if shouldNukeEntry(*mainEntry) {
310+
t.Fatalf("expected main entry to be skipped by nuke: %+v", *mainEntry)
311+
}
312+
313+
if linkedEntry == nil {
314+
t.Fatalf("expected linked worktree entry, got %+v", entries)
315+
}
316+
if linkedEntry.IsMain {
317+
t.Fatalf("expected linked entry to remain non-main: %+v", *linkedEntry)
318+
}
319+
}
320+
321+
func runGit(t *testing.T, dir string, args ...string) string {
322+
t.Helper()
323+
324+
cmd := exec.Command("git", args...)
325+
cmd.Dir = dir
326+
out, err := cmd.CombinedOutput()
327+
if err != nil {
328+
t.Fatalf("git %s (dir=%s): %v\n%s", strings.Join(args, " "), dir, err, out)
329+
}
330+
return strings.TrimSpace(string(out))
331+
}

0 commit comments

Comments
 (0)