From 8defcd478f77715088d0c6463afb5955ecb0bb0d Mon Sep 17 00:00:00 2001 From: rara Date: Mon, 13 Apr 2026 11:56:02 +0800 Subject: [PATCH 1/3] Fix worktree protection detection --- internal/worktree/commands.go | 28 ++++++-------- internal/worktree/commands_test.go | 59 ++++++++++++++++++++++++++++++ internal/worktree/git.go | 24 +++++------- internal/worktree/git_test.go | 36 ++++++++++++++++++ 4 files changed, 116 insertions(+), 31 deletions(-) create mode 100644 internal/worktree/commands_test.go create mode 100644 internal/worktree/git_test.go diff --git a/internal/worktree/commands.go b/internal/worktree/commands.go index ea5dbe2..e8d2ad6 100644 --- a/internal/worktree/commands.go +++ b/internal/worktree/commands.go @@ -61,11 +61,6 @@ func runClean() error { return err } - mainPath, err := MainPath() - if err != nil { - return err - } - merged, err := MergedBranches() if err != nil { return err @@ -84,7 +79,7 @@ func runClean() error { removed := 0 for _, e := range entries { - if e.Path == mainPath || e.Branch == "" || !merged[e.Branch] { + if !shouldCleanEntry(e, merged) { continue } fmt.Printf("Removing worktree: %s (branch: %s)\n", e.Path, e.Branch) @@ -116,11 +111,6 @@ func runClean() error { } func runNuke() error { - mainPath, err := MainPath() - if err != nil { - return err - } - entries, err := List() if err != nil { return err @@ -128,15 +118,13 @@ func runNuke() error { removed := 0 for _, e := range entries { - if e.Path == mainPath { + if !shouldNukeEntry(e) { continue } fmt.Printf("Removing: %s\n", e.Path) if err := Remove(e.Path, true); err != nil { - fmt.Fprintf(os.Stderr, " warning: %s — cleaning up manually\n", err) - if removeErr := os.RemoveAll(e.Path); removeErr != nil { - fmt.Fprintf(os.Stderr, " warning: manual cleanup failed: %s\n", removeErr) - } + fmt.Fprintf(os.Stderr, " warning: %s\n", err) + continue } if e.Branch != "" { _ = DeleteBranch(e.Branch, true) @@ -148,3 +136,11 @@ func runNuke() error { fmt.Printf("Removed %d worktree(s).\n", removed) return nil } + +func shouldCleanEntry(e Entry, merged map[string]bool) bool { + return !e.Protected() && e.Branch != "" && merged[e.Branch] +} + +func shouldNukeEntry(e Entry) bool { + return !e.Protected() +} diff --git a/internal/worktree/commands_test.go b/internal/worktree/commands_test.go new file mode 100644 index 0000000..f55337b --- /dev/null +++ b/internal/worktree/commands_test.go @@ -0,0 +1,59 @@ +package worktree + +import "testing" + +func TestShouldCleanEntry(t *testing.T) { + t.Parallel() + + merged := map[string]bool{ + "merged": true, + } + + cases := []struct { + name string + entry Entry + want bool + }{ + {name: "merged regular worktree", entry: Entry{Branch: "merged"}, want: true}, + {name: "main worktree", entry: Entry{Branch: "merged", IsMain: true}, want: false}, + {name: "current worktree", entry: Entry{Branch: "merged", IsCurrent: true}, want: false}, + {name: "locked worktree", entry: Entry{Branch: "merged", Locked: true}, want: false}, + {name: "detached head", entry: Entry{}, want: false}, + {name: "unmerged branch", entry: Entry{Branch: "feature"}, want: false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := shouldCleanEntry(tc.entry, merged); got != tc.want { + t.Fatalf("shouldCleanEntry(%+v) = %v, want %v", tc.entry, got, tc.want) + } + }) + } +} + +func TestShouldNukeEntry(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + entry Entry + want bool + }{ + {name: "regular worktree", entry: Entry{}, want: true}, + {name: "main worktree", entry: Entry{IsMain: true}, want: false}, + {name: "current worktree", entry: Entry{IsCurrent: true}, want: false}, + {name: "locked worktree", entry: Entry{Locked: true}, want: false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := shouldNukeEntry(tc.entry); got != tc.want { + t.Fatalf("shouldNukeEntry(%+v) = %v, want %v", tc.entry, got, tc.want) + } + }) + } +} diff --git a/internal/worktree/git.go b/internal/worktree/git.go index 65058e0..40bfbdb 100644 --- a/internal/worktree/git.go +++ b/internal/worktree/git.go @@ -63,11 +63,6 @@ func List() ([]Entry, error) { return nil, fmt.Errorf("git worktree list: %w", err) } - mainPath, err := MainPath() - if err != nil { - return nil, err - } - merged, err := MergedBranches() if err != nil { return nil, err @@ -83,7 +78,7 @@ func List() ([]Entry, error) { // finalizeEntry fills computed fields and returns the entry ready for collection. finalizeEntry := func(e Entry) Entry { - e.IsMain = e.Path == mainPath + e.IsMain = isMainWorktree(e.Path) e.Prunable = prunable e.Locked = locked e.IsCurrent = isSameOrChild(cwd, e.Path) @@ -202,6 +197,14 @@ func resolveGitDir(worktreePath string) string { return gitdir } +func isMainWorktree(worktreePath string) bool { + info, err := os.Stat(filepath.Join(worktreePath, ".git")) + if err != nil { + return false + } + return info.IsDir() +} + func classifyEntry(e *Entry, merged map[string]bool) Status { if e.Prunable { return StatusPrunable @@ -225,15 +228,6 @@ func isSameOrChild(child, parent string) bool { return c == p || strings.HasPrefix(c, p+string(os.PathSeparator)) } -// MainPath returns the top-level path of the main checkout. -func MainPath() (string, error) { - out, err := exec.CommandContext(context.Background(), "git", "rev-parse", "--show-toplevel").Output() - if err != nil { - return "", fmt.Errorf("git rev-parse --show-toplevel: %w", err) - } - return strings.TrimSpace(string(out)), nil -} - // MergedBranches returns branch names that are fully merged into main. func MergedBranches() (map[string]bool, error) { out, err := exec.CommandContext(context.Background(), "git", "branch", "--merged", "main", "--format=%(refname:short)").Output() diff --git a/internal/worktree/git_test.go b/internal/worktree/git_test.go new file mode 100644 index 0000000..4c1cb77 --- /dev/null +++ b/internal/worktree/git_test.go @@ -0,0 +1,36 @@ +package worktree + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIsMainWorktree(t *testing.T) { + t.Parallel() + + root := t.TempDir() + mainPath := filepath.Join(root, "main") + linkedPath := filepath.Join(root, "linked") + missingPath := filepath.Join(root, "missing") + + if err := os.MkdirAll(filepath.Join(mainPath, ".git"), 0o755); err != nil { + t.Fatalf("mkdir main .git: %v", err) + } + if err := os.MkdirAll(linkedPath, 0o755); err != nil { + t.Fatalf("mkdir linked: %v", err) + } + if err := os.WriteFile(filepath.Join(linkedPath, ".git"), []byte("gitdir: /tmp/gitdir\n"), 0o644); err != nil { + t.Fatalf("write linked .git file: %v", err) + } + + if !isMainWorktree(mainPath) { + t.Fatalf("expected %q to be detected as the main worktree", mainPath) + } + if isMainWorktree(linkedPath) { + t.Fatalf("expected %q to be detected as a linked worktree", linkedPath) + } + if isMainWorktree(missingPath) { + t.Fatalf("expected %q without .git metadata to be non-main", missingPath) + } +} From 83180ffc47b5662f6e359ffca9a07c5eaccfa31f Mon Sep 17 00:00:00 2001 From: rara Date: Mon, 13 Apr 2026 18:57:45 +0800 Subject: [PATCH 2/3] Handle separate-git-dir main worktrees --- internal/worktree/git.go | 17 ++++++++++------- internal/worktree/git_test.go | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/internal/worktree/git.go b/internal/worktree/git.go index 40bfbdb..6c83ba4 100644 --- a/internal/worktree/git.go +++ b/internal/worktree/git.go @@ -169,19 +169,18 @@ func lastActiveTime(path string) time.Time { } // resolveGitDir returns the path to the actual git directory for a worktree. -// For the main checkout, this is /.git. For linked worktrees, .git is a -// file containing "gitdir: " pointing to the real git metadata. +// The worktree's .git metadata may be either a directory or a file containing +// "gitdir: " that points to the actual git metadata directory. func resolveGitDir(worktreePath string) string { dotGit := filepath.Join(worktreePath, ".git") info, err := os.Stat(dotGit) if err != nil { return dotGit } - // Main checkout: .git is a directory + // .git can be a directory or a file pointing at the real git metadata. if info.IsDir() { return dotGit } - // Linked worktree: .git is a file with "gitdir: " data, err := os.ReadFile(dotGit) if err != nil { return dotGit @@ -198,11 +197,15 @@ func resolveGitDir(worktreePath string) string { } func isMainWorktree(worktreePath string) bool { - info, err := os.Stat(filepath.Join(worktreePath, ".git")) - if err != nil { + if _, err := os.Stat(filepath.Join(worktreePath, ".git")); err != nil { return false } - return info.IsDir() + return !isLinkedGitDir(resolveGitDir(worktreePath)) +} + +func isLinkedGitDir(gitDir string) bool { + gitDir = filepath.Clean(gitDir) + return filepath.Base(filepath.Dir(gitDir)) == "worktrees" } func classifyEntry(e *Entry, merged map[string]bool) Status { diff --git a/internal/worktree/git_test.go b/internal/worktree/git_test.go index 4c1cb77..cec22e0 100644 --- a/internal/worktree/git_test.go +++ b/internal/worktree/git_test.go @@ -11,22 +11,37 @@ func TestIsMainWorktree(t *testing.T) { root := t.TempDir() mainPath := filepath.Join(root, "main") + separateMainPath := filepath.Join(root, "separate-main") linkedPath := filepath.Join(root, "linked") missingPath := filepath.Join(root, "missing") + separateGitDir := filepath.Join(root, "repo.git") + linkedGitDir := filepath.Join(separateGitDir, "worktrees", "linked") if err := os.MkdirAll(filepath.Join(mainPath, ".git"), 0o755); err != nil { t.Fatalf("mkdir main .git: %v", err) } + if err := os.MkdirAll(separateMainPath, 0o755); err != nil { + t.Fatalf("mkdir separate main: %v", err) + } if err := os.MkdirAll(linkedPath, 0o755); err != nil { t.Fatalf("mkdir linked: %v", err) } - if err := os.WriteFile(filepath.Join(linkedPath, ".git"), []byte("gitdir: /tmp/gitdir\n"), 0o644); err != nil { + if err := os.MkdirAll(linkedGitDir, 0o755); err != nil { + t.Fatalf("mkdir linked gitdir: %v", err) + } + if err := os.WriteFile(filepath.Join(separateMainPath, ".git"), []byte("gitdir: "+separateGitDir+"\n"), 0o644); err != nil { + t.Fatalf("write separate main .git file: %v", err) + } + if err := os.WriteFile(filepath.Join(linkedPath, ".git"), []byte("gitdir: "+linkedGitDir+"\n"), 0o644); err != nil { t.Fatalf("write linked .git file: %v", err) } if !isMainWorktree(mainPath) { t.Fatalf("expected %q to be detected as the main worktree", mainPath) } + if !isMainWorktree(separateMainPath) { + t.Fatalf("expected %q with separate git dir to be detected as the main worktree", separateMainPath) + } if isMainWorktree(linkedPath) { t.Fatalf("expected %q to be detected as a linked worktree", linkedPath) } From c365f48485caad254943e5b97182107273b35be2 Mon Sep 17 00:00:00 2001 From: rara Date: Tue, 14 Apr 2026 01:04:38 +0800 Subject: [PATCH 3/3] Fix separate-git-dir worktree entry detection --- internal/worktree/git.go | 61 ++++++++++++++++++++---- internal/worktree/git_test.go | 89 ++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 11 deletions(-) diff --git a/internal/worktree/git.go b/internal/worktree/git.go index 6c83ba4..db5850f 100644 --- a/internal/worktree/git.go +++ b/internal/worktree/git.go @@ -70,6 +70,7 @@ func List() ([]Entry, error) { // Detect current working directory to mark the active worktree cwd, _ := os.Getwd() + currentGitDir := currentWorktreeGitDir() var entries []Entry var cur Entry @@ -78,10 +79,11 @@ func List() ([]Entry, error) { // finalizeEntry fills computed fields and returns the entry ready for collection. finalizeEntry := func(e Entry) Entry { - e.IsMain = isMainWorktree(e.Path) + entryGitDir := entryGitDir(e.Path) + e.IsMain = entryGitDir != "" && !isLinkedGitDir(entryGitDir) e.Prunable = prunable e.Locked = locked - e.IsCurrent = isSameOrChild(cwd, e.Path) + e.IsCurrent = isSameOrChild(cwd, e.Path) || samePath(currentGitDir, entryGitDir) e.Status = classifyEntry(&e, merged) // Populate LastActive for non-prunable entries with existing paths if !e.Prunable { @@ -146,12 +148,15 @@ func lastActiveTime(path string) time.Time { var latest time.Time // Resolve the actual git directory (handles both main checkout and linked worktrees) - gitDir := resolveGitDir(path) - candidates := []string{ - filepath.Join(gitDir, "HEAD"), - filepath.Join(gitDir, "index"), - filepath.Join(path, ".git"), // mtime of .git itself (file or dir) + gitDir := entryGitDir(path) + var candidates []string + if gitDir != "" { + candidates = append(candidates, + filepath.Join(gitDir, "HEAD"), + filepath.Join(gitDir, "index"), + ) } + candidates = append(candidates, filepath.Join(path, ".git")) // mtime of .git itself (file or dir) for _, c := range candidates { if info, err := os.Stat(c); err == nil { if info.ModTime().After(latest) { @@ -168,6 +173,31 @@ func lastActiveTime(path string) time.Time { return latest } +func currentWorktreeGitDir() string { + out, err := exec.CommandContext(context.Background(), "git", "rev-parse", "--absolute-git-dir").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func entryGitDir(path string) string { + if isGitDir(path) { + return filepath.Clean(path) + } + dotGit := filepath.Join(path, ".git") + if _, err := os.Stat(dotGit); err != nil { + return "" + } + return resolveGitDir(path) +} + +func isGitDir(path string) bool { + headInfo, headErr := os.Stat(filepath.Join(path, "HEAD")) + configInfo, configErr := os.Stat(filepath.Join(path, "config")) + return headErr == nil && !headInfo.IsDir() && configErr == nil && !configInfo.IsDir() +} + // resolveGitDir returns the path to the actual git directory for a worktree. // The worktree's .git metadata may be either a directory or a file containing // "gitdir: " that points to the actual git metadata directory. @@ -197,10 +227,11 @@ func resolveGitDir(worktreePath string) string { } func isMainWorktree(worktreePath string) bool { - if _, err := os.Stat(filepath.Join(worktreePath, ".git")); err != nil { + gitDir := entryGitDir(worktreePath) + if gitDir == "" { return false } - return !isLinkedGitDir(resolveGitDir(worktreePath)) + return !isLinkedGitDir(gitDir) } func isLinkedGitDir(gitDir string) bool { @@ -231,6 +262,18 @@ func isSameOrChild(child, parent string) bool { return c == p || strings.HasPrefix(c, p+string(os.PathSeparator)) } +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + x, err1 := filepath.EvalSymlinks(a) + y, err2 := filepath.EvalSymlinks(b) + if err1 != nil || err2 != nil { + return filepath.Clean(a) == filepath.Clean(b) + } + return x == y +} + // MergedBranches returns branch names that are fully merged into main. func MergedBranches() (map[string]bool, error) { out, err := exec.CommandContext(context.Background(), "git", "branch", "--merged", "main", "--format=%(refname:short)").Output() diff --git a/internal/worktree/git_test.go b/internal/worktree/git_test.go index cec22e0..263ec6e 100644 --- a/internal/worktree/git_test.go +++ b/internal/worktree/git_test.go @@ -2,13 +2,13 @@ package worktree import ( "os" + "os/exec" "path/filepath" + "strings" "testing" ) func TestIsMainWorktree(t *testing.T) { - t.Parallel() - root := t.TempDir() mainPath := filepath.Join(root, "main") separateMainPath := filepath.Join(root, "separate-main") @@ -29,6 +29,12 @@ func TestIsMainWorktree(t *testing.T) { if err := os.MkdirAll(linkedGitDir, 0o755); err != nil { t.Fatalf("mkdir linked gitdir: %v", err) } + if err := os.WriteFile(filepath.Join(separateGitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { + t.Fatalf("write separate gitdir HEAD: %v", err) + } + if err := os.WriteFile(filepath.Join(separateGitDir, "config"), []byte("[core]\n"), 0o644); err != nil { + t.Fatalf("write separate gitdir config: %v", err) + } if err := os.WriteFile(filepath.Join(separateMainPath, ".git"), []byte("gitdir: "+separateGitDir+"\n"), 0o644); err != nil { t.Fatalf("write separate main .git file: %v", err) } @@ -42,6 +48,9 @@ func TestIsMainWorktree(t *testing.T) { if !isMainWorktree(separateMainPath) { t.Fatalf("expected %q with separate git dir to be detected as the main worktree", separateMainPath) } + if !isMainWorktree(separateGitDir) { + t.Fatalf("expected %q gitdir path to be detected as the main worktree entry", separateGitDir) + } if isMainWorktree(linkedPath) { t.Fatalf("expected %q to be detected as a linked worktree", linkedPath) } @@ -49,3 +58,79 @@ func TestIsMainWorktree(t *testing.T) { t.Fatalf("expected %q without .git metadata to be non-main", missingPath) } } + +func TestListProtectsSeparateGitDirMainWorktree(t *testing.T) { + root := t.TempDir() + mainPath := filepath.Join(root, "main") + gitDir := filepath.Join(root, "repo.git") + linkedPath := filepath.Join(root, "feature") + + runGit(t, root, "init", "-b", "main", "--separate-git-dir", gitDir, mainPath) + runGit(t, mainPath, "config", "user.name", "Test User") + runGit(t, mainPath, "config", "user.email", "test@example.com") + + if err := os.WriteFile(filepath.Join(mainPath, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write README.md: %v", err) + } + runGit(t, mainPath, "add", "README.md") + runGit(t, mainPath, "commit", "-m", "initial commit") + runGit(t, mainPath, "worktree", "add", "-b", "feature", linkedPath, "HEAD") + + t.Chdir(mainPath) + + entries, err := List() + if err != nil { + t.Fatalf("List(): %v", err) + } + + var mainEntry *Entry + var linkedEntry *Entry + for i := range entries { + e := &entries[i] + switch { + case e.IsMain: + mainEntry = e + case samePath(e.Path, linkedPath): + linkedEntry = e + } + } + + if mainEntry == nil { + t.Fatalf("expected a main worktree entry, got %+v", entries) + } + if !samePath(mainEntry.Path, gitDir) { + t.Fatalf("expected main entry path %q from git porcelain, got %q", gitDir, mainEntry.Path) + } + if !mainEntry.IsCurrent { + t.Fatalf("expected separate-git-dir main entry to be current: %+v", *mainEntry) + } + if !mainEntry.Protected() { + t.Fatalf("expected separate-git-dir main entry to be protected: %+v", *mainEntry) + } + merged := map[string]bool{mainEntry.Branch: true} + if shouldCleanEntry(*mainEntry, merged) { + t.Fatalf("expected main entry to be skipped by clean: %+v", *mainEntry) + } + if shouldNukeEntry(*mainEntry) { + t.Fatalf("expected main entry to be skipped by nuke: %+v", *mainEntry) + } + + if linkedEntry == nil { + t.Fatalf("expected linked worktree entry, got %+v", entries) + } + if linkedEntry.IsMain { + t.Fatalf("expected linked entry to remain non-main: %+v", *linkedEntry) + } +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s (dir=%s): %v\n%s", strings.Join(args, " "), dir, err, out) + } + return strings.TrimSpace(string(out)) +}