diff --git a/internal/worktree/commands.go b/internal/worktree/commands.go index 7447c35..4ae5e3e 100644 --- a/internal/worktree/commands.go +++ b/internal/worktree/commands.go @@ -122,11 +122,6 @@ func runClean(dryRun bool) error { } } - mainPath, err := MainPath() - if err != nil { - return err - } - merged, err := MergedBranches() if err != nil { return err @@ -149,7 +144,7 @@ func runClean(dryRun bool) error { } for _, e := range entries { - if e.Path == mainPath || e.Branch == "" || !merged[e.Branch] { + if !shouldCleanEntry(e, merged) { continue } if e.Dirty > 0 { @@ -195,11 +190,6 @@ func runClean(dryRun bool) error { } func runNuke() error { - mainPath, err := MainPath() - if err != nil { - return err - } - entries, err := List() if err != nil { return err @@ -207,15 +197,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 != "" { if branchErr := DeleteBranch(e.Branch, true); branchErr != nil { @@ -231,3 +219,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 b192344..13a0e50 100644 --- a/internal/worktree/git.go +++ b/internal/worktree/git.go @@ -76,11 +76,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 @@ -88,6 +83,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 @@ -96,10 +92,11 @@ 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 + 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 computed fields for non-prunable entries with existing paths if !e.Prunable { @@ -173,12 +170,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) { @@ -195,20 +195,44 @@ 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. -// 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 @@ -224,6 +248,19 @@ func resolveGitDir(worktreePath string) string { return gitdir } +func isMainWorktree(worktreePath string) bool { + gitDir := entryGitDir(worktreePath) + if gitDir == "" { + return false + } + return !isLinkedGitDir(gitDir) +} + +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 { if e.Prunable { return StatusPrunable @@ -247,15 +284,16 @@ 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) { - ctx, cancel := gitContext() - defer cancel() - out, err := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel").Output() - if err != nil { - return "", fmt.Errorf("git rev-parse --show-toplevel: %w", err) +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 strings.TrimSpace(string(out)), nil + return x == y } // MergedBranches returns branch names that are fully merged into main. diff --git a/internal/worktree/git_test.go b/internal/worktree/git_test.go index c4f6fb2..631f90b 100644 --- a/internal/worktree/git_test.go +++ b/internal/worktree/git_test.go @@ -2,7 +2,9 @@ package worktree import ( "os" + "os/exec" "path/filepath" + "strings" "testing" "time" ) @@ -200,3 +202,130 @@ func TestEntryProtected(t *testing.T) { }) } } + +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"), 0o750); err != nil { + t.Fatalf("mkdir main .git: %v", err) + } + if err := os.MkdirAll(separateMainPath, 0o750); err != nil { + t.Fatalf("mkdir separate main: %v", err) + } + if err := os.MkdirAll(linkedPath, 0o750); err != nil { + t.Fatalf("mkdir linked: %v", err) + } + if err := os.MkdirAll(linkedGitDir, 0o750); err != nil { + t.Fatalf("mkdir linked gitdir: %v", err) + } + if err := os.WriteFile(filepath.Join(separateGitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o600); err != nil { + t.Fatalf("write separate gitdir HEAD: %v", err) + } + if err := os.WriteFile(filepath.Join(separateGitDir, "config"), []byte("[core]\n"), 0o600); err != nil { + t.Fatalf("write separate gitdir config: %v", err) + } + if err := os.WriteFile(filepath.Join(separateMainPath, ".git"), []byte("gitdir: "+separateGitDir+"\n"), 0o600); err != nil { + t.Fatalf("write separate main .git file: %v", err) + } + if err := os.WriteFile(filepath.Join(linkedPath, ".git"), []byte("gitdir: "+linkedGitDir+"\n"), 0o600); 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(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) + } + if isMainWorktree(missingPath) { + 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"), 0o600); 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)) +}