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
37 changes: 37 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,39 @@ func FindMainWorktree(path string) string {

return ""
}

// GitCommonDir returns the shared .git directory for the repository at
// projectPath (e.g. "/repo/.git"). Returns "" for non-git directories.
// Works for both main worktrees and linked worktrees.
// E-PENPAL-WORKTREE-WATCH: resolves the git common dir for fs watching.
func GitCommonDir(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)
}
return filepath.Clean(commonDir)
}

// GitWorktreesDir returns the path to the .git/worktrees/ directory for the
// repository that projectPath belongs to, or "" if it doesn't exist.
// E-PENPAL-WORKTREE-WATCH: resolves the worktrees metadata directory for fs watching.
func GitWorktreesDir(projectPath string) string {
commonDir := GitCommonDir(projectPath)
if commonDir == "" {
return ""
}
wtDir := filepath.Join(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)
}
}
105 changes: 75 additions & 30 deletions apps/penpal/internal/watcher/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ type Watcher struct {
windowFocuses map[string]focusTarget
baseWatched map[string]struct{}
dynamicWatched map[string]struct{}

// E-PENPAL-WORKTREE-WATCH: tracks watched dirs for worktree detection.
// worktreeWatchDirs: .git/worktrees/ dirs (projects with existing worktrees)
// gitDirWatches: .git/ dirs (projects without worktrees, to detect first add)
// Both are read in handleEvent and written in syncBaseWatchesLocked — protected by focusMu.
worktreeWatchDirs map[string]struct{}
gitDirWatches map[string]struct{}
}

// New creates a new watcher
Expand All @@ -87,9 +94,11 @@ 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{}),
gitDirWatches: make(map[string]struct{}),
}

return w, nil
Expand Down Expand Up @@ -330,6 +339,29 @@ func (w *Watcher) syncBaseWatchesLocked(workspacePaths []string, projects []disc
desired[filepath.Clean(p.Path)] = struct{}{}
}

// E-PENPAL-WORKTREE-WATCH: watch .git/worktrees/ for projects with worktrees,
// and .git/ for projects without worktrees (to detect the first worktree add).
desiredWtDirs := make(map[string]struct{})
desiredGitDirs := make(map[string]struct{})
for _, p := range projects {
if p.Name == "(root)" {
continue
}
if len(p.Worktrees) > 0 {
if wtDir := discovery.GitWorktreesDir(p.Path); wtDir != "" {
clean := filepath.Clean(wtDir)
desiredWtDirs[clean] = struct{}{}
desired[clean] = struct{}{}
}
} else {
if gitDir := discovery.GitCommonDir(p.Path); gitDir != "" {
clean := filepath.Clean(gitDir)
desiredGitDirs[clean] = struct{}{}
desired[clean] = struct{}{}
}
}
}

for path := range w.baseWatched {
if _, ok := desired[path]; ok {
continue
Expand All @@ -355,6 +387,9 @@ func (w *Watcher) syncBaseWatchesLocked(workspacePaths []string, projects []disc
}
w.baseWatched[path] = struct{}{}
}

w.worktreeWatchDirs = desiredWtDirs
w.gitDirWatches = desiredGitDirs
}

func (w *Watcher) syncDynamicWatchesLocked() {
Expand Down Expand Up @@ -475,27 +510,49 @@ func (w *Watcher) loop() {
}
}

// rediscoverProjects runs full project discovery, updates the cache and watches,
// and broadcasts a projects-changed SSE event.
func (w *Watcher) rediscoverProjects() {
if w.discoverFn == nil {
return
}
projects, err := w.discoverFn()
if err != nil {
return
}
w.cache.RescanWith(projects)
w.focusMu.Lock()
w.syncBaseWatchesLocked(w.workspacePaths, projects)
w.syncDynamicWatchesLocked()
w.focusMu.Unlock()
w.Broadcast(Event{Type: EventProjectsChanged})
}

// E-PENPAL-WATCHER: routes fsnotify events to project/workspace refresh or SSE broadcast.
func (w *Watcher) handleEvent(event fsnotify.Event) {
path := filepath.Clean(event.Name)
parentDir := filepath.Clean(filepath.Dir(path))

// E-PENPAL-WORKTREE-WATCH: detect worktree add/remove via .git/worktrees/ changes,
// or first worktree creation via .git/ directory watch.
w.focusMu.Lock()
_, isWorktreeDir := w.worktreeWatchDirs[parentDir]
_, isGitDir := w.gitDirWatches[parentDir]
w.focusMu.Unlock()

if isWorktreeDir {
w.debounceRefresh("worktrees:"+parentDir, w.rediscoverProjects)
return
}
if isGitDir && filepath.Base(path) == "worktrees" && event.Op&fsnotify.Create != 0 {
w.debounceRefresh("worktrees:"+parentDir, w.rediscoverProjects)
return
}

// Check if this is a change in a workspace directory (new/removed project)
parentDir := filepath.Clean(filepath.Dir(path))
for _, ws := range w.workspacePaths {
if parentDir == filepath.Clean(ws) {
w.debounceRefresh("workspace:"+ws, 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})
}
}
})
w.debounceRefresh("workspace:"+ws, w.rediscoverProjects)
return
}
}
Expand Down Expand Up @@ -526,19 +583,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
if matched {
for _, p := range w.cache.Projects() {
if parentDir == filepath.Clean(p.Path) {
w.debounceRefresh("sources:"+p.QualifiedName(), 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})
}
}
})
w.debounceRefresh("sources:"+p.QualifiedName(), w.rediscoverProjects)
return
}
}
Expand Down
Loading