Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/penpal/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ see-also:
- <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.
← [P-PENPAL-WORKTREE](PRODUCT.md#P-PENPAL-WORKTREE)

- <a id="E-PENPAL-WORKTREE-WATCH"></a>**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)

- <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.
← [P-PENPAL-CLAUDE-PLANS](PRODUCT.md#P-PENPAL-CLAUDE-PLANS)

Expand Down
2 changes: 1 addition & 1 deletion apps/penpal/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Penpal is a desktop application and local web server for collaborative review of

- <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.

- <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.
- <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.

- <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.

Expand Down
1 change: 1 addition & 0 deletions apps/penpal/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand Down
27 changes: 27 additions & 0 deletions apps/penpal/internal/discovery/worktree.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package discovery

import (
"os"
"os/exec"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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 ""
}
122 changes: 122 additions & 0 deletions apps/penpal/internal/discovery/worktree_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package discovery

import (
"os"
"os/exec"
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -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)
}
}
45 changes: 41 additions & 4 deletions apps/penpal/internal/watcher/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -355,6 +372,8 @@ func (w *Watcher) syncBaseWatchesLocked(workspacePaths []string, projects []disc
}
w.baseWatched[path] = struct{}{}
}

w.worktreeWatchDirs = desiredWtDirs
}

func (w *Watcher) syncDynamicWatchesLocked() {
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading