Skip to content

Commit aa3e58e

Browse files
loganjclaude
andauthored
feat(penpal): live-detect worktree additions and removals (#548)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 041a8fa commit aa3e58e

7 files changed

Lines changed: 396 additions & 84 deletions

File tree

apps/penpal/ERD.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ see-also:
9191
- <a id="E-PENPAL-WORKTREE-DISCOVERY"></a>**E-PENPAL-WORKTREE-DISCOVERY**: Worktrees are discovered by parsing `git worktree list --porcelain` output. Each worktree gets a name, path, branch, and `IsMain` flag. The `refs/heads/` prefix is stripped from branch names.
9292
[P-PENPAL-WORKTREE](PRODUCT.md#P-PENPAL-WORKTREE)
9393

94+
- <a id="E-PENPAL-WORKTREE-WATCH"></a>**E-PENPAL-WORKTREE-WATCH**: Worktree additions and removals are detected by the existing workspace directory watch — `git worktree add`/`remove` creates or deletes a sibling directory in the workspace, triggering the workspace rescan path. The rescan re-runs full project discovery (including `DiscoverWorktrees`), updates the cache, and broadcasts a `projects` SSE event so the frontend reflects the change. No additional inotify watches or subprocess calls are needed. `GitCommonDir` resolves the shared `.git` directory using pure filesystem reads (no `git rev-parse`).
95+
[P-PENPAL-WORKTREE](PRODUCT.md#P-PENPAL-WORKTREE)
96+
9497
- <a id="E-PENPAL-CLAUDE-PLANS-DETECT"></a>**E-PENPAL-CLAUDE-PLANS-DETECT**: `DiscoverClaudePlans()` checks `~/.claude/plans/` for existence and at least one `.md` file. If found, a synthetic standalone project is injected. If the user already manually added the same path, a tree source is injected into the existing entry instead of duplicating.
9598
[P-PENPAL-CLAUDE-PLANS](PRODUCT.md#P-PENPAL-CLAUDE-PLANS)
9699

apps/penpal/PRODUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Penpal is a desktop application and local web server for collaborative review of
2222

2323
- <a id="P-PENPAL-STANDALONE"></a>**P-PENPAL-STANDALONE**: Users can add standalone projects (directories or individual files) outside of any workspace, via the home view "+" button or the `penpal open` CLI command.
2424

25-
- <a id="P-PENPAL-WORKTREE"></a>**P-PENPAL-WORKTREE**: Git worktrees for a project are discovered automatically. In the home view, multi-worktree projects expand to show each worktree as a child item with its branch name. In the project view, a worktree dropdown in the breadcrumb bar lets the user switch between worktrees. Each worktree has its own branch name and independent comment storage.
25+
- <a id="P-PENPAL-WORKTREE"></a>**P-PENPAL-WORKTREE**: Git worktrees for a project are discovered automatically. In the home view, multi-worktree projects expand to show each worktree as a child item with its branch name. In the project view, a worktree dropdown in the breadcrumb bar lets the user switch between worktrees. Each worktree has its own branch name and independent comment storage. When worktrees are added or removed (via `git worktree add`/`remove`), the worktree list updates without restarting the server.
2626

2727
- <a id="P-PENPAL-DEDUP"></a>**P-PENPAL-DEDUP**: When multiple directories in a workspace share the same git repository (one is a worktree of the other), only the main worktree is shown as a project to avoid duplicates.
2828

apps/penpal/TESTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ see-also:
6666
| Source Types — manual (P-PENPAL-SRC-MANUAL) ||| grouping_test.go (TestBuildFileGroups_ManualSourceDirHeadings) ||
6767
| Cache & File Scanning (E-PENPAL-CACHE, SCAN) | cache_test.go (TestCheckAllProjectsHasFiles, TestProjectHasAnyMarkdown_IgnoresGitignore, TestProjectHasAnyMarkdown_SkipsVCSDirs, TestAllFiles_DeduplicatesAllMarkdown, TestEnsureProjectScanned_NoDuplicateScans, TestResolveFileInfo, TestUpsertFile, TestRemoveFile, TestRescanWith_PreservesUnchangedProjects, TestSourcesChanged) ||||
6868
| Worktree Support (P-PENPAL-WORKTREE) | discovery/worktree_test.go, cache/worktree_test.go | Layout.test.tsx | worktree_test.go (API + MCP) ||
69+
| Worktree Watch (E-PENPAL-WORKTREE-WATCH) | watcher_test.go ||||
6970
| Worktree Dropdown (P-PENPAL-PROJECT-WORKTREE-DROPDOWN) || Layout.test.tsx |||
7071
| Git Integration (P-PENPAL-GIT-INFO) |||||
7172
| File List & Grouping (P-PENPAL-FILE-LIST) || ProjectPage.test.tsx | grouping_test.go, integration_test.go ||

apps/penpal/internal/discovery/worktree.go

Lines changed: 52 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,54 @@
11
package discovery
22

33
import (
4+
"os"
45
"os/exec"
56
"path/filepath"
67
"strings"
78
)
89

10+
// gitCommonDirFS resolves the shared .git directory using only filesystem
11+
// reads — no subprocess. For a main worktree .git is a directory; for a
12+
// linked worktree .git is a file containing "gitdir: <path>" and the
13+
// referenced gitdir contains a "commondir" file pointing back to the
14+
// shared .git.
15+
func gitCommonDirFS(projectPath string) string {
16+
gitPath := filepath.Join(projectPath, ".git")
17+
info, err := os.Lstat(gitPath)
18+
if err != nil {
19+
return ""
20+
}
21+
// Main worktree: .git is a directory — it IS the common dir.
22+
if info.IsDir() {
23+
return gitPath
24+
}
25+
// Linked worktree: .git is a file with "gitdir: <path>".
26+
data, err := os.ReadFile(gitPath)
27+
if err != nil {
28+
return ""
29+
}
30+
line := strings.TrimSpace(string(data))
31+
if !strings.HasPrefix(line, "gitdir: ") {
32+
return ""
33+
}
34+
gitDir := strings.TrimPrefix(line, "gitdir: ")
35+
if !filepath.IsAbs(gitDir) {
36+
gitDir = filepath.Join(projectPath, gitDir)
37+
}
38+
gitDir = filepath.Clean(gitDir)
39+
// Read commondir file to find the shared .git directory.
40+
cdPath := filepath.Join(gitDir, "commondir")
41+
cdData, err := os.ReadFile(cdPath)
42+
if err != nil {
43+
return ""
44+
}
45+
commonDir := strings.TrimSpace(string(cdData))
46+
if !filepath.IsAbs(commonDir) {
47+
commonDir = filepath.Join(gitDir, commonDir)
48+
}
49+
return filepath.Clean(commonDir)
50+
}
51+
952
// Worktree represents a git worktree associated with a project.
1053
type Worktree struct {
1154
Name string `json:"name"` // directory name (e.g., "fancy-name")
@@ -81,64 +124,17 @@ func parseWorktreeList(projectPath string, output string) []Worktree {
81124
return worktrees
82125
}
83126

84-
// ResolveWorktree finds the worktree that contains the given absolute path.
85-
// Returns the worktree name and the main project path, or empty strings if
86-
// the path doesn't belong to any worktree.
87-
func ResolveWorktree(projectPath string, absPath string) (worktreeName string, mainProjectPath string) {
88-
absPath = filepath.Clean(absPath)
89-
90-
// First check if this path is inside the main project
91-
mainPath := filepath.Clean(projectPath)
92-
if strings.HasPrefix(absPath, mainPath+"/") || absPath == mainPath {
93-
// Check if it's inside a worktree subdirectory
94-
worktrees := DiscoverWorktrees(projectPath)
95-
for _, wt := range worktrees {
96-
if !wt.IsMain && (strings.HasPrefix(absPath, wt.Path+"/") || absPath == wt.Path) {
97-
return wt.Name, mainPath
98-
}
99-
}
100-
return "", mainPath
101-
}
102-
103-
return "", ""
104-
}
105-
106-
// FindMainWorktree returns the path to the main worktree for a given path
107-
// that might be inside a worktree. It reads the .git file to find the
108-
// gitdir and traces back to the main worktree.
109-
func FindMainWorktree(path string) string {
110-
cmd := exec.Command("git", "-C", path, "rev-parse", "--git-common-dir")
111-
out, err := cmd.Output()
112-
if err != nil {
127+
// gitWorktreesDir returns the path to the .git/worktrees/ directory for the
128+
// repository that projectPath belongs to, or "" if it doesn't exist.
129+
// Uses pure filesystem reads via gitCommonDirFS — no subprocess calls.
130+
func gitWorktreesDir(projectPath string) string {
131+
commonDir := gitCommonDirFS(projectPath)
132+
if commonDir == "" {
113133
return ""
114134
}
115-
commonDir := strings.TrimSpace(string(out))
116-
if commonDir == "" || commonDir == "." {
117-
return ""
135+
wtDir := filepath.Join(commonDir, "worktrees")
136+
if info, err := os.Stat(wtDir); err == nil && info.IsDir() {
137+
return wtDir
118138
}
119-
120-
// commonDir is the .git directory of the main worktree
121-
// If it's relative, resolve it relative to the path
122-
if !filepath.IsAbs(commonDir) {
123-
// Get the actual git dir for this worktree first
124-
cmd2 := exec.Command("git", "-C", path, "rev-parse", "--git-dir")
125-
out2, err := cmd2.Output()
126-
if err != nil {
127-
return ""
128-
}
129-
gitDir := strings.TrimSpace(string(out2))
130-
if !filepath.IsAbs(gitDir) {
131-
gitDir = filepath.Join(path, gitDir)
132-
}
133-
commonDir = filepath.Join(gitDir, commonDir)
134-
}
135-
136-
commonDir = filepath.Clean(commonDir)
137-
138-
// The main worktree is the parent of the .git directory
139-
if filepath.Base(commonDir) == ".git" {
140-
return filepath.Dir(commonDir)
141-
}
142-
143139
return ""
144140
}

apps/penpal/internal/discovery/worktree_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package discovery
22

33
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
47
"testing"
58
)
69

@@ -100,3 +103,144 @@ func TestParseWorktreeList_BranchStripping(t *testing.T) {
100103
t.Errorf("wt branch = %q, want %q", got[1].Branch, "feature/nested")
101104
}
102105
}
106+
107+
// initGitRepo creates a git repo in dir with an initial commit.
108+
func initGitRepo(t *testing.T, dir string) {
109+
t.Helper()
110+
for _, args := range [][]string{
111+
{"init"},
112+
{"config", "user.email", "test@test.com"},
113+
{"config", "user.name", "Test"},
114+
{"commit", "--allow-empty", "-m", "init"},
115+
} {
116+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
117+
if out, err := cmd.CombinedOutput(); err != nil {
118+
t.Fatalf("git %v: %v\n%s", args, err, out)
119+
}
120+
}
121+
}
122+
123+
// resolveSymlinks resolves symlinks in a path for reliable comparison on macOS
124+
// where /var → /private/var.
125+
func resolveSymlinks(t *testing.T, path string) string {
126+
t.Helper()
127+
resolved, err := filepath.EvalSymlinks(path)
128+
if err != nil {
129+
t.Fatalf("EvalSymlinks(%q): %v", path, err)
130+
}
131+
return resolved
132+
}
133+
134+
// E-PENPAL-WORKTREE-WATCH: verifies gitWorktreesDir returns the .git/worktrees/ dir for a repo with worktrees.
135+
func TestWorktreesDir_MainWorktree(t *testing.T) {
136+
mainDir := resolveSymlinks(t, t.TempDir())
137+
initGitRepo(t, mainDir)
138+
139+
// Before adding a worktree, the dir doesn't exist
140+
if got := gitWorktreesDir(mainDir); got != "" {
141+
t.Fatalf("expected empty before worktree add, got %q", got)
142+
}
143+
144+
// Add a worktree
145+
wtDir := filepath.Join(resolveSymlinks(t, t.TempDir()), "my-worktree")
146+
cmd := exec.Command("git", "-C", mainDir, "worktree", "add", "-b", "test-branch", wtDir)
147+
if out, err := cmd.CombinedOutput(); err != nil {
148+
t.Fatalf("git worktree add: %v\n%s", err, out)
149+
}
150+
151+
// Now gitWorktreesDir should return the .git/worktrees/ path
152+
got := gitWorktreesDir(mainDir)
153+
want := filepath.Join(mainDir, ".git", "worktrees")
154+
if got != want {
155+
t.Errorf("gitWorktreesDir(main) = %q, want %q", got, want)
156+
}
157+
158+
// It should also work when called from the linked worktree
159+
got2 := gitWorktreesDir(wtDir)
160+
if got2 != want {
161+
t.Errorf("gitWorktreesDir(linked) = %q, want %q", got2, want)
162+
}
163+
}
164+
165+
// E-PENPAL-WORKTREE-WATCH: verifies gitWorktreesDir returns "" for a non-git directory.
166+
func TestWorktreesDir_NotGitRepo(t *testing.T) {
167+
dir := t.TempDir()
168+
if got := gitWorktreesDir(dir); got != "" {
169+
t.Errorf("gitWorktreesDir(non-git) = %q, want empty", got)
170+
}
171+
}
172+
173+
// E-PENPAL-WORKTREE-WATCH: verifies gitWorktreesDir returns "" for a repo with no worktrees.
174+
func TestWorktreesDir_NoWorktrees(t *testing.T) {
175+
dir := t.TempDir()
176+
initGitRepo(t, dir)
177+
if got := gitWorktreesDir(dir); got != "" {
178+
t.Errorf("gitWorktreesDir(no worktrees) = %q, want empty", got)
179+
}
180+
}
181+
182+
// E-PENPAL-WORKTREE-WATCH: verifies worktree directory appears after git worktree add
183+
// and disappears after git worktree remove.
184+
func TestWorktreesDir_AddRemoveCycle(t *testing.T) {
185+
mainDir := resolveSymlinks(t, t.TempDir())
186+
initGitRepo(t, mainDir)
187+
188+
wtPath := filepath.Join(resolveSymlinks(t, t.TempDir()), "wt")
189+
cmd := exec.Command("git", "-C", mainDir, "worktree", "add", "-b", "wt-branch", wtPath)
190+
if out, err := cmd.CombinedOutput(); err != nil {
191+
t.Fatalf("git worktree add: %v\n%s", err, out)
192+
}
193+
194+
wtDir := gitWorktreesDir(mainDir)
195+
if wtDir == "" {
196+
t.Fatal("expected non-empty after add")
197+
}
198+
199+
// Verify the specific worktree entry exists
200+
entries, err := os.ReadDir(wtDir)
201+
if err != nil {
202+
t.Fatal(err)
203+
}
204+
found := false
205+
for _, e := range entries {
206+
if e.Name() == filepath.Base(wtPath) {
207+
found = true
208+
}
209+
}
210+
if !found {
211+
t.Errorf("expected entry %q in %s", filepath.Base(wtPath), wtDir)
212+
}
213+
214+
// Remove the worktree
215+
cmd = exec.Command("git", "-C", mainDir, "worktree", "remove", wtPath)
216+
if out, err := cmd.CombinedOutput(); err != nil {
217+
t.Fatalf("git worktree remove: %v\n%s", err, out)
218+
}
219+
220+
// After removing the last worktree, the worktrees/ dir should be gone
221+
if got := gitWorktreesDir(mainDir); got != "" {
222+
t.Errorf("expected empty after removing last worktree, got %q", got)
223+
}
224+
}
225+
226+
// E-PENPAL-WORKTREE-WATCH: verifies gitCommonDirFS returns "" for malformed .git file.
227+
func TestGitCommonDirFS_MalformedGitFile(t *testing.T) {
228+
dir := t.TempDir()
229+
// .git file with no "gitdir:" prefix
230+
os.WriteFile(filepath.Join(dir, ".git"), []byte("not a gitdir line\n"), 0o644)
231+
if got := gitCommonDirFS(dir); got != "" {
232+
t.Errorf("expected empty for malformed .git file, got %q", got)
233+
}
234+
}
235+
236+
// E-PENPAL-WORKTREE-WATCH: verifies gitCommonDirFS returns "" when commondir file is missing.
237+
func TestGitCommonDirFS_MissingCommondir(t *testing.T) {
238+
dir := t.TempDir()
239+
gitDir := filepath.Join(dir, "fake-gitdir")
240+
os.MkdirAll(gitDir, 0o755)
241+
// .git file points to a valid directory but commondir file doesn't exist
242+
os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644)
243+
if got := gitCommonDirFS(dir); got != "" {
244+
t.Errorf("expected empty for missing commondir, got %q", got)
245+
}
246+
}

0 commit comments

Comments
 (0)