Skip to content
Merged
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**: 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`).
← [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_IgnoresGitignore, TestProjectHasAnyMarkdown_SkipsVCSDirs, TestAllFiles_DeduplicatesAllMarkdown, TestEnsureProjectScanned_NoDuplicateScans, TestResolveFileInfo, TestUpsertFile, TestRemoveFile, TestRescanWith_PreservesUnchangedProjects, TestSourcesChanged) | — | — | — |
| 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
108 changes: 52 additions & 56 deletions apps/penpal/internal/discovery/worktree.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,54 @@
package discovery

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

// gitCommonDirFS resolves the shared .git directory using only filesystem
// reads — no subprocess. For a main worktree .git is a directory; for a
// linked worktree .git is a file containing "gitdir: <path>" and the
// referenced gitdir contains a "commondir" file pointing back to the
// shared .git.
func gitCommonDirFS(projectPath string) string {
gitPath := filepath.Join(projectPath, ".git")
info, err := os.Lstat(gitPath)
if err != nil {
return ""
}
// Main worktree: .git is a directory — it IS the common dir.
if info.IsDir() {
return gitPath
}
// Linked worktree: .git is a file with "gitdir: <path>".
data, err := os.ReadFile(gitPath)
if err != nil {
return ""
}
line := strings.TrimSpace(string(data))
if !strings.HasPrefix(line, "gitdir: ") {
return ""
}
gitDir := strings.TrimPrefix(line, "gitdir: ")
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(projectPath, gitDir)
}
gitDir = filepath.Clean(gitDir)
// Read commondir file to find the shared .git directory.
cdPath := filepath.Join(gitDir, "commondir")
cdData, err := os.ReadFile(cdPath)
if err != nil {
return ""
}
commonDir := strings.TrimSpace(string(cdData))
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(gitDir, commonDir)
}
return filepath.Clean(commonDir)
}

// Worktree represents a git worktree associated with a project.
type Worktree struct {
Name string `json:"name"` // directory name (e.g., "fancy-name")
Expand Down Expand Up @@ -81,64 +124,17 @@ func parseWorktreeList(projectPath string, output string) []Worktree {
return worktrees
}

// ResolveWorktree finds the worktree that contains the given absolute path.
// Returns the worktree name and the main project path, or empty strings if
// the path doesn't belong to any worktree.
func ResolveWorktree(projectPath string, absPath string) (worktreeName string, mainProjectPath string) {
absPath = filepath.Clean(absPath)

// First check if this path is inside the main project
mainPath := filepath.Clean(projectPath)
if strings.HasPrefix(absPath, mainPath+"/") || absPath == mainPath {
// Check if it's inside a worktree subdirectory
worktrees := DiscoverWorktrees(projectPath)
for _, wt := range worktrees {
if !wt.IsMain && (strings.HasPrefix(absPath, wt.Path+"/") || absPath == wt.Path) {
return wt.Name, mainPath
}
}
return "", mainPath
}

return "", ""
}

// FindMainWorktree returns the path to the main worktree for a given path
// that might be inside a worktree. It reads the .git file to find the
// gitdir and traces back to the main worktree.
func FindMainWorktree(path string) string {
cmd := exec.Command("git", "-C", path, "rev-parse", "--git-common-dir")
out, err := cmd.Output()
if err != nil {
// gitWorktreesDir returns the path to the .git/worktrees/ directory for the
// repository that projectPath belongs to, or "" if it doesn't exist.
// Uses pure filesystem reads via gitCommonDirFS — no subprocess calls.
func gitWorktreesDir(projectPath string) string {
commonDir := gitCommonDirFS(projectPath)
if commonDir == "" {
return ""
}
commonDir := strings.TrimSpace(string(out))
if commonDir == "" || commonDir == "." {
return ""
wtDir := filepath.Join(commonDir, "worktrees")
if info, err := os.Stat(wtDir); err == nil && info.IsDir() {
return wtDir
}

// commonDir is the .git directory of the main worktree
// If it's relative, resolve it relative to the path
if !filepath.IsAbs(commonDir) {
// Get the actual git dir for this worktree first
cmd2 := exec.Command("git", "-C", path, "rev-parse", "--git-dir")
out2, err := cmd2.Output()
if err != nil {
return ""
}
gitDir := strings.TrimSpace(string(out2))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
commonDir = filepath.Join(gitDir, commonDir)
}

commonDir = filepath.Clean(commonDir)

// The main worktree is the parent of the .git directory
if filepath.Base(commonDir) == ".git" {
return filepath.Dir(commonDir)
}

return ""
}
144 changes: 144 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,144 @@ 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 TestWorktreesDir_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 TestWorktreesDir_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 TestWorktreesDir_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 TestWorktreesDir_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)
}
}

// E-PENPAL-WORKTREE-WATCH: verifies gitCommonDirFS returns "" for malformed .git file.
func TestGitCommonDirFS_MalformedGitFile(t *testing.T) {
dir := t.TempDir()
// .git file with no "gitdir:" prefix
os.WriteFile(filepath.Join(dir, ".git"), []byte("not a gitdir line\n"), 0o644)
if got := gitCommonDirFS(dir); got != "" {
t.Errorf("expected empty for malformed .git file, got %q", got)
}
}

// E-PENPAL-WORKTREE-WATCH: verifies gitCommonDirFS returns "" when commondir file is missing.
func TestGitCommonDirFS_MissingCommondir(t *testing.T) {
dir := t.TempDir()
gitDir := filepath.Join(dir, "fake-gitdir")
os.MkdirAll(gitDir, 0o755)
// .git file points to a valid directory but commondir file doesn't exist
os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644)
if got := gitCommonDirFS(dir); got != "" {
t.Errorf("expected empty for missing commondir, got %q", got)
}
}
Loading