diff --git a/apps/penpal/ERD.md b/apps/penpal/ERD.md
index 1089932b..e207e0f4 100644
--- a/apps/penpal/ERD.md
+++ b/apps/penpal/ERD.md
@@ -91,6 +91,9 @@ see-also:
- **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.
← [P-PENPAL-WORKTREE](PRODUCT.md#P-PENPAL-WORKTREE)
+- **E-PENPAL-WORKTREE-WATCH**: The watcher monitors each project's `.git/worktrees/` directory (for the main worktree) or the equivalent resolved via `git rev-parse --git-common-dir`. When entries are created or removed in that directory, the watcher re-runs `DiscoverWorktrees` for the affected project, updates the cached worktree list, and broadcasts a `projects` SSE event so the frontend reflects the change.
+ ← [P-PENPAL-WORKTREE](PRODUCT.md#P-PENPAL-WORKTREE)
+
- **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.
← [P-PENPAL-CLAUDE-PLANS](PRODUCT.md#P-PENPAL-CLAUDE-PLANS)
diff --git a/apps/penpal/PRODUCT.md b/apps/penpal/PRODUCT.md
index 5f132a7a..cec77bf5 100644
--- a/apps/penpal/PRODUCT.md
+++ b/apps/penpal/PRODUCT.md
@@ -22,7 +22,7 @@ Penpal is a desktop application and local web server for collaborative review of
- **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.
-- **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.
+- **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.
- **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.
diff --git a/apps/penpal/TESTING.md b/apps/penpal/TESTING.md
index a184ebaf..2d8a4fb8 100644
--- a/apps/penpal/TESTING.md
+++ b/apps/penpal/TESTING.md
@@ -66,6 +66,7 @@ see-also:
| Source Types — manual (P-PENPAL-SRC-MANUAL) | — | — | grouping_test.go (TestBuildFileGroups_ManualSourceDirHeadings) | — |
| Cache & File Scanning (E-PENPAL-CACHE, SCAN) | cache_test.go (TestCheckAllProjectsHasFiles, TestProjectHasAnyMarkdown_SkipsGitignored, TestProjectHasAnyMarkdown_SkipsVCSDirs, TestAllFiles_DeduplicatesAllMarkdown, TestEnsureProjectScanned_NoDuplicateScans) | — | — | — |
| Worktree Support (P-PENPAL-WORKTREE) | discovery/worktree_test.go, cache/worktree_test.go | Layout.test.tsx | worktree_test.go (API + MCP) | — |
+| Worktree Watch (E-PENPAL-WORKTREE-WATCH) | watcher_test.go | — | — | — |
| Worktree Dropdown (P-PENPAL-PROJECT-WORKTREE-DROPDOWN) | — | Layout.test.tsx | — | — |
| Git Integration (P-PENPAL-GIT-INFO) | — | — | — | — |
| File List & Grouping (P-PENPAL-FILE-LIST) | — | ProjectPage.test.tsx | grouping_test.go, integration_test.go | — |
diff --git a/apps/penpal/internal/discovery/worktree.go b/apps/penpal/internal/discovery/worktree.go
index ce0af4e1..24b7a15a 100644
--- a/apps/penpal/internal/discovery/worktree.go
+++ b/apps/penpal/internal/discovery/worktree.go
@@ -1,6 +1,7 @@
package discovery
import (
+ "os"
"os/exec"
"path/filepath"
"strings"
@@ -142,3 +143,29 @@ func FindMainWorktree(path string) string {
return ""
}
+
+// GitWorktreesDir returns the path to the .git/worktrees/ directory for the
+// repository that projectPath belongs to, or "" if it doesn't exist.
+// Works for both main worktrees (.git is a directory) and linked worktrees
+// (.git is a file pointing to the main repo's gitdir).
+// E-PENPAL-WORKTREE-WATCH: resolves the worktrees metadata directory for fs watching.
+func GitWorktreesDir(projectPath string) string {
+ cmd := exec.Command("git", "-C", projectPath, "rev-parse", "--git-common-dir")
+ out, err := cmd.Output()
+ if err != nil {
+ return ""
+ }
+ commonDir := strings.TrimSpace(string(out))
+ if commonDir == "" || commonDir == "." {
+ return ""
+ }
+ // --git-common-dir output is relative to the -C directory
+ if !filepath.IsAbs(commonDir) {
+ commonDir = filepath.Join(projectPath, commonDir)
+ }
+ wtDir := filepath.Join(filepath.Clean(commonDir), "worktrees")
+ if info, err := os.Stat(wtDir); err == nil && info.IsDir() {
+ return wtDir
+ }
+ return ""
+}
diff --git a/apps/penpal/internal/discovery/worktree_test.go b/apps/penpal/internal/discovery/worktree_test.go
index 38485919..3e12b5a9 100644
--- a/apps/penpal/internal/discovery/worktree_test.go
+++ b/apps/penpal/internal/discovery/worktree_test.go
@@ -1,6 +1,9 @@
package discovery
import (
+ "os"
+ "os/exec"
+ "path/filepath"
"testing"
)
@@ -100,3 +103,122 @@ func TestParseWorktreeList_BranchStripping(t *testing.T) {
t.Errorf("wt branch = %q, want %q", got[1].Branch, "feature/nested")
}
}
+
+// initGitRepo creates a git repo in dir with an initial commit.
+func initGitRepo(t *testing.T, dir string) {
+ t.Helper()
+ for _, args := range [][]string{
+ {"init"},
+ {"config", "user.email", "test@test.com"},
+ {"config", "user.name", "Test"},
+ {"commit", "--allow-empty", "-m", "init"},
+ } {
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+}
+
+// resolveSymlinks resolves symlinks in a path for reliable comparison on macOS
+// where /var → /private/var.
+func resolveSymlinks(t *testing.T, path string) string {
+ t.Helper()
+ resolved, err := filepath.EvalSymlinks(path)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(%q): %v", path, err)
+ }
+ return resolved
+}
+
+// E-PENPAL-WORKTREE-WATCH: verifies GitWorktreesDir returns the .git/worktrees/ dir for a repo with worktrees.
+func TestGitWorktreesDir_MainWorktree(t *testing.T) {
+ mainDir := resolveSymlinks(t, t.TempDir())
+ initGitRepo(t, mainDir)
+
+ // Before adding a worktree, the dir doesn't exist
+ if got := GitWorktreesDir(mainDir); got != "" {
+ t.Fatalf("expected empty before worktree add, got %q", got)
+ }
+
+ // Add a worktree
+ wtDir := filepath.Join(resolveSymlinks(t, t.TempDir()), "my-worktree")
+ cmd := exec.Command("git", "-C", mainDir, "worktree", "add", "-b", "test-branch", wtDir)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git worktree add: %v\n%s", err, out)
+ }
+
+ // Now GitWorktreesDir should return the .git/worktrees/ path
+ got := GitWorktreesDir(mainDir)
+ want := filepath.Join(mainDir, ".git", "worktrees")
+ if got != want {
+ t.Errorf("GitWorktreesDir(main) = %q, want %q", got, want)
+ }
+
+ // It should also work when called from the linked worktree
+ got2 := GitWorktreesDir(wtDir)
+ if got2 != want {
+ t.Errorf("GitWorktreesDir(linked) = %q, want %q", got2, want)
+ }
+}
+
+// E-PENPAL-WORKTREE-WATCH: verifies GitWorktreesDir returns "" for a non-git directory.
+func TestGitWorktreesDir_NotGitRepo(t *testing.T) {
+ dir := t.TempDir()
+ if got := GitWorktreesDir(dir); got != "" {
+ t.Errorf("GitWorktreesDir(non-git) = %q, want empty", got)
+ }
+}
+
+// E-PENPAL-WORKTREE-WATCH: verifies GitWorktreesDir returns "" for a repo with no worktrees.
+func TestGitWorktreesDir_NoWorktrees(t *testing.T) {
+ dir := t.TempDir()
+ initGitRepo(t, dir)
+ if got := GitWorktreesDir(dir); got != "" {
+ t.Errorf("GitWorktreesDir(no worktrees) = %q, want empty", got)
+ }
+}
+
+// E-PENPAL-WORKTREE-WATCH: verifies worktree directory appears after git worktree add
+// and disappears after git worktree remove.
+func TestGitWorktreesDir_AddRemoveCycle(t *testing.T) {
+ mainDir := resolveSymlinks(t, t.TempDir())
+ initGitRepo(t, mainDir)
+
+ wtPath := filepath.Join(resolveSymlinks(t, t.TempDir()), "wt")
+ cmd := exec.Command("git", "-C", mainDir, "worktree", "add", "-b", "wt-branch", wtPath)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git worktree add: %v\n%s", err, out)
+ }
+
+ wtDir := GitWorktreesDir(mainDir)
+ if wtDir == "" {
+ t.Fatal("expected non-empty after add")
+ }
+
+ // Verify the specific worktree entry exists
+ entries, err := os.ReadDir(wtDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, e := range entries {
+ if e.Name() == filepath.Base(wtPath) {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("expected entry %q in %s", filepath.Base(wtPath), wtDir)
+ }
+
+ // Remove the worktree
+ cmd = exec.Command("git", "-C", mainDir, "worktree", "remove", wtPath)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git worktree remove: %v\n%s", err, out)
+ }
+
+ // After removing the last worktree, the worktrees/ dir should be gone
+ if got := GitWorktreesDir(mainDir); got != "" {
+ t.Errorf("expected empty after removing last worktree, got %q", got)
+ }
+}
diff --git a/apps/penpal/internal/watcher/watcher.go b/apps/penpal/internal/watcher/watcher.go
index c5e96f32..9686023f 100644
--- a/apps/penpal/internal/watcher/watcher.go
+++ b/apps/penpal/internal/watcher/watcher.go
@@ -70,6 +70,9 @@ type Watcher struct {
windowFocuses map[string]focusTarget
baseWatched map[string]struct{}
dynamicWatched map[string]struct{}
+
+ // E-PENPAL-WORKTREE-WATCH: tracks .git/worktrees/ dirs to detect worktree add/remove.
+ worktreeWatchDirs map[string]struct{}
}
// New creates a new watcher
@@ -87,9 +90,10 @@ func New(c *cache.Cache, act *activity.Tracker) (*Watcher, error) {
done: make(chan struct{}),
subs: make(map[chan Event]struct{}),
debounce: make(map[string]*time.Timer),
- windowFocuses: make(map[string]focusTarget),
- baseWatched: make(map[string]struct{}),
- dynamicWatched: make(map[string]struct{}),
+ windowFocuses: make(map[string]focusTarget),
+ baseWatched: make(map[string]struct{}),
+ dynamicWatched: make(map[string]struct{}),
+ worktreeWatchDirs: make(map[string]struct{}),
}
return w, nil
@@ -330,6 +334,19 @@ func (w *Watcher) syncBaseWatchesLocked(workspacePaths []string, projects []disc
desired[filepath.Clean(p.Path)] = struct{}{}
}
+ // E-PENPAL-WORKTREE-WATCH: watch .git/worktrees/ dirs to detect worktree add/remove.
+ desiredWtDirs := make(map[string]struct{})
+ for _, p := range projects {
+ if len(p.Worktrees) == 0 {
+ continue
+ }
+ if wtDir := discovery.GitWorktreesDir(p.Path); wtDir != "" {
+ clean := filepath.Clean(wtDir)
+ desiredWtDirs[clean] = struct{}{}
+ desired[clean] = struct{}{}
+ }
+ }
+
for path := range w.baseWatched {
if _, ok := desired[path]; ok {
continue
@@ -355,6 +372,8 @@ func (w *Watcher) syncBaseWatchesLocked(workspacePaths []string, projects []disc
}
w.baseWatched[path] = struct{}{}
}
+
+ w.worktreeWatchDirs = desiredWtDirs
}
func (w *Watcher) syncDynamicWatchesLocked() {
@@ -479,8 +498,26 @@ func (w *Watcher) loop() {
func (w *Watcher) handleEvent(event fsnotify.Event) {
path := filepath.Clean(event.Name)
- // Check if this is a change in a workspace directory (new/removed project)
+ // E-PENPAL-WORKTREE-WATCH: detect worktree add/remove via .git/worktrees/ changes.
parentDir := filepath.Clean(filepath.Dir(path))
+ if _, ok := w.worktreeWatchDirs[parentDir]; ok {
+ w.debounceRefresh("worktrees:"+parentDir, func() {
+ if w.discoverFn != nil {
+ projects, err := w.discoverFn()
+ if err == nil {
+ w.cache.RescanWith(projects)
+ w.focusMu.Lock()
+ w.syncBaseWatchesLocked(w.workspacePaths, projects)
+ w.syncDynamicWatchesLocked()
+ w.focusMu.Unlock()
+ w.Broadcast(Event{Type: EventProjectsChanged})
+ }
+ }
+ })
+ return
+ }
+
+ // Check if this is a change in a workspace directory (new/removed project)
for _, ws := range w.workspacePaths {
if parentDir == filepath.Clean(ws) {
w.debounceRefresh("workspace:"+ws, func() {
diff --git a/apps/penpal/internal/watcher/watcher_test.go b/apps/penpal/internal/watcher/watcher_test.go
index 4e30fc75..f3da87c4 100644
--- a/apps/penpal/internal/watcher/watcher_test.go
+++ b/apps/penpal/internal/watcher/watcher_test.go
@@ -2,10 +2,13 @@ package watcher
import (
"os"
+ "os/exec"
"path/filepath"
"sort"
"testing"
+ "time"
+ "github.com/fsnotify/fsnotify"
"github.com/loganj/penpal/internal/cache"
"github.com/loganj/penpal/internal/discovery"
)
@@ -282,6 +285,143 @@ func TestWindowFocusUnionAcrossWindows(t *testing.T) {
assertWatched(t, w, thoughtsDir2, true, "window B still keeps proj2 watched")
}
+// E-PENPAL-WORKTREE-WATCH: verifies syncBaseWatchesLocked watches .git/worktrees/ for
+// projects that have worktrees, and that handleEvent triggers re-discovery on changes.
+func TestWorktreeWatchDir(t *testing.T) {
+ // Create a real git repo with a worktree so GitWorktreesDir resolves
+ mainDir := t.TempDir()
+ for _, args := range [][]string{
+ {"init"},
+ {"config", "user.email", "test@test.com"},
+ {"config", "user.name", "Test"},
+ {"commit", "--allow-empty", "-m", "init"},
+ } {
+ cmd := exec.Command("git", append([]string{"-C", mainDir}, args...)...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ wtPath := filepath.Join(t.TempDir(), "wt1")
+ cmd := exec.Command("git", "-C", mainDir, "worktree", "add", "-b", "b1", wtPath)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git worktree add: %v\n%s", err, out)
+ }
+
+ worktrees := discovery.DiscoverWorktrees(mainDir)
+ project := discovery.Project{
+ Name: "myrepo",
+ Path: mainDir,
+ Worktrees: worktrees,
+ }
+
+ c := cache.New()
+ c.SetProjects([]discovery.Project{project})
+
+ w, err := New(c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer w.Stop()
+
+ // Sync base watches with the project
+ w.focusMu.Lock()
+ w.syncBaseWatchesLocked(nil, []discovery.Project{project})
+ w.focusMu.Unlock()
+
+ // The .git/worktrees/ dir should be watched
+ gitWtDir := filepath.Join(mainDir, ".git", "worktrees")
+ assertWatched(t, w, gitWtDir, true, ".git/worktrees/ should be base-watched")
+
+ // Verify it's tracked in worktreeWatchDirs
+ if _, ok := w.worktreeWatchDirs[filepath.Clean(gitWtDir)]; !ok {
+ t.Errorf("expected %s in worktreeWatchDirs", gitWtDir)
+ }
+}
+
+// E-PENPAL-WORKTREE-WATCH: verifies that projects without worktrees don't get
+// a .git/worktrees/ watch.
+func TestWorktreeWatchDir_NoWorktrees(t *testing.T) {
+ mainDir := t.TempDir()
+ for _, args := range [][]string{
+ {"init"},
+ {"config", "user.email", "test@test.com"},
+ {"config", "user.name", "Test"},
+ {"commit", "--allow-empty", "-m", "init"},
+ } {
+ cmd := exec.Command("git", append([]string{"-C", mainDir}, args...)...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+
+ project := discovery.Project{
+ Name: "solo",
+ Path: mainDir,
+ // No worktrees
+ }
+
+ c := cache.New()
+ c.SetProjects([]discovery.Project{project})
+
+ w, err := New(c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer w.Stop()
+
+ w.focusMu.Lock()
+ w.syncBaseWatchesLocked(nil, []discovery.Project{project})
+ w.focusMu.Unlock()
+
+ if len(w.worktreeWatchDirs) != 0 {
+ t.Errorf("expected no worktreeWatchDirs, got %v", w.worktreeWatchDirs)
+ }
+}
+
+// E-PENPAL-WORKTREE-WATCH: verifies that an event in .git/worktrees/ triggers re-discovery.
+func TestWorktreeWatchDir_EventTriggersRediscovery(t *testing.T) {
+ // Set up a watcher with a worktreeWatchDir and a discoverFn
+ c := cache.New()
+ c.SetProjects([]discovery.Project{{Name: "proj", Path: "/tmp/proj"}})
+
+ w, err := New(c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer w.Stop()
+
+ discovered := make(chan struct{}, 1)
+ w.discoverFn = func() ([]discovery.Project, error) {
+ select {
+ case discovered <- struct{}{}:
+ default:
+ }
+ return []discovery.Project{{Name: "proj", Path: "/tmp/proj"}}, nil
+ }
+ w.workspacePaths = nil
+
+ // Manually set a worktree watch dir
+ fakeWtDir := t.TempDir()
+ w.worktreeWatchDirs = map[string]struct{}{
+ filepath.Clean(fakeWtDir): {},
+ }
+
+ // Simulate an event in the worktree watch dir
+ fakeEvent := fsnotify.Event{
+ Name: filepath.Join(fakeWtDir, "new-worktree"),
+ Op: fsnotify.Create,
+ }
+ w.handleEvent(fakeEvent)
+
+ // The debounce timer fires after 100ms; wait for the discovery callback
+ select {
+ case <-discovered:
+ // success
+ case <-time.After(500 * time.Millisecond):
+ t.Fatal("expected discoverFn to be called after worktree dir event")
+ }
+}
+
func assertWatched(t *testing.T, w *Watcher, dir string, expected bool, context string) {
t.Helper()
watched := w.watcher.WatchList()