From b0dfac9c0da776a12573d6ba1bb4cfa327057876 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 30 Mar 2026 00:23:02 -0400 Subject: [PATCH] feat(penpal): live-detect worktree additions and removals Watch each project's .git/worktrees/ directory so that `git worktree add` and `git worktree remove` are detected without restarting the server. On change, full re-discovery runs and an SSE event pushes the updated worktree list to the frontend. Co-Authored-By: Claude Opus 4.6 --- apps/penpal/ERD.md | 3 + apps/penpal/PRODUCT.md | 2 +- apps/penpal/TESTING.md | 1 + apps/penpal/internal/discovery/worktree.go | 27 ++++ .../internal/discovery/worktree_test.go | 122 +++++++++++++++ apps/penpal/internal/watcher/watcher.go | 45 +++++- apps/penpal/internal/watcher/watcher_test.go | 140 ++++++++++++++++++ 7 files changed, 335 insertions(+), 5 deletions(-) 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()