From c4269a218e6514d49f9195cb349015a50c008142 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 6 Mar 2026 14:10:26 -0500 Subject: [PATCH] feat(penpal): add git worktree awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover git worktrees for each project and expose them throughout the stack — backend discovery, cache, file scanning, comments, MCP tools, and frontend UI. Backend: - Discover worktrees via `git worktree list --porcelain` during project scan - Add worktree-scoped file scanning (ScanProjectSourcesForWorktree) that remaps both tree and files sources to worktree paths - Scope comments/threads to worktrees via optional worktree parameter - Handle worktree `.git` files (not directories) in git info resolution - Add `/api/ready` endpoint so Tauri waits for full server initialization before loading the webview, fixing a startup race condition - File watcher monitors worktree paths for changes Frontend: - Parse `@worktree` suffix in URLs for worktree-scoped navigation - Sidebar shows worktree sub-items with stacked name + branch layout - Project cards show worktree list with stacked presentation - File and project pages pass worktree param to all API calls - Comments panel scopes threads to active worktree Co-Authored-By: Claude Opus 4.6 --- apps/penpal/frontend/src-tauri/src/lib.rs | 21 +- apps/penpal/frontend/src/api.ts | 26 +- .../frontend/src/components/CommentsPanel.tsx | 21 +- .../penpal/frontend/src/components/Layout.tsx | 91 +++++-- apps/penpal/frontend/src/index.css | 19 +- .../frontend/src/pages/FilePage.test.tsx | 48 +++- apps/penpal/frontend/src/pages/FilePage.tsx | 51 ++-- .../penpal/frontend/src/pages/ProjectPage.tsx | 16 +- .../frontend/src/pages/WorkspacePage.tsx | 20 +- apps/penpal/frontend/src/types.ts | 9 + apps/penpal/frontend/src/utils/worktree.ts | 34 +++ apps/penpal/internal/cache/cache.go | 105 ++++++++ apps/penpal/internal/cache/worktree_test.go | 203 +++++++++++++++ apps/penpal/internal/comments/operations.go | 72 ++++- apps/penpal/internal/comments/storage.go | 33 ++- apps/penpal/internal/discovery/discovery.go | 11 + apps/penpal/internal/discovery/git.go | 19 ++ apps/penpal/internal/discovery/worktree.go | 142 ++++++++++ .../internal/discovery/worktree_test.go | 100 +++++++ apps/penpal/internal/mcpserver/tools.go | 53 ++-- .../internal/mcpserver/worktree_test.go | 245 ++++++++++++++++++ apps/penpal/internal/server/comments.go | 24 +- apps/penpal/internal/server/file.go | 18 +- apps/penpal/internal/server/server.go | 82 ++++-- apps/penpal/internal/server/worktree_test.go | 225 ++++++++++++++++ apps/penpal/internal/watcher/watcher.go | 61 ++++- apps/penpal/internal/watcher/watcher_test.go | 91 +++++++ thoughts/plans/penpal-worktree-awareness.md | 228 ++++++++++++++++ 28 files changed, 1928 insertions(+), 140 deletions(-) create mode 100644 apps/penpal/frontend/src/utils/worktree.ts create mode 100644 apps/penpal/internal/cache/worktree_test.go create mode 100644 apps/penpal/internal/discovery/worktree.go create mode 100644 apps/penpal/internal/discovery/worktree_test.go create mode 100644 apps/penpal/internal/mcpserver/worktree_test.go create mode 100644 apps/penpal/internal/server/worktree_test.go create mode 100644 apps/penpal/internal/watcher/watcher_test.go create mode 100644 thoughts/plans/penpal-worktree-awareness.md diff --git a/apps/penpal/frontend/src-tauri/src/lib.rs b/apps/penpal/frontend/src-tauri/src/lib.rs index 3f771f5a..d83adab0 100644 --- a/apps/penpal/frontend/src-tauri/src/lib.rs +++ b/apps/penpal/frontend/src-tauri/src/lib.rs @@ -50,11 +50,24 @@ pub fn run() { // Store the child so we can kill it on quit *app.state::().0.lock().unwrap() = Some(child); - // Wait for server to be ready + // Wait for server to be fully ready (projects discovered and files scanned). + // The /api/ready endpoint blocks until initialization is complete. let addr = format!("127.0.0.1:{}", port); - for _ in 0..50 { - if std::net::TcpStream::connect(&addr).is_ok() { - break; + for _ in 0..300 { + if let Ok(mut stream) = std::net::TcpStream::connect(&addr) { + use std::io::{Read, Write}; + let req = format!("GET /api/ready HTTP/1.0\r\nHost: {}\r\n\r\n", addr); + if stream.write_all(req.as_bytes()).is_ok() { + // Set a generous timeout — initialization may take a while + stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok(); + let mut buf = [0u8; 256]; + if let Ok(n) = stream.read(&mut buf) { + let resp = String::from_utf8_lossy(&buf[..n]); + if resp.contains("200") { + break; + } + } + } } std::thread::sleep(std::time::Duration::from_millis(100)); } diff --git a/apps/penpal/frontend/src/api.ts b/apps/penpal/frontend/src/api.ts index 524e3e76..6486a0d9 100644 --- a/apps/penpal/frontend/src/api.ts +++ b/apps/penpal/frontend/src/api.ts @@ -38,6 +38,10 @@ async function apiVoid(path: string, options?: RequestInit): Promise { if (!res.ok) throw new Error(`API error: ${res.status}`); } +function wtParam(worktree?: string): string { + return worktree ? `&worktree=${encodeURIComponent(worktree)}` : ''; +} + export const api = { // Projects listProjects: () => apiFetch('/api/projects'), @@ -47,8 +51,8 @@ export const api = { apiVoid('/api/projects', { method: 'DELETE', body: JSON.stringify({ path }) }), // Project files - getProjectFiles: (qn: string) => - apiFetch(`/api/project/${qn}`), + getProjectFiles: (qn: string, worktree?: string) => + apiFetch(`/api/project/${qn}${worktree ? '?worktree=' + encodeURIComponent(worktree) : ''}`), getProjectInfo: (name: string) => apiFetch(`/api/project-info?name=${encodeURIComponent(name)}`), deleteProject: (project: string) => @@ -61,30 +65,30 @@ export const api = { getInReview: () => apiFetch('/api/in-review'), // Threads - getThreads: (project: string, file: string) => - apiFetch(`/api/threads?project=${encodeURIComponent(project)}&path=${encodeURIComponent(file)}`), + getThreads: (project: string, file: string, worktree?: string) => + apiFetch(`/api/threads?project=${encodeURIComponent(project)}&path=${encodeURIComponent(file)}${wtParam(worktree)}`), getAllThreads: (project: string) => apiFetch(`/api/threads?project=${encodeURIComponent(project)}`), - createThread: (data: CreateThreadReq) => + createThread: (data: CreateThreadReq & { worktree?: string }) => apiFetch('/api/threads', { method: 'POST', body: JSON.stringify(data) }), - replyToThread: (id: string, data: ReplyReq) => + replyToThread: (id: string, data: ReplyReq & { worktree?: string }) => apiFetch(`/api/threads/${encodeURIComponent(id)}/comments`, { method: 'POST', body: JSON.stringify(data), }), - patchThread: (id: string, data: PatchThreadReq) => + patchThread: (id: string, data: PatchThreadReq & { worktree?: string }) => apiFetch<{ ok: boolean }>(`/api/threads/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(data), }), // Reviews - getReviews: (project: string) => - apiFetch(`/api/reviews?project=${encodeURIComponent(project)}`), + getReviews: (project: string, worktree?: string) => + apiFetch(`/api/reviews?project=${encodeURIComponent(project)}${wtParam(worktree)}`), // Raw file content - getRawFile: (project: string, path: string) => - fetch(`${API_BASE}/api/raw?project=${encodeURIComponent(project)}&path=${encodeURIComponent(path)}`, { + getRawFile: (project: string, path: string, worktree?: string) => + fetch(`${API_BASE}/api/raw?project=${encodeURIComponent(project)}&path=${encodeURIComponent(path)}${wtParam(worktree)}`, { cache: 'no-store', }).then( (r) => { diff --git a/apps/penpal/frontend/src/components/CommentsPanel.tsx b/apps/penpal/frontend/src/components/CommentsPanel.tsx index db69193b..4ab90328 100644 --- a/apps/penpal/frontend/src/components/CommentsPanel.tsx +++ b/apps/penpal/frontend/src/components/CommentsPanel.tsx @@ -9,6 +9,7 @@ interface CommentsPanelProps { threads: ThreadResponse[]; anchorLines: Record; project: string; + worktree?: string; filePath: string; onRefresh: () => void; onThreadFocus?: (threadId: string, line: number) => void; @@ -45,6 +46,7 @@ export default function CommentsPanel({ threads, anchorLines, project, + worktree, filePath, onRefresh, onThreadFocus, @@ -147,6 +149,7 @@ export default function CommentsPanel({ anchor={pendingAnchor} selectedText={pendingText || ''} project={project} + worktree={worktree} filePath={filePath} onSubmit={() => { onCancelNewThread?.(); @@ -170,6 +173,7 @@ export default function CommentsPanel({ isHighlighted={highlightedThread === t.id} onClick={() => handleThreadClick(t.id)} project={project} + worktree={worktree} filePath={filePath} onRefresh={onRefresh} replyOpen={replyFormThread === t.id} @@ -188,6 +192,7 @@ export default function CommentsPanel({ isHighlighted={highlightedThread === t.id} onClick={() => handleThreadClick(t.id)} project={project} + worktree={worktree} filePath={filePath} onRefresh={onRefresh} replyOpen={replyFormThread === t.id} @@ -227,6 +232,7 @@ interface NewThreadFormProps { anchor: Anchor; selectedText: string; project: string; + worktree?: string; filePath: string; onSubmit: () => void; onCancel: () => void; @@ -237,6 +243,7 @@ function NewThreadForm({ anchor, selectedText, project, + worktree, filePath, onSubmit, onCancel, @@ -263,6 +270,7 @@ function NewThreadForm({ author: author.trim(), role: 'human', body: body.trim(), + worktree: worktree || undefined, }); onSubmit(); } catch (err) { @@ -324,6 +332,7 @@ interface ThreadCardProps { isHighlighted: boolean; onClick: () => void; project: string; + worktree?: string; filePath: string; onRefresh: () => void; replyOpen: boolean; @@ -338,6 +347,7 @@ function ThreadCard({ isHighlighted, onClick, project, + worktree, filePath, onRefresh, replyOpen, @@ -353,14 +363,14 @@ function ThreadCard({ const handleResolve = (e: React.MouseEvent) => { e.stopPropagation(); const author = getSavedAuthor() || 'anonymous'; - api.patchThread(thread.id, { project, path: filePath, status: 'resolved', resolvedBy: author }) + api.patchThread(thread.id, { project, path: filePath, status: 'resolved', resolvedBy: author, worktree: worktree || undefined }) .then(onRefresh) .catch((err) => console.error('Failed to resolve:', err)); }; const handleReopen = (e: React.MouseEvent) => { e.stopPropagation(); - api.patchThread(thread.id, { project, path: filePath, status: 'open' }) + api.patchThread(thread.id, { project, path: filePath, status: 'open', worktree: worktree || undefined }) .then(onRefresh) .catch((err) => console.error('Failed to reopen:', err)); }; @@ -371,7 +381,7 @@ function ThreadCard({ onToggleReply(); return; } - api.replyToThread(thread.id, { project, path: filePath, author, role: 'human', body: text }) + api.replyToThread(thread.id, { project, path: filePath, author, role: 'human', body: text, worktree: worktree || undefined }) .then(onRefresh) .catch((err) => console.error('Failed to submit suggested reply:', err)); }; @@ -487,6 +497,7 @@ function ThreadCard({ { onToggleReply(); @@ -504,12 +515,13 @@ function ThreadCard({ interface ReplyFormProps { threadId: string; project: string; + worktree?: string; filePath: string; onSubmit: () => void; onCancel: () => void; } -function ReplyForm({ threadId, project, filePath, onSubmit, onCancel }: ReplyFormProps) { +function ReplyForm({ threadId, project, worktree, filePath, onSubmit, onCancel }: ReplyFormProps) { const [body, setBody] = useState(''); const [author, setAuthor] = useState(getSavedAuthor()); const [submitting, setSubmitting] = useState(false); @@ -530,6 +542,7 @@ function ReplyForm({ threadId, project, filePath, onSubmit, onCancel }: ReplyFor author: author.trim(), role: 'human', body: body.trim(), + worktree: worktree || undefined, }); onSubmit(); } catch (err) { diff --git a/apps/penpal/frontend/src/components/Layout.tsx b/apps/penpal/frontend/src/components/Layout.tsx index ddd582ed..278cae84 100644 --- a/apps/penpal/frontend/src/components/Layout.tsx +++ b/apps/penpal/frontend/src/components/Layout.tsx @@ -10,6 +10,7 @@ import FindBar from './FindBar'; import InstallToolsModal from './InstallToolsModal'; import type { Heading } from './TableOfContents'; import type { APIProject, SSEEvent } from '../types'; +import { parseProjectWorktree } from '../utils/worktree'; export interface LayoutContext { setHeadings: (headings: Heading[]) => void; @@ -252,12 +253,30 @@ export default function Layout() { return () => window.removeEventListener('keydown', handleKeyDown); }, [goBack, goForward, canGoBack, canGoForward]); - // Detect project-mode view: /project/:qn or /file/:qn/* + // Detect project-mode view: /project/:qn[@worktree] or /file/:qn[@worktree]/* // QN may contain slashes (e.g. "Development/birdseye"), so match against known projects const pathAfterPrefix = location.pathname.match(/^\/(project|file)\/(.+)/)?.[2] || ''; - const activeProject = pathAfterPrefix - ? projects.find((p) => pathAfterPrefix === p.qualifiedName || pathAfterPrefix.startsWith(p.qualifiedName + '/')) - : null; + const { activeProject, activeWorktree } = (() => { + if (!pathAfterPrefix) return { activeProject: null, activeWorktree: '' }; + // Strip @worktree suffix before matching against project QNs + const sorted = [...projects].sort((a, b) => b.qualifiedName.length - a.qualifiedName.length); + for (const p of sorted) { + // Check for exact match or prefix match (with / or @ following) + if ( + pathAfterPrefix === p.qualifiedName || + pathAfterPrefix.startsWith(p.qualifiedName + '/') || + pathAfterPrefix.startsWith(p.qualifiedName + '@') + ) { + const rest = pathAfterPrefix.slice(p.qualifiedName.length); + const { worktree } = parseProjectWorktree(p.qualifiedName + rest.split('/')[0]); + return { activeProject: p, activeWorktree: worktree }; + } + } + // Fallback: try parsing with @ + const parsed = parseProjectWorktree(pathAfterPrefix.split('/').slice(0, 2).join('/')); + const fallbackProject = projects.find((p) => p.qualifiedName === parsed.project) || null; + return { activeProject: fallbackProject, activeWorktree: parsed.worktree }; + })(); // Show project-mode sidebar as soon as URL matches, even before projects load const isProjectMode = !!pathAfterPrefix; @@ -468,30 +487,64 @@ export default function Layout() { .some((p) => p.agentConnected) && } )} - {activeProject && ( + {activeProject && activeProject.worktrees && activeProject.worktrees.length > 1 ? ( + <> + {activeProject.worktrees.map((wt) => { + const isActive = wt.isMain ? !activeWorktree : activeWorktree === wt.name; + const url = wt.isMain + ? `/project/${activeProject.qualifiedName}` + : `/project/${activeProject.qualifiedName}@${wt.name}`; + return ( + + + {wt.isMain ? activeProject.name : wt.name} + {wt.isMain && activeProject.badges.map((b) => ( + + {b.text} + + ))} + + {wt.branch && ( + {wt.branch} + )} + + ); + })} + + ) : activeProject ? ( - {activeProject.name} - {activeProject.badges.map((b) => ( - - {b.text} - - ))} - {activeProject.agentConnected && } + + {activeProject.name} + {activeProject.badges.map((b) => ( + + {b.text} + + ))} + {activeProject.agentConnected && } + {activeProject.branch && ( - + {activeProject.branch} {activeProject.dirty && *} )} - )} + ) : null} ) : ( <> diff --git a/apps/penpal/frontend/src/index.css b/apps/penpal/frontend/src/index.css index 9e482ad4..7ad03c45 100644 --- a/apps/penpal/frontend/src/index.css +++ b/apps/penpal/frontend/src/index.css @@ -323,13 +323,10 @@ a:hover { text-decoration: underline; } background: var(--badge-active-bg); color: var(--badge-active-color); } -.sidebar-item .branch-info { - font-size: 0.8em; - color: var(--accent-success); - flex-shrink: 0; - margin-left: auto; -} -.sidebar-item .branch-dirty { color: var(--accent-danger); } +.sidebar-item.worktree-item { flex-direction: column; align-items: flex-start; gap: 1px; } +.sidebar-item.worktree-item .worktree-name { display: flex; align-items: center; gap: 6px; } +.sidebar-item.worktree-item .branch-name { font-size: 0.85em; color: var(--accent-success); opacity: 0.8; } +.sidebar-item.worktree-item .branch-dirty { color: var(--accent-danger); } .agent-dot { display: inline-block; width: 7px; @@ -1179,9 +1176,15 @@ a:hover { text-decoration: underline; } } .project-card-name a { text-decoration: none; } .project-card-name a:hover { text-decoration: none; } -.project-card-meta { font-size: 0.8em; color: var(--text-subtle); margin-bottom: 8px; } +.project-card-meta { font-size: 0.8em; color: var(--text-subtle); margin-bottom: 8px; display: flex; flex-wrap: wrap; gap: 4px 8px; } .project-card-meta .branch { color: var(--accent-success); } .project-card-meta .dirty { color: var(--accent-danger); } +.project-card-worktrees { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.worktree-card-item { display: flex; flex-direction: column; text-decoration: none; padding: 4px 8px; border-radius: 4px; background: var(--bg-surface-tertiary); min-width: 0; } +.worktree-card-item:hover { background: var(--bg-surface-hover); } +.worktree-card-name { font-size: 0.85em; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.worktree-card-branch { font-size: 0.78em; color: var(--accent-success); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.worktree-card-branch .dirty { color: var(--accent-danger); } .project-card .project-age { font-size: 0.8em; color: var(--text-faint); } .project-card-name .agent-dot { margin-left: 8px; diff --git a/apps/penpal/frontend/src/pages/FilePage.test.tsx b/apps/penpal/frontend/src/pages/FilePage.test.tsx index 1dad2761..5539cb96 100644 --- a/apps/penpal/frontend/src/pages/FilePage.test.tsx +++ b/apps/penpal/frontend/src/pages/FilePage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, waitFor, act } from '@testing-library/react'; -import { MemoryRouter, Routes, Route, Outlet } from 'react-router-dom'; +import { MemoryRouter, Routes, Route, Outlet, useNavigate } from 'react-router-dom'; import FilePage from './FilePage'; import { api } from '../api'; import { useSSE } from '../hooks/useSSE'; @@ -41,9 +41,9 @@ function LayoutWrapper() { return ; } -function renderFilePage() { +function renderFilePage(url = '/file/ws/proj/thoughts/plan.md') { return render( - + }> } /> @@ -53,6 +53,25 @@ function renderFilePage() { ); } +// Helper component to expose programmatic navigation for tests +let testNavigate: ReturnType; +function NavTrigger() { + testNavigate = useNavigate(); + return null; +} + +function renderFilePageNavigable(url = '/file/ws/proj/thoughts/plan.md') { + return render( + + + }> + } /> + + + , + ); +} + const agentNotRunning = { running: false, project: 'ws/proj', @@ -389,6 +408,29 @@ describe('FilePage', () => { }); }); + it('re-fetches file metadata when worktree changes', async () => { + vi.mocked(api.getAgentStatus).mockResolvedValue(agentNotRunning); + vi.mocked(api.getProjectFiles).mockResolvedValue([ + { name: 'Thoughts', source: 'thoughts', sourceType: 'thoughts', auto: false, files: [{ path: 'thoughts/plan.md', name: 'plan.md', title: 'Plan', fileType: 'thoughts', age: '1h' }] }, + ]); + + renderFilePageNavigable('/file/ws/proj@wt1/thoughts/plan.md'); + + await waitFor(() => { + expect(api.getProjectFiles).toHaveBeenCalledWith('ws/proj', 'wt1'); + }); + + // Navigate to a different worktree on the same file + vi.mocked(api.getProjectFiles).mockClear(); + await act(async () => { + testNavigate('/file/ws/proj@wt2/thoughts/plan.md'); + }); + + await waitFor(() => { + expect(api.getProjectFiles).toHaveBeenCalledWith('ws/proj', 'wt2'); + }); + }); + it('refreshes content on SSE files event', async () => { vi.mocked(api.getAgentStatus).mockResolvedValue(agentNotRunning); diff --git a/apps/penpal/frontend/src/pages/FilePage.tsx b/apps/penpal/frontend/src/pages/FilePage.tsx index 39c71662..bbd136ef 100644 --- a/apps/penpal/frontend/src/pages/FilePage.tsx +++ b/apps/penpal/frontend/src/pages/FilePage.tsx @@ -15,6 +15,7 @@ import type { Heading } from '../components/TableOfContents'; import type { LayoutContext } from '../components/Layout'; import type { ThreadHighlight } from '../components/rehypeCommentHighlights'; import type { ThreadResponse, Anchor, AgentStatus } from '../types'; +import { parseProjectWorktree } from '../utils/worktree'; export default function FilePage() { const location = useLocation(); @@ -111,25 +112,40 @@ export default function FilePage() { return highlights; }, [threads, anchorLines, pendingAnchor]); - // Resolve project QN and file path from URL by matching against known projects. - // The URL is /file/{qualifiedName}/{filePath} where qualifiedName may contain slashes - // (e.g. "Development/birdseye"), so we can't rely on a single :param. - const { project, path } = useMemo(() => { + // Resolve project QN, worktree, and file path from URL by matching against known projects. + // URL: /file/{qualifiedName}[@worktree]/{filePath} + // QN may contain slashes (e.g. "Development/birdseye"), so we match longest-first. + const { project, worktree, path } = useMemo(() => { const rest = location.pathname.replace(/^\/file\//, ''); // Try matching against known projects (longest match first) const sorted = [...projects].sort((a, b) => b.qualifiedName.length - a.qualifiedName.length); for (const p of sorted) { - if (rest === p.qualifiedName || rest.startsWith(p.qualifiedName + '/')) { - return { - project: p.qualifiedName, - path: rest.slice(p.qualifiedName.length + 1), - }; + if (rest === p.qualifiedName || rest.startsWith(p.qualifiedName + '/') || rest.startsWith(p.qualifiedName + '@')) { + const afterQN = rest.slice(p.qualifiedName.length); + // afterQN could be "@worktree/path", "/path", or "" + let wt = ''; + let filePath = ''; + if (afterQN.startsWith('@')) { + const slashIdx = afterQN.indexOf('/'); + if (slashIdx === -1) { + wt = afterQN.slice(1); + } else { + wt = afterQN.slice(1, slashIdx); + filePath = afterQN.slice(slashIdx + 1); + } + } else if (afterQN.startsWith('/')) { + filePath = afterQN.slice(1); + } + return { project: p.qualifiedName, worktree: wt, path: filePath }; } } - // Fallback: assume first two segments are the QN + // Fallback: parse with @ support const segments = rest.split('/'); + const qnCandidate = segments.slice(0, 2).join('/'); + const { project: proj, worktree: wt } = parseProjectWorktree(qnCandidate); return { - project: segments.slice(0, 2).join('/'), + project: proj, + worktree: wt, path: segments.slice(2).join('/'), }; }, [location.pathname, projects]); @@ -138,7 +154,7 @@ export default function FilePage() { const fetchContent = useCallback(async (opts?: { silent?: boolean }) => { if (!project || !path) return; try { - const content = await api.getRawFile(project, path); + const content = await api.getRawFile(project, path, worktree || undefined); setRawMarkdown(content); setError(null); } catch (err) { @@ -149,13 +165,13 @@ export default function FilePage() { } finally { setLoading(false); } - }, [project, path]); + }, [project, path, worktree]); // Fetch threads const fetchThreads = useCallback(async () => { if (!project || !path) return; try { - const data = await api.getThreads(project, path); + const data = await api.getThreads(project, path, worktree || undefined); setThreads(data); // Build anchor lines from thread data // The server resolves anchors; here we use startLine from the anchor @@ -167,7 +183,7 @@ export default function FilePage() { } catch (err) { console.error('Failed to load threads:', err); } - }, [project, path]); + }, [project, path, worktree]); // Start polling for agent status updates const startAgentPolling = useCallback(() => { @@ -215,7 +231,7 @@ export default function FilePage() { useEffect(() => { if (!project || !path) return; // Get file metadata from project files list - api.getProjectFiles(project).then((groups) => { + api.getProjectFiles(project, worktree || undefined).then((groups) => { for (const group of (groups || [])) { for (const file of (group.files || [])) { if (file.path === path) { @@ -237,7 +253,7 @@ export default function FilePage() { const p = projects.find((pr) => pr.qualifiedName === project); if (p) setProjectPath(p.projectPath); }).catch(() => {}); - }, [project, path]); + }, [project, path, worktree]); // Initial data load useEffect(() => { @@ -415,6 +431,7 @@ export default function FilePage() { threads={threads} anchorLines={anchorLines} project={project} + worktree={worktree} filePath={path} onRefresh={fetchThreads} onThreadFocus={handleThreadFocus} diff --git a/apps/penpal/frontend/src/pages/ProjectPage.tsx b/apps/penpal/frontend/src/pages/ProjectPage.tsx index ed60721d..4c81a1d4 100644 --- a/apps/penpal/frontend/src/pages/ProjectPage.tsx +++ b/apps/penpal/frontend/src/pages/ProjectPage.tsx @@ -5,6 +5,7 @@ import { useSSE } from '../hooks/useSSE'; import type { LayoutContext } from '../components/Layout'; import type { APIFileGroupView, APIFile, APIFileInReview, AgentStatus, SSEEvent } from '../types'; import FileTypeBadge from '../components/FileTypeBadge'; +import { parseProjectWorktree } from '../utils/worktree'; function debounce void>(fn: T, ms: number): T { let timer: ReturnType; @@ -26,7 +27,8 @@ function WorkingIndicator() { export default function ProjectPage() { const location = useLocation(); const { setSidebarExtra } = useOutletContext(); - const qn = location.pathname.replace(/^\/project\//, ''); + const qnRaw = location.pathname.replace(/^\/project\//, ''); + const { project: qn, worktree } = parseProjectWorktree(qnRaw); const [groups, setGroups] = useState([]); const [reviewData, setReviewData] = useState>({}); const [agentStatus, setAgentStatus] = useState(null); @@ -44,17 +46,17 @@ export default function ProjectPage() { const refreshFiles = useCallback(() => { if (!qn) return; - api.getProjectFiles(qn).then(setGroups).catch(() => {}); - }, [qn]); + api.getProjectFiles(qn, worktree || undefined).then(setGroups).catch(() => {}); + }, [qn, worktree]); const refreshReviews = useCallback(() => { if (!qn) return; - api.getReviews(qn).then((reviews) => { + api.getReviews(qn, worktree || undefined).then((reviews) => { const map: Record = {}; reviews.forEach((r) => { map[r.filePath] = r; }); setReviewData(map); }).catch(() => {}); - }, [qn]); + }, [qn, worktree]); const refreshAgent = useCallback(() => { if (!qn) return; @@ -368,7 +370,7 @@ export default function ProjectPage() { )} {review && (review.workingThreads ?? 0) > 0 && } - e.stopPropagation()}> + e.stopPropagation()}> {file.title || file.name} {file.title ? file.name : '\u00A0'} @@ -453,7 +455,7 @@ export default function ProjectPage() { in review {(reviewData[path].workingThreads ?? 0) > 0 && } - {title || name} + {title || name} {title ? name : '\u00A0'} diff --git a/apps/penpal/frontend/src/pages/WorkspacePage.tsx b/apps/penpal/frontend/src/pages/WorkspacePage.tsx index 1e857c23..ccf502db 100644 --- a/apps/penpal/frontend/src/pages/WorkspacePage.tsx +++ b/apps/penpal/frontend/src/pages/WorkspacePage.tsx @@ -153,11 +153,27 @@ export default function WorkspacePage() { - {p.branch && ( + {p.worktrees && p.worktrees.length > 1 ? ( +
+ {p.worktrees.map((wt) => ( + + {wt.isMain ? p.name : wt.name} + {wt.branch && {wt.branch}{wt.isMain && p.dirty && *}} + + ))} +
+ ) : p.branch ? (
{p.branch}{p.dirty && *}
- )} + ) : null} ); } diff --git a/apps/penpal/frontend/src/types.ts b/apps/penpal/frontend/src/types.ts index 74d3d7d8..8c5ed922 100644 --- a/apps/penpal/frontend/src/types.ts +++ b/apps/penpal/frontend/src/types.ts @@ -8,6 +8,13 @@ export interface APIBadge { activeColor?: string; } +export interface APIWorktree { + name: string; + path: string; + branch: string; + isMain: boolean; +} + export interface APIProject { name: string; qualifiedName: string; @@ -24,6 +31,7 @@ export interface APIProject { agentRunning?: boolean; age?: string; reviewCount?: number; + worktrees?: APIWorktree[]; } export interface APIFile { @@ -165,6 +173,7 @@ export interface SSEEvent { type: 'projects' | 'files' | 'comments' | 'agents' | 'navigate'; project?: string; path?: string; + worktree?: string; } // Search types diff --git a/apps/penpal/frontend/src/utils/worktree.ts b/apps/penpal/frontend/src/utils/worktree.ts new file mode 100644 index 00000000..c2c00a2d --- /dev/null +++ b/apps/penpal/frontend/src/utils/worktree.ts @@ -0,0 +1,34 @@ +/** + * Worktree URL encoding utilities. + * + * URL pattern: /project/QN@worktree or /file/QN@worktree/path + * The @ separator splits the project qualified name from the worktree name. + * Main worktree has no @ suffix. + */ + +export function parseProjectWorktree(qnWithWorktree: string): { + project: string; + worktree: string; +} { + const atIdx = qnWithWorktree.indexOf('@'); + if (atIdx === -1) { + return { project: qnWithWorktree, worktree: '' }; + } + return { + project: qnWithWorktree.slice(0, atIdx), + worktree: qnWithWorktree.slice(atIdx + 1), + }; +} + +export function buildProjectWorktreeQN(project: string, worktree?: string): string { + if (!worktree) return project; + return `${project}@${worktree}`; +} + +export function projectURL(project: string, worktree?: string): string { + return `/project/${buildProjectWorktreeQN(project, worktree)}`; +} + +export function fileURL(project: string, worktree: string | undefined, path: string): string { + return `/file/${buildProjectWorktreeQN(project, worktree)}/${path}`; +} diff --git a/apps/penpal/internal/cache/cache.go b/apps/penpal/internal/cache/cache.go index e152b06a..d27152e6 100644 --- a/apps/penpal/internal/cache/cache.go +++ b/apps/penpal/internal/cache/cache.go @@ -26,6 +26,7 @@ type FileInfo struct { Title string // H1 heading extracted from markdown files ModTime time.Time FileType string // "research", "plan", or "other" + Worktree string // worktree name, empty for main } // Cache holds all cached data for the server @@ -102,6 +103,65 @@ func (c *Cache) FindProjectByPath(absPath string) *discovery.Project { return best } +// FindProjectByPathWithWorktree returns a project and the worktree name for a +// given absolute path. If the path is inside a worktree of a known project, +// it returns the parent project and the worktree name. If the path is inside +// the main project, worktree is empty. +func (c *Cache) FindProjectByPathWithWorktree(absPath string) (project *discovery.Project, worktree string) { + absPath = filepath.Clean(absPath) + + // First, try direct project match (handles main worktree and non-worktree projects) + project = c.FindProjectByPath(absPath) + if project != nil { + // Check if the path is inside a worktree of this project + for _, wt := range project.Worktrees { + if !wt.IsMain && (strings.HasPrefix(absPath, wt.Path+"/") || absPath == wt.Path) { + return project, wt.Name + } + } + return project, "" + } + + // Path didn't match any project directly. It might be inside a worktree + // that lives outside the project directory (e.g., at an arbitrary path). + c.mu.RLock() + defer c.mu.RUnlock() + for i := range c.projects { + for _, wt := range c.projects[i].Worktrees { + if !wt.IsMain && (strings.HasPrefix(absPath, wt.Path+"/") || absPath == wt.Path) { + p := c.projects[i] + return &p, wt.Name + } + } + } + + return nil, "" +} + +// WorktreePath returns the filesystem path for a worktree of the given project. +// If worktreeName is empty, returns the project's main path. +func (c *Cache) WorktreePath(projectName, worktreeName string) string { + if worktreeName == "" { + project := c.FindProject(projectName) + if project == nil { + return "" + } + return project.Path + } + + project := c.FindProject(projectName) + if project == nil { + return "" + } + + for _, wt := range project.Worktrees { + if wt.Name == worktreeName { + return wt.Path + } + } + return "" +} + // SetProjectFiles updates the file list for a project func (c *Cache) SetProjectFiles(projectName string, files []FileInfo) { c.mu.Lock() @@ -312,6 +372,51 @@ func extractTitle(path string) string { return "" } +// ScanProjectSourcesForWorktree scans a project's sources remapped to a worktree path. +// Each source's RootPath under the project is remapped to the equivalent path under +// the worktree. Sources whose directory doesn't exist in the worktree are skipped. +func ScanProjectSourcesForWorktree(project *discovery.Project, worktreePath string) []FileInfo { + // Build a temporary project with remapped sources + wtProject := *project + wtProject.Path = worktreePath + var remapped []discovery.FileSource + for _, s := range project.Sources { + ns := s + if s.RootPath != "" { + rel, err := filepath.Rel(project.Path, s.RootPath) + if err != nil { + continue + } + newRoot := filepath.Join(worktreePath, rel) + if _, err := os.Stat(newRoot); err != nil { + continue // source dir doesn't exist in worktree + } + ns.RootPath = newRoot + } + if len(s.Files) > 0 { + // Remap absolute file paths from main project to worktree + var remappedFiles []string + for _, f := range s.Files { + rel, err := filepath.Rel(project.Path, f) + if err != nil { + continue + } + newPath := filepath.Join(worktreePath, rel) + if _, err := os.Stat(newPath); err == nil { + remappedFiles = append(remappedFiles, newPath) + } + } + if len(remappedFiles) == 0 { + continue // no files exist in worktree + } + ns.Files = remappedFiles + } + remapped = append(remapped, ns) + } + wtProject.Sources = remapped + return scanProjectSources(&wtProject) +} + // scanProjectSources scans all sources of a project for markdown files. // Files are de-duplicated by project-relative path: if multiple sources cover // the same file, only the first source's entry is kept. This means auto-detected diff --git a/apps/penpal/internal/cache/worktree_test.go b/apps/penpal/internal/cache/worktree_test.go new file mode 100644 index 00000000..f48b7fd8 --- /dev/null +++ b/apps/penpal/internal/cache/worktree_test.go @@ -0,0 +1,203 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" + + "github.com/loganj/penpal/internal/discovery" +) + +func TestFindProjectByPathWithWorktree(t *testing.T) { + c := New() + + // Set up a project with worktrees + c.SetProjects([]discovery.Project{ + { + Name: "myrepo", + Path: "/home/user/Development/myrepo", + Origin: "workspace", + WorkspaceName: "Development", + Worktrees: []discovery.Worktree{ + {Name: "myrepo", Path: "/home/user/Development/myrepo", Branch: "main", IsMain: true}, + {Name: "fancy-name", Path: "/home/user/Development/myrepo/.claude/worktrees/fancy-name", Branch: "feature-branch"}, + {Name: "external-wt", Path: "/tmp/external-worktree", Branch: "other-branch"}, + }, + }, + }) + + tests := []struct { + name string + absPath string + wantProject string + wantWorktree string + }{ + { + name: "main project root", + absPath: "/home/user/Development/myrepo", + wantProject: "Development/myrepo", + wantWorktree: "", + }, + { + name: "file in main project", + absPath: "/home/user/Development/myrepo/thoughts/plan.md", + wantProject: "Development/myrepo", + wantWorktree: "", + }, + { + name: "file in nested worktree", + absPath: "/home/user/Development/myrepo/.claude/worktrees/fancy-name/thoughts/plan.md", + wantProject: "Development/myrepo", + wantWorktree: "fancy-name", + }, + { + name: "worktree root", + absPath: "/home/user/Development/myrepo/.claude/worktrees/fancy-name", + wantProject: "Development/myrepo", + wantWorktree: "fancy-name", + }, + { + name: "external worktree", + absPath: "/tmp/external-worktree/src/main.go", + wantProject: "Development/myrepo", + wantWorktree: "external-wt", + }, + { + name: "unrelated path", + absPath: "/home/user/other/project", + wantProject: "", + wantWorktree: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project, worktree := c.FindProjectByPathWithWorktree(tt.absPath) + if tt.wantProject == "" { + if project != nil { + t.Errorf("expected nil project, got %s", project.QualifiedName()) + } + return + } + if project == nil { + t.Fatalf("expected project %s, got nil", tt.wantProject) + } + if project.QualifiedName() != tt.wantProject { + t.Errorf("project = %s, want %s", project.QualifiedName(), tt.wantProject) + } + if worktree != tt.wantWorktree { + t.Errorf("worktree = %q, want %q", worktree, tt.wantWorktree) + } + }) + } +} + +func TestScanProjectSourcesForWorktree(t *testing.T) { + // Set up a main project with a "thoughts" tree source and a "manual" files source + mainDir := t.TempDir() + wtDir := t.TempDir() + + // Create thoughts dirs and files in both main and worktree + os.MkdirAll(filepath.Join(mainDir, "thoughts"), 0o755) + os.MkdirAll(filepath.Join(wtDir, "thoughts"), 0o755) + os.WriteFile(filepath.Join(mainDir, "thoughts", "main-only.md"), []byte("# Main Only\n"), 0o644) + os.WriteFile(filepath.Join(wtDir, "thoughts", "wt-only.md"), []byte("# WT Only\n"), 0o644) + os.WriteFile(filepath.Join(mainDir, "thoughts", "shared.md"), []byte("# Shared Main\n"), 0o644) + os.WriteFile(filepath.Join(wtDir, "thoughts", "shared.md"), []byte("# Shared WT\n"), 0o644) + + // Create a manually-added file that exists only in main + os.WriteFile(filepath.Join(mainDir, "manual.md"), []byte("# Manual\n"), 0o644) + // And one that exists in both + os.WriteFile(filepath.Join(mainDir, "both.md"), []byte("# Both Main\n"), 0o644) + os.WriteFile(filepath.Join(wtDir, "both.md"), []byte("# Both WT\n"), 0o644) + + project := &discovery.Project{ + Name: "test", + Path: mainDir, + Origin: "workspace", + WorkspaceName: "ws", + Sources: []discovery.FileSource{ + { + Name: "thoughts", + Type: "tree", + SourceTypeName: "manual", + RootPath: filepath.Join(mainDir, "thoughts"), + }, + { + Name: "manual files", + Type: "files", + SourceTypeName: "manual", + Files: []string{ + filepath.Join(mainDir, "manual.md"), + filepath.Join(mainDir, "both.md"), + }, + }, + }, + } + + files := ScanProjectSourcesForWorktree(project, wtDir) + + // Collect file names + names := map[string]bool{} + for _, f := range files { + names[f.Name] = true + } + + // Should include worktree's thoughts files + if !names["wt-only.md"] { + t.Error("expected wt-only.md from worktree thoughts dir") + } + if !names["shared.md"] { + t.Error("expected shared.md from worktree thoughts dir") + } + // Should NOT include main-only thoughts file + if names["main-only.md"] { + t.Error("main-only.md should not appear in worktree scan") + } + // Should include manually-added file that exists in worktree + if !names["both.md"] { + t.Error("expected both.md (exists in worktree)") + } + // Should NOT include manually-added file that only exists in main + if names["manual.md"] { + t.Error("manual.md should not appear in worktree scan (doesn't exist in worktree)") + } +} + +func TestWorktreePath(t *testing.T) { + c := New() + + c.SetProjects([]discovery.Project{ + { + Name: "myrepo", + Path: "/home/user/myrepo", + Origin: "workspace", + WorkspaceName: "Dev", + Worktrees: []discovery.Worktree{ + {Name: "myrepo", Path: "/home/user/myrepo", Branch: "main", IsMain: true}, + {Name: "fancy", Path: "/home/user/myrepo/.claude/worktrees/fancy", Branch: "feat"}, + }, + }, + }) + + tests := []struct { + name string + project string + worktree string + want string + }{ + {"empty worktree returns project path", "Dev/myrepo", "", "/home/user/myrepo"}, + {"known worktree", "Dev/myrepo", "fancy", "/home/user/myrepo/.claude/worktrees/fancy"}, + {"unknown worktree", "Dev/myrepo", "nonexistent", ""}, + {"unknown project", "Dev/other", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := c.WorktreePath(tt.project, tt.worktree) + if got != tt.want { + t.Errorf("WorktreePath(%q, %q) = %q, want %q", tt.project, tt.worktree, got, tt.want) + } + }) + } +} diff --git a/apps/penpal/internal/comments/operations.go b/apps/penpal/internal/comments/operations.go index 6e6abc0a..fa5a4390 100644 --- a/apps/penpal/internal/comments/operations.go +++ b/apps/penpal/internal/comments/operations.go @@ -15,10 +15,15 @@ import ( // the specified text selection. The first comment is added to the thread. // IDs and timestamps are generated automatically. func (s *Store) CreateThread(projectName, filePath string, anchor Anchor, comment Comment) (*Thread, error) { + return s.CreateThreadForWorktree(projectName, filePath, "", anchor, comment) +} + +// CreateThreadForWorktree creates a thread scoped to a specific worktree. +func (s *Store) CreateThreadForWorktree(projectName, filePath, worktree string, anchor Anchor, comment Comment) (*Thread, error) { s.mu.Lock() defer s.mu.Unlock() - fc, err := s.Load(projectName, filePath) + fc, err := s.LoadForWorktree(projectName, filePath, worktree) if err != nil { return nil, err } @@ -37,7 +42,7 @@ func (s *Store) CreateThread(projectName, filePath string, anchor Anchor, commen fc.Threads = append(fc.Threads, thread) - if err := s.Save(projectName, filePath, fc); err != nil { + if err := s.SaveForWorktree(projectName, filePath, worktree, fc); err != nil { return nil, err } @@ -50,10 +55,15 @@ func (s *Store) CreateThread(projectName, filePath string, anchor Anchor, commen // AddComment appends a comment to an existing thread. The comment ID and // timestamp are generated automatically. func (s *Store) AddComment(projectName, filePath, threadID string, comment Comment) (*Thread, error) { + return s.AddCommentForWorktree(projectName, filePath, "", threadID, comment) +} + +// AddCommentForWorktree appends a comment scoped to a specific worktree. +func (s *Store) AddCommentForWorktree(projectName, filePath, worktree, threadID string, comment Comment) (*Thread, error) { s.mu.Lock() defer s.mu.Unlock() - fc, err := s.Load(projectName, filePath) + fc, err := s.LoadForWorktree(projectName, filePath, worktree) if err != nil { return nil, err } @@ -67,7 +77,7 @@ func (s *Store) AddComment(projectName, filePath, threadID string, comment Comme } fc.Threads[i].Comments = append(fc.Threads[i].Comments, comment) - if err := s.Save(projectName, filePath, fc); err != nil { + if err := s.SaveForWorktree(projectName, filePath, worktree, fc); err != nil { return nil, err } if s.activity != nil { @@ -83,10 +93,15 @@ func (s *Store) AddComment(projectName, filePath, threadID string, comment Comme // ResolveThread marks a thread as resolved. func (s *Store) ResolveThread(projectName, filePath, threadID, resolvedBy string) error { + return s.ResolveThreadForWorktree(projectName, filePath, "", threadID, resolvedBy) +} + +// ResolveThreadForWorktree marks a thread as resolved, scoped to a worktree. +func (s *Store) ResolveThreadForWorktree(projectName, filePath, worktree, threadID, resolvedBy string) error { s.mu.Lock() defer s.mu.Unlock() - fc, err := s.Load(projectName, filePath) + fc, err := s.LoadForWorktree(projectName, filePath, worktree) if err != nil { return err } @@ -96,7 +111,7 @@ func (s *Store) ResolveThread(projectName, filePath, threadID, resolvedBy string fc.Threads[i].Status = "resolved" fc.Threads[i].ResolvedAt = time.Now() fc.Threads[i].ResolvedBy = resolvedBy - if err := s.Save(projectName, filePath, fc); err != nil { + if err := s.SaveForWorktree(projectName, filePath, worktree, fc); err != nil { return err } if s.activity != nil { @@ -111,10 +126,15 @@ func (s *Store) ResolveThread(projectName, filePath, threadID, resolvedBy string // ReopenThread sets a resolved thread back to open. func (s *Store) ReopenThread(projectName, filePath, threadID string) error { + return s.ReopenThreadForWorktree(projectName, filePath, "", threadID) +} + +// ReopenThreadForWorktree sets a resolved thread back to open, scoped to a worktree. +func (s *Store) ReopenThreadForWorktree(projectName, filePath, worktree, threadID string) error { s.mu.Lock() defer s.mu.Unlock() - fc, err := s.Load(projectName, filePath) + fc, err := s.LoadForWorktree(projectName, filePath, worktree) if err != nil { return err } @@ -124,7 +144,7 @@ func (s *Store) ReopenThread(projectName, filePath, threadID string) error { fc.Threads[i].Status = "open" fc.Threads[i].ResolvedAt = time.Time{} fc.Threads[i].ResolvedBy = "" - return s.Save(projectName, filePath, fc) + return s.SaveForWorktree(projectName, filePath, worktree, fc) } } @@ -142,12 +162,26 @@ func (s *Store) ListOpenThreads(projectName string) ([]ThreadWithFile, error) { // project and returns threads matching the given status filter. // An empty status returns all threads regardless of status. func (s *Store) ListThreadsByStatus(projectName, status string) ([]ThreadWithFile, error) { + return s.ListThreadsByStatusForWorktree(projectName, status, "") +} + +// ListThreadsByStatusForWorktree walks the comments directory scoped to a worktree. +func (s *Store) ListThreadsByStatusForWorktree(projectName, status, worktree string) ([]ThreadWithFile, error) { project := s.cache.FindProject(projectName) if project == nil { return nil, fmt.Errorf("project not found: %s", projectName) } - commentsDir := filepath.Join(project.Path, ".penpal", "comments") + basePath := project.Path + if worktree != "" { + wtPath := s.cache.WorktreePath(projectName, worktree) + if wtPath == "" { + return nil, fmt.Errorf("worktree not found: %s", worktree) + } + basePath = wtPath + } + + commentsDir := filepath.Join(basePath, ".penpal", "comments") var results []ThreadWithFile err := filepath.Walk(commentsDir, func(path string, info os.FileInfo, err error) error { @@ -215,12 +249,28 @@ func (s *Store) HasPendingHumanComments(projectName string) bool { // project and returns all files that have at least one open comment thread. // Returned file paths are relative to the project root (e.g., "thoughts/shared/plans/foo.md"). func (s *Store) ListFilesInReview(projectName string) ([]FileInReview, error) { + return s.ListFilesInReviewForWorktree(projectName, "") +} + +// ListFilesInReviewForWorktree lists files in review scoped to a worktree. +func (s *Store) ListFilesInReviewForWorktree(projectName, worktree string) ([]FileInReview, error) { project := s.cache.FindProject(projectName) if project == nil { return nil, fmt.Errorf("project not found: %s", projectName) } - commentsDir := filepath.Join(project.Path, ".penpal", "comments") + basePath := project.Path + if worktree != "" { + wtPath := s.cache.WorktreePath(projectName, worktree) + if wtPath == "" { + return nil, fmt.Errorf("worktree not found: %s", worktree) + } + basePath = wtPath + } + + commentsDir := filepath.Join(basePath, ".penpal", "comments") + // For source file existence checks, use the worktree path if available + sourceBasePath := basePath var results []FileInReview err := filepath.Walk(commentsDir, func(path string, info os.FileInfo, err error) error { @@ -251,7 +301,7 @@ func (s *Store) ListFilesInReview(projectName string) ([]FileInReview, error) { filePath := strings.TrimSuffix(rel, ".json") // Skip sidecars whose source file no longer exists on disk. - sourceFile := filepath.Join(project.Path, filePath) + sourceFile := filepath.Join(sourceBasePath, filePath) if _, err := os.Stat(sourceFile); os.IsNotExist(err) { return nil } diff --git a/apps/penpal/internal/comments/storage.go b/apps/penpal/internal/comments/storage.go index 73c10630..2e4a3071 100644 --- a/apps/penpal/internal/comments/storage.go +++ b/apps/penpal/internal/comments/storage.go @@ -15,11 +15,28 @@ import ( // // filePath is relative to the project root (e.g., "thoughts/shared/plans/foo.md"). func (s *Store) commentsPath(projectName, filePath string) (string, error) { + return s.commentsPathForWorktree(projectName, filePath, "") +} + +// commentsPathForWorktree returns the absolute path to the sidecar JSON file, +// scoped to a specific worktree. When worktree is empty, uses the main project path. +// When worktree is specified, uses the worktree's filesystem path. +func (s *Store) commentsPathForWorktree(projectName, filePath, worktree string) (string, error) { project := s.cache.FindProject(projectName) if project == nil { return "", fmt.Errorf("project not found: %s", projectName) } - commentsDir := filepath.Join(project.Path, ".penpal", "comments") + + basePath := project.Path + if worktree != "" { + wtPath := s.cache.WorktreePath(projectName, worktree) + if wtPath == "" { + return "", fmt.Errorf("worktree not found: %s", worktree) + } + basePath = wtPath + } + + commentsDir := filepath.Join(basePath, ".penpal", "comments") full := filepath.Join(commentsDir, filePath+".json") // Prevent path traversal: resolved path must stay within the comments dir. @@ -39,7 +56,12 @@ func (s *Store) commentsPath(projectName, filePath string) (string, error) { // Load reads and parses the sidecar JSON for the given project and file. // If the file does not exist, it returns an empty FileComments (not an error). func (s *Store) Load(projectName, filePath string) (*FileComments, error) { - p, err := s.commentsPath(projectName, filePath) + return s.LoadForWorktree(projectName, filePath, "") +} + +// LoadForWorktree reads and parses the sidecar JSON scoped to a worktree. +func (s *Store) LoadForWorktree(projectName, filePath, worktree string) (*FileComments, error) { + p, err := s.commentsPathForWorktree(projectName, filePath, worktree) if err != nil { return nil, err } @@ -76,8 +98,13 @@ func migrateInReplyTo(fc *FileComments) { // It writes to a temporary file first, then renames it into place. // Directories are created as needed. func (s *Store) Save(projectName, filePath string, fc *FileComments) error { + return s.SaveForWorktree(projectName, filePath, "", fc) +} + +// SaveForWorktree writes comments scoped to a worktree. +func (s *Store) SaveForWorktree(projectName, filePath, worktree string, fc *FileComments) error { migrateInReplyTo(fc) - p, err := s.commentsPath(projectName, filePath) + p, err := s.commentsPathForWorktree(projectName, filePath, worktree) if err != nil { return err } diff --git a/apps/penpal/internal/discovery/discovery.go b/apps/penpal/internal/discovery/discovery.go index 290918f1..b25c7513 100644 --- a/apps/penpal/internal/discovery/discovery.go +++ b/apps/penpal/internal/discovery/discovery.go @@ -226,6 +226,7 @@ type Project struct { Git *GitInfo FileCount int LastModified time.Time + Worktrees []Worktree // discovered worktrees for this project } // ThoughtsPath returns the thoughts source root if present, empty string otherwise. @@ -362,6 +363,13 @@ func DiscoverWorkspace(workspacePath, workspaceName string) ([]Project, error) { }) } + // Discover worktrees for each project + for i := range projects { + if projects[i].Name != "(root)" { + projects[i].Worktrees = DiscoverWorktrees(projects[i].Path) + } + } + sort.Slice(projects, func(i, j int) bool { if projects[i].Name == "(root)" { return true @@ -401,6 +409,9 @@ func LoadStandaloneProject(projectPath string, cfg config.ProjectConfig) (Projec // Add user-configured sources project.Sources = append(project.Sources, SourceConfigsToFileSources(absPath, cfg.Sources)...) + // Discover worktrees + project.Worktrees = DiscoverWorktrees(absPath) + return project, nil } diff --git a/apps/penpal/internal/discovery/git.go b/apps/penpal/internal/discovery/git.go index 08fc541a..8b6f8701 100644 --- a/apps/penpal/internal/discovery/git.go +++ b/apps/penpal/internal/discovery/git.go @@ -17,8 +17,22 @@ type GitInfo struct { func GetGitInfo(projectPath string) *GitInfo { // Read branch directly from .git/HEAD — avoids 2 subprocess calls + // For worktrees, .git is a file containing "gitdir: ...", so we + // fall back to git rev-parse if the direct file read fails. headContent, err := os.ReadFile(filepath.Join(projectPath, ".git", "HEAD")) if err != nil { + // Check if .git is a file (worktree) rather than a directory + gitFile := filepath.Join(projectPath, ".git") + if fi, statErr := os.Stat(gitFile); statErr == nil && !fi.IsDir() { + // It's a worktree — use git rev-parse to get branch + cmd := exec.Command("git", "-C", projectPath, "rev-parse", "--abbrev-ref", "HEAD") + out, cmdErr := cmd.Output() + if cmdErr != nil { + return nil + } + info := &GitInfo{Branch: strings.TrimSpace(string(out))} + return enrichGitInfo(info, projectPath) + } return nil // not a git repo (or bare repo) } @@ -30,6 +44,11 @@ func GetGitInfo(projectPath string) *GitInfo { info.Branch = head[:7] // detached HEAD, show short hash } + return enrichGitInfo(info, projectPath) +} + +// enrichGitInfo adds dirty status and unpushed commit info to a GitInfo. +func enrichGitInfo(info *GitInfo, projectPath string) *GitInfo { // Still need git subprocess for dirty check (no good file-based alternative) cmd := exec.Command("git", "-C", projectPath, "status", "--porcelain") if out, err := cmd.Output(); err == nil { diff --git a/apps/penpal/internal/discovery/worktree.go b/apps/penpal/internal/discovery/worktree.go new file mode 100644 index 00000000..5d668277 --- /dev/null +++ b/apps/penpal/internal/discovery/worktree.go @@ -0,0 +1,142 @@ +package discovery + +import ( + "os/exec" + "path/filepath" + "strings" +) + +// Worktree represents a git worktree associated with a project. +type Worktree struct { + Name string `json:"name"` // directory name (e.g., "fancy-name") + Path string `json:"path"` // absolute filesystem path + Branch string `json:"branch"` // checked-out branch + IsMain bool `json:"isMain"` // true for the original clone +} + +// DiscoverWorktrees returns all git worktrees for the project at projectPath. +// The main worktree is always first. Returns nil if the project is not a git repo +// or has no additional worktrees. +func DiscoverWorktrees(projectPath string) []Worktree { + cmd := exec.Command("git", "-C", projectPath, "worktree", "list", "--porcelain") + out, err := cmd.Output() + if err != nil { + return nil + } + return parseWorktreeList(projectPath, string(out)) +} + +// parseWorktreeList parses `git worktree list --porcelain` output into Worktree structs. +func parseWorktreeList(projectPath string, output string) []Worktree { + if output == "" { + return nil + } + + var worktrees []Worktree + var current *Worktree + mainPath := filepath.Clean(projectPath) + + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + if current != nil { + worktrees = append(worktrees, *current) + current = nil + } + continue + } + + if strings.HasPrefix(line, "worktree ") { + wtPath := strings.TrimPrefix(line, "worktree ") + cleanPath := filepath.Clean(wtPath) + current = &Worktree{ + Path: cleanPath, + Name: filepath.Base(cleanPath), + IsMain: cleanPath == mainPath, + } + } else if strings.HasPrefix(line, "branch ") { + if current != nil { + branch := strings.TrimPrefix(line, "branch ") + // Strip refs/heads/ prefix + branch = strings.TrimPrefix(branch, "refs/heads/") + current.Branch = branch + } + } else if line == "bare" { + // Skip bare repos + current = nil + } + } + // Handle last entry without trailing newline + if current != nil { + worktrees = append(worktrees, *current) + } + + // Only return if there are additional worktrees beyond main + if len(worktrees) <= 1 { + return nil + } + + 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 { + return "" + } + commonDir := strings.TrimSpace(string(out)) + if commonDir == "" || commonDir == "." { + return "" + } + + // 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 "" +} diff --git a/apps/penpal/internal/discovery/worktree_test.go b/apps/penpal/internal/discovery/worktree_test.go new file mode 100644 index 00000000..71345ba4 --- /dev/null +++ b/apps/penpal/internal/discovery/worktree_test.go @@ -0,0 +1,100 @@ +package discovery + +import ( + "testing" +) + +func TestParseWorktreeList(t *testing.T) { + tests := []struct { + name string + projectPath string + output string + wantLen int + wantNames []string + }{ + { + name: "empty output", + projectPath: "/repo", + output: "", + wantLen: 0, + }, + { + name: "single worktree (main only)", + projectPath: "/repo", + output: "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\n", + wantLen: 0, // returns nil when only main exists + }, + { + name: "main plus one worktree", + projectPath: "/repo", + output: "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo/.claude/worktrees/fancy-name\nHEAD def456\nbranch refs/heads/feature-branch\n\n", + wantLen: 2, + wantNames: []string{"repo", "fancy-name"}, + }, + { + name: "main plus multiple worktrees", + projectPath: "/home/user/project", + output: "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\nworktree /home/user/project/.claude/worktrees/wt-a\nHEAD def456\nbranch refs/heads/branch-a\n\nworktree /home/user/project/.claude/worktrees/wt-b\nHEAD 789012\nbranch refs/heads/branch-b\n\n", + wantLen: 3, + wantNames: []string{"project", "wt-a", "wt-b"}, + }, + { + name: "strips refs/heads/ prefix", + projectPath: "/repo", + output: "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /tmp/wt\nHEAD def\nbranch refs/heads/my-feature\n\n", + wantLen: 2, + }, + { + name: "bare repo entry is skipped", + projectPath: "/repo", + output: "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /bare\nbare\n\nworktree /tmp/wt\nHEAD def\nbranch refs/heads/feature\n\n", + wantLen: 2, + wantNames: []string{"repo", "wt"}, + }, + { + name: "no trailing newline", + projectPath: "/repo", + output: "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /tmp/wt\nHEAD def\nbranch refs/heads/feat", + wantLen: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseWorktreeList(tt.projectPath, tt.output) + if len(got) != tt.wantLen { + t.Fatalf("parseWorktreeList: got %d worktrees, want %d: %+v", len(got), tt.wantLen, got) + } + if tt.wantNames != nil { + for i, name := range tt.wantNames { + if got[i].Name != name { + t.Errorf("worktree[%d].Name = %q, want %q", i, got[i].Name, name) + } + } + } + // Verify IsMain is set correctly + for _, wt := range got { + if wt.Path == tt.projectPath && !wt.IsMain { + t.Errorf("worktree at project path should be IsMain=true") + } + if wt.Path != tt.projectPath && wt.IsMain { + t.Errorf("worktree at %s should be IsMain=false", wt.Path) + } + } + }) + } +} + +func TestParseWorktreeList_BranchStripping(t *testing.T) { + output := "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /tmp/wt\nHEAD def\nbranch refs/heads/feature/nested\n\n" + got := parseWorktreeList("/repo", output) + if len(got) != 2 { + t.Fatalf("expected 2 worktrees, got %d", len(got)) + } + if got[0].Branch != "main" { + t.Errorf("main branch = %q, want %q", got[0].Branch, "main") + } + if got[1].Branch != "feature/nested" { + t.Errorf("wt branch = %q, want %q", got[1].Branch, "feature/nested") + } +} diff --git a/apps/penpal/internal/mcpserver/tools.go b/apps/penpal/internal/mcpserver/tools.go index 43618bb7..882a240b 100644 --- a/apps/penpal/internal/mcpserver/tools.go +++ b/apps/penpal/internal/mcpserver/tools.go @@ -17,15 +17,17 @@ import ( // --- Input types for each tool --- type listThreadsInput struct { - Project string `json:"project" jsonschema:"Project name"` - Path string `json:"path,omitempty" jsonschema:"File path relative to project root, e.g. thoughts/plans/foo.md"` - Status string `json:"status,omitempty" jsonschema:"Filter by status: open or resolved"` + Project string `json:"project" jsonschema:"Project name"` + Path string `json:"path,omitempty" jsonschema:"File path relative to project root, e.g. thoughts/plans/foo.md"` + Status string `json:"status,omitempty" jsonschema:"Filter by status: open or resolved"` + Worktree string `json:"worktree,omitempty" jsonschema:"Worktree name to scope comments to. Omit for main worktree."` } type readThreadInput struct { Project string `json:"project" jsonschema:"Project name"` Path string `json:"path" jsonschema:"File path relative to project root, e.g. thoughts/plans/foo.md"` ThreadID string `json:"threadId" jsonschema:"Thread ID"` + Worktree string `json:"worktree,omitempty" jsonschema:"Worktree name to scope comments to. Omit for main worktree."` } type replyInput struct { @@ -34,6 +36,7 @@ type replyInput struct { ThreadID string `json:"threadId" jsonschema:"Thread ID to reply to"` Body string `json:"body" jsonschema:"Reply message body"` SuggestedReplies []string `json:"suggestedReplies,omitempty" jsonschema:"Up to 3 short reply suggestions shown as clickable pills to the human"` + Worktree string `json:"worktree,omitempty" jsonschema:"Worktree name to scope comments to. Omit for main worktree."` } type createThreadInput struct { @@ -43,15 +46,18 @@ type createThreadInput struct { Body string `json:"body" jsonschema:"Comment body"` HeadingPath string `json:"headingPath,omitempty" jsonschema:"Heading path for context"` SuggestedReplies []string `json:"suggestedReplies,omitempty" jsonschema:"Up to 3 short reply suggestions shown as clickable pills to the human"` + Worktree string `json:"worktree,omitempty" jsonschema:"Worktree name to scope comments to. Omit for main worktree."` } type filesInReviewInput struct { - Project string `json:"project" jsonschema:"Project name"` + Project string `json:"project" jsonschema:"Project name"` + Worktree string `json:"worktree,omitempty" jsonschema:"Worktree name to scope comments to. Omit for main worktree."` } type waitForChangesInput struct { Project string `json:"project" jsonschema:"Project name"` SinceSeq uint64 `json:"sinceSeq,omitempty" jsonschema:"Sequence number from previous wait call. Changes since this seq return immediately."` + Worktree string `json:"worktree,omitempty" jsonschema:"Worktree name to scope comments to. Omit for main worktree."` } type findProjectInput struct { @@ -87,7 +93,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { if status == "" { status = "open" } - threads, err := store.ListThreadsByStatus(input.Project, status) + threads, err := store.ListThreadsByStatusForWorktree(input.Project, status, input.Worktree) if err != nil { return nil, nil, err } @@ -108,7 +114,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { // Load threads for a specific file store.RecordHeartbeat(input.Project, input.Path) - fc, err := store.Load(input.Project, input.Path) + fc, err := store.LoadForWorktree(input.Project, input.Path, input.Worktree) if err != nil { return nil, nil, err } @@ -137,7 +143,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { store.RecordHeartbeat(input.Project, input.Path) - fc, err := store.Load(input.Project, input.Path) + fc, err := store.LoadForWorktree(input.Project, input.Path, input.Worktree) if err != nil { return nil, nil, err } @@ -172,7 +178,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { Body: input.Body, SuggestedReplies: input.SuggestedReplies, } - thread, err := store.AddComment(input.Project, input.Path, input.ThreadID, comment) + thread, err := store.AddCommentForWorktree(input.Project, input.Path, input.Worktree, input.ThreadID, comment) if err != nil { return nil, nil, err } @@ -195,7 +201,17 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { return nil, nil, fmt.Errorf("project not found: %s", input.Project) } - fullPath := filepath.Join(project.Path, input.Path) + // Use worktree path if specified, otherwise project path + basePath := project.Path + if input.Worktree != "" { + wtPath := c.WorktreePath(input.Project, input.Worktree) + if wtPath == "" { + return nil, nil, fmt.Errorf("worktree not found: %s", input.Worktree) + } + basePath = wtPath + } + + fullPath := filepath.Join(basePath, input.Path) content, err := os.ReadFile(fullPath) if err != nil { return nil, nil, fmt.Errorf("reading file: %w", err) @@ -242,7 +258,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { SuggestedReplies: input.SuggestedReplies, } - thread, err := store.CreateThread(input.Project, input.Path, anchor, comment) + thread, err := store.CreateThreadForWorktree(input.Project, input.Path, input.Worktree, anchor, comment) if err != nil { return nil, nil, err } @@ -259,7 +275,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { return nil, nil, fmt.Errorf("project is required") } - files, err := store.ListFilesInReview(input.Project) + files, err := store.ListFilesInReviewForWorktree(input.Project, input.Worktree) if err != nil { return nil, nil, err } @@ -280,7 +296,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { OpenThreads: f.OpenThreads, } - fc, loadErr := store.Load(input.Project, f.FilePath) + fc, loadErr := store.LoadForWorktree(input.Project, f.FilePath, input.Worktree) if loadErr == nil { var oldestPending *comments.Thread var oldestTime time.Time @@ -332,7 +348,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { // Record heartbeat after waking store.RecordHeartbeat(input.Project, "") - files, err := store.ListFilesInReview(input.Project) + files, err := store.ListFilesInReviewForWorktree(input.Project, input.Worktree) if err != nil { return nil, nil, err } @@ -358,7 +374,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { } var pending []pendingThread for _, f := range files { - fc, loadErr := store.Load(input.Project, f.FilePath) + fc, loadErr := store.LoadForWorktree(input.Project, f.FilePath, input.Worktree) if loadErr == nil { for _, t := range fc.Threads { if t.Status == "open" && len(t.Comments) > 0 && t.Comments[len(t.Comments)-1].Role == "human" { @@ -396,7 +412,7 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { // Refresh working timestamps for threads still awaiting a response // so they survive across 30s wait cycles. for _, f := range files { - fc, loadErr := store.Load(input.Project, f.FilePath) + fc, loadErr := store.LoadForWorktree(input.Project, f.FilePath, input.Worktree) if loadErr == nil { for _, t := range fc.Threads { if t.Status == "open" && len(t.Comments) > 0 && t.Comments[len(t.Comments)-1].Role == "human" { @@ -418,13 +434,13 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { // penpal_find_project mcp.AddTool(server, &mcp.Tool{ Name: "penpal_find_project", - Description: "Find the penpal project for a given directory. Returns the project name to use with other penpal tools. Call this first if you don't already know your project name.", + Description: "Find the penpal project for a given directory. Returns the project name and optional worktree to use with other penpal tools. Call this first if you don't already know your project name.", }, func(ctx context.Context, req *mcp.CallToolRequest, input findProjectInput) (*mcp.CallToolResult, any, error) { if input.Directory == "" { return nil, nil, fmt.Errorf("directory is required") } - project := c.FindProjectByPath(input.Directory) + project, worktree := c.FindProjectByPathWithWorktree(input.Directory) if project == nil { return nil, nil, fmt.Errorf("no project found for directory: %s", input.Directory) } @@ -433,6 +449,9 @@ func registerTools(server *mcp.Server, store *comments.Store, c *cache.Cache) { "project": project.QualifiedName(), "path": project.Path, } + if worktree != "" { + result["worktree"] = worktree + } res, err := textResult(result) return res, nil, err }) diff --git a/apps/penpal/internal/mcpserver/worktree_test.go b/apps/penpal/internal/mcpserver/worktree_test.go new file mode 100644 index 00000000..35a26d9f --- /dev/null +++ b/apps/penpal/internal/mcpserver/worktree_test.go @@ -0,0 +1,245 @@ +package mcpserver + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/loganj/penpal/internal/comments" + "github.com/loganj/penpal/internal/discovery" +) + +// setupWithWorktree creates a test env with a project that has a worktree. +func setupWithWorktree(t *testing.T) (*testEnv, string, func()) { + t.Helper() + + env, cleanup := setup(t) + + // Create a worktree directory inside the project + wtDir := filepath.Join(env.projDir, ".claude", "worktrees", "test-wt") + if err := os.MkdirAll(wtDir, 0755); err != nil { + cleanup() + t.Fatalf("creating worktree dir: %v", err) + } + + // Create thoughts dir in worktree + if err := os.MkdirAll(filepath.Join(wtDir, "thoughts"), 0755); err != nil { + cleanup() + t.Fatalf("creating worktree thoughts dir: %v", err) + } + + // Update the project to include worktrees + env.cache.SetProjects([]discovery.Project{{ + Name: env.projName, + Path: env.projDir, + Origin: "standalone", + Worktrees: []discovery.Worktree{ + {Name: filepath.Base(env.projDir), Path: env.projDir, Branch: "main", IsMain: true}, + {Name: "test-wt", Path: wtDir, Branch: "feature-branch"}, + }, + }}) + + return env, wtDir, cleanup +} + +func TestFindProject_WithWorktree(t *testing.T) { + env, wtDir, cleanup := setupWithWorktree(t) + defer cleanup() + + text := callTool(t, env, "penpal_find_project", map[string]any{ + "directory": wtDir, + }) + + var result map[string]string + if err := json.Unmarshal([]byte(text), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result["project"] != env.projName { + t.Errorf("project = %q, want %q", result["project"], env.projName) + } + if result["worktree"] != "test-wt" { + t.Errorf("worktree = %q, want %q", result["worktree"], "test-wt") + } +} + +func TestFindProject_MainWorktree(t *testing.T) { + env, _, cleanup := setupWithWorktree(t) + defer cleanup() + + text := callTool(t, env, "penpal_find_project", map[string]any{ + "directory": env.projDir, + }) + + var result map[string]string + if err := json.Unmarshal([]byte(text), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if result["project"] != env.projName { + t.Errorf("project = %q, want %q", result["project"], env.projName) + } + if _, ok := result["worktree"]; ok { + t.Errorf("worktree should not be present for main project, got %q", result["worktree"]) + } +} + +func TestCreateThread_InWorktree(t *testing.T) { + env, wtDir, cleanup := setupWithWorktree(t) + defer cleanup() + + // Write a file in the worktree + mdPath := filepath.Join(wtDir, "thoughts", "wt-test.md") + os.WriteFile(mdPath, []byte("# Worktree Test\n\nThis is worktree content."), 0644) + + text := callTool(t, env, "penpal_create_thread", map[string]any{ + "project": env.projName, + "path": "thoughts/wt-test.md", + "selectedText": "worktree content", + "body": "Comment in worktree.", + "worktree": "test-wt", + }) + + var thread comments.Thread + if err := json.Unmarshal([]byte(text), &thread); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if thread.ID == "" { + t.Error("thread ID is empty") + } + + // Verify the sidecar was created in the worktree directory, not the main project + wtSidecar := filepath.Join(wtDir, ".penpal", "comments", "thoughts", "wt-test.md.json") + if _, err := os.Stat(wtSidecar); os.IsNotExist(err) { + t.Error("sidecar file should exist in worktree directory") + } + + mainSidecar := filepath.Join(env.projDir, ".penpal", "comments", "thoughts", "wt-test.md.json") + if _, err := os.Stat(mainSidecar); !os.IsNotExist(err) { + t.Error("sidecar file should NOT exist in main project directory") + } +} + +func TestListThreads_WorktreeScoped(t *testing.T) { + env, wtDir, cleanup := setupWithWorktree(t) + defer cleanup() + + // Create a thread in the worktree + srcPath := filepath.Join(wtDir, "thoughts", "scoped.md") + os.MkdirAll(filepath.Dir(srcPath), 0755) + os.WriteFile(srcPath, []byte("test content"), 0644) + + anchor := comments.Anchor{SelectedText: "test"} + comment := comments.Comment{Author: "human", Role: "human", Body: "worktree comment"} + env.store.CreateThreadForWorktree(env.projName, "thoughts/scoped.md", "test-wt", anchor, comment) + + // Also create a thread in the main project on the same file path + mainSrcPath := filepath.Join(env.projDir, "thoughts", "scoped.md") + os.MkdirAll(filepath.Dir(mainSrcPath), 0755) + os.WriteFile(mainSrcPath, []byte("main content"), 0644) + env.store.CreateThread(env.projName, "thoughts/scoped.md", anchor, comments.Comment{Author: "human", Role: "human", Body: "main comment"}) + + // List threads for worktree — should only see worktree thread + text := callTool(t, env, "penpal_list_threads", map[string]any{ + "project": env.projName, + "path": "thoughts/scoped.md", + "worktree": "test-wt", + }) + + var threads []comments.Thread + if err := json.Unmarshal([]byte(text), &threads); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(threads) != 1 { + t.Fatalf("expected 1 worktree thread, got %d", len(threads)) + } + if threads[0].Comments[0].Body != "worktree comment" { + t.Errorf("expected worktree comment, got %q", threads[0].Comments[0].Body) + } + + // List threads without worktree — should see main thread + text2 := callTool(t, env, "penpal_list_threads", map[string]any{ + "project": env.projName, + "path": "thoughts/scoped.md", + }) + + var mainThreads []comments.Thread + if err := json.Unmarshal([]byte(text2), &mainThreads); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(mainThreads) != 1 { + t.Fatalf("expected 1 main thread, got %d", len(mainThreads)) + } + if mainThreads[0].Comments[0].Body != "main comment" { + t.Errorf("expected main comment, got %q", mainThreads[0].Comments[0].Body) + } +} + +func TestReply_InWorktree(t *testing.T) { + env, wtDir, cleanup := setupWithWorktree(t) + defer cleanup() + + // Create a thread in the worktree + srcPath := filepath.Join(wtDir, "thoughts", "reply-wt.md") + os.MkdirAll(filepath.Dir(srcPath), 0755) + os.WriteFile(srcPath, []byte("test"), 0644) + + anchor := comments.Anchor{SelectedText: "test"} + comment := comments.Comment{Author: "human", Role: "human", Body: "Original"} + thread, _ := env.store.CreateThreadForWorktree(env.projName, "thoughts/reply-wt.md", "test-wt", anchor, comment) + + text := callTool(t, env, "penpal_reply", map[string]any{ + "project": env.projName, + "path": "thoughts/reply-wt.md", + "threadId": thread.ID, + "body": "Reply in worktree", + "worktree": "test-wt", + }) + + var result comments.Thread + if err := json.Unmarshal([]byte(text), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Comments) != 2 { + t.Fatalf("expected 2 comments, got %d", len(result.Comments)) + } + if result.Comments[1].Body != "Reply in worktree" { + t.Errorf("reply body = %q, want %q", result.Comments[1].Body, "Reply in worktree") + } +} + +func TestFilesInReview_WorktreeScoped(t *testing.T) { + env, wtDir, cleanup := setupWithWorktree(t) + defer cleanup() + + // Create files and threads in worktree + srcPath := filepath.Join(wtDir, "thoughts", "review-wt.md") + os.MkdirAll(filepath.Dir(srcPath), 0755) + os.WriteFile(srcPath, []byte("content"), 0644) + + anchor := comments.Anchor{SelectedText: "content"} + comment := comments.Comment{Author: "human", Role: "human", Body: "Review this"} + env.store.CreateThreadForWorktree(env.projName, "thoughts/review-wt.md", "test-wt", anchor, comment) + + // Also create a thread in main + mainSrcPath := filepath.Join(env.projDir, "thoughts", "review-main.md") + os.MkdirAll(filepath.Dir(mainSrcPath), 0755) + os.WriteFile(mainSrcPath, []byte("main content"), 0644) + env.store.CreateThread(env.projName, "thoughts/review-main.md", anchor, comments.Comment{Author: "human", Role: "human", Body: "Main review"}) + + // files_in_review for worktree should only show worktree files + text := callTool(t, env, "penpal_files_in_review", map[string]any{ + "project": env.projName, + "worktree": "test-wt", + }) + + var files []fileWithThreadsResponse + if err := json.Unmarshal([]byte(text), &files); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 worktree file, got %d", len(files)) + } + if files[0].FilePath != "thoughts/review-wt.md" { + t.Errorf("file = %q, want %q", files[0].FilePath, "thoughts/review-wt.md") + } +} diff --git a/apps/penpal/internal/server/comments.go b/apps/penpal/internal/server/comments.go index dfb6b43e..f00b5c14 100644 --- a/apps/penpal/internal/server/comments.go +++ b/apps/penpal/internal/server/comments.go @@ -65,7 +65,7 @@ type APIFileInReview struct { WorkingThreads int `json:"workingThreads,omitempty"` } -// handleAPIListReviews handles GET /api/reviews?project=X[&agent=true]. +// handleAPIListReviews handles GET /api/reviews?project=X[&agent=true][&worktree=Z]. func (s *Server) handleAPIListReviews(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -78,9 +78,10 @@ func (s *Server) handleAPIListReviews(w http.ResponseWriter, r *http.Request) { return } + worktree := r.URL.Query().Get("worktree") isAgent := r.URL.Query().Get("agent") == "true" - files, err := s.comments.ListFilesInReview(projectName) + files, err := s.comments.ListFilesInReviewForWorktree(projectName, worktree) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -119,7 +120,7 @@ type threadResponse struct { AgentWorking bool `json:"agentWorking,omitempty"` } -// handleListThreads handles GET /api/threads?project=X&path=Y[&status=open][&agent=true]. +// handleListThreads handles GET /api/threads?project=X&path=Y[&status=open][&agent=true][&worktree=Z]. // Paths are project-relative (e.g., "thoughts/plans/foo.md"). func (s *Server) handleListThreads(w http.ResponseWriter, r *http.Request) { projectName := r.URL.Query().Get("project") @@ -130,6 +131,7 @@ func (s *Server) handleListThreads(w http.ResponseWriter, r *http.Request) { filePath := r.URL.Query().Get("path") status := r.URL.Query().Get("status") + worktree := r.URL.Query().Get("worktree") isAgent := r.URL.Query().Get("agent") == "true" // Record heartbeat when an agent polls for threads @@ -139,7 +141,7 @@ func (s *Server) handleListThreads(w http.ResponseWriter, r *http.Request) { // When path is omitted, return all open threads across the project if filePath == "" { - threads, err := s.comments.ListOpenThreads(projectName) + threads, err := s.comments.ListThreadsByStatusForWorktree(projectName, "open", worktree) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -152,11 +154,12 @@ func (s *Server) handleListThreads(w http.ResponseWriter, r *http.Request) { return } - threads, err := s.comments.LoadThreads(projectName, filePath) + fc, err := s.comments.LoadForWorktree(projectName, filePath, worktree) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + threads := fc.Threads // Filter by status if requested if status != "" { @@ -200,6 +203,7 @@ func (s *Server) handleCreateThread(w http.ResponseWriter, r *http.Request) { Role string `json:"role"` Body string `json:"body"` SuggestedReplies []string `json:"suggestedReplies,omitempty"` + Worktree string `json:"worktree,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -218,7 +222,7 @@ func (s *Server) handleCreateThread(w http.ResponseWriter, r *http.Request) { SuggestedReplies: req.SuggestedReplies, } - thread, err := s.comments.CreateThread(req.Project, req.Path, req.Anchor, comment) + thread, err := s.comments.CreateThreadForWorktree(req.Project, req.Path, req.Worktree, req.Anchor, comment) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -240,6 +244,7 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request, thread Role string `json:"role"` Body string `json:"body"` SuggestedReplies []string `json:"suggestedReplies,omitempty"` + Worktree string `json:"worktree,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -258,7 +263,7 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request, thread SuggestedReplies: req.SuggestedReplies, } - thread, err := s.comments.AddComment(req.Project, req.Path, threadID, comment) + thread, err := s.comments.AddCommentForWorktree(req.Project, req.Path, req.Worktree, threadID, comment) if err != nil { if strings.Contains(err.Error(), "not found") { http.Error(w, err.Error(), http.StatusNotFound) @@ -282,6 +287,7 @@ func (s *Server) handleUpdateThread(w http.ResponseWriter, r *http.Request, thre Path string `json:"path"` Status string `json:"status"` ResolvedBy string `json:"resolvedBy"` + Worktree string `json:"worktree,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -296,9 +302,9 @@ func (s *Server) handleUpdateThread(w http.ResponseWriter, r *http.Request, thre var err error switch req.Status { case "resolved": - err = s.comments.ResolveThread(req.Project, req.Path, threadID, req.ResolvedBy) + err = s.comments.ResolveThreadForWorktree(req.Project, req.Path, req.Worktree, threadID, req.ResolvedBy) case "open": - err = s.comments.ReopenThread(req.Project, req.Path, threadID) + err = s.comments.ReopenThreadForWorktree(req.Project, req.Path, req.Worktree, threadID) default: http.Error(w, "invalid status: must be 'resolved' or 'open'", http.StatusBadRequest) return diff --git a/apps/penpal/internal/server/file.go b/apps/penpal/internal/server/file.go index b1d7520e..678a6170 100644 --- a/apps/penpal/internal/server/file.go +++ b/apps/penpal/internal/server/file.go @@ -12,6 +12,7 @@ import ( func (s *Server) handleRawFile(w http.ResponseWriter, r *http.Request) { qualifiedName := r.URL.Query().Get("project") filePath := r.URL.Query().Get("path") + worktree := r.URL.Query().Get("worktree") if qualifiedName == "" || filePath == "" { http.Error(w, "missing project or path", http.StatusBadRequest) return @@ -23,11 +24,22 @@ func (s *Server) handleRawFile(w http.ResponseWriter, r *http.Request) { return } - fullPath := filepath.Join(project.Path, filePath) + // Use worktree path if specified + basePath := project.Path + if worktree != "" { + wtPath := s.cache.WorktreePath(qualifiedName, worktree) + if wtPath == "" { + http.Error(w, "worktree not found", http.StatusNotFound) + return + } + basePath = wtPath + } + + fullPath := filepath.Join(basePath, filePath) - // Prevent path traversal: resolved path must stay within the project root. + // Prevent path traversal: resolved path must stay within the base path. resolved, err := filepath.Abs(fullPath) - if err != nil || !isSubpath(project.Path, resolved) { + if err != nil || !isSubpath(basePath, resolved) { http.Error(w, "invalid path", http.StatusBadRequest) return } diff --git a/apps/penpal/internal/server/server.go b/apps/penpal/internal/server/server.go index 3655a2f8..d0e511d8 100644 --- a/apps/penpal/internal/server/server.go +++ b/apps/penpal/internal/server/server.go @@ -32,6 +32,8 @@ type Server struct { mcpHandler http.Handler mux *http.ServeMux loadOnce sync.Once + readyCh chan struct{} // closed when initial population is complete + readyOnce sync.Once frontendDir string // if set, serve React SPA from this directory at /app/ cfg *config.Config cfgPath string @@ -59,6 +61,7 @@ func New(c *cache.Cache, w *watcher.Watcher, cs *comments.Store, mcpHandler http activity: act, mcpHandler: mcpHandler, mux: http.NewServeMux(), + readyCh: make(chan struct{}), frontendDir: frontendDir, cfg: cfg, cfgPath: cfgPath, @@ -192,11 +195,20 @@ func (s *Server) ensureLoaded() { }) } +// handleReady blocks until the server's initial population is complete. +// Tauri polls this endpoint before showing the webview. +func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) { + <-s.readyCh + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ready":true}`)) +} + // populateProjects scans file lists and fills in git info in the background. func (s *Server) populateProjects() { s.cache.RefreshAllProjects() s.seedRecentActivity() log.Printf("Background file scan complete") + s.readyOnce.Do(func() { close(s.readyCh) }) // signal that essential data is ready s.watcher.Broadcast(watcher.Event{Type: watcher.EventProjectsChanged}) // Notify any open project pages that their file lists are now ready. @@ -282,6 +294,8 @@ func (s *Server) routes() { // Publish to Blockcell s.mux.HandleFunc("/api/publish", s.handlePublish) s.mux.HandleFunc("/api/publish-state", s.handlePublishState) + // Readiness check — blocks until initial population is complete + s.mux.HandleFunc("/api/ready", s.handleReady) // Install tools (CLI symlink + Claude Code plugin) s.mux.HandleFunc("/api/install-tools", s.handleInstallTools) // React SPA at /app/ (served from frontend/dist/ when it exists) @@ -599,22 +613,30 @@ type APIBadge struct { Bg string `json:"bg"` } +type APIWorktree struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + IsMain bool `json:"isMain"` +} + type APIProject struct { - Name string `json:"name"` - QualifiedName string `json:"qualifiedName"` - Workspace string `json:"workspace"` - WorkspacePath string `json:"workspacePath,omitempty"` - ProjectPath string `json:"projectPath"` - Origin string `json:"origin"` - Badges []APIBadge `json:"badges"` - Branch string `json:"branch,omitempty"` - Dirty bool `json:"dirty,omitempty"` - FileCount int `json:"fileCount"` - LastModified string `json:"lastModified"` - AgentConnected bool `json:"agentConnected,omitempty"` - AgentRunning bool `json:"agentRunning,omitempty"` - Age string `json:"age,omitempty"` - ReviewCount int `json:"reviewCount,omitempty"` + Name string `json:"name"` + QualifiedName string `json:"qualifiedName"` + Workspace string `json:"workspace"` + WorkspacePath string `json:"workspacePath,omitempty"` + ProjectPath string `json:"projectPath"` + Origin string `json:"origin"` + Badges []APIBadge `json:"badges"` + Branch string `json:"branch,omitempty"` + Dirty bool `json:"dirty,omitempty"` + FileCount int `json:"fileCount"` + LastModified string `json:"lastModified"` + AgentConnected bool `json:"agentConnected,omitempty"` + AgentRunning bool `json:"agentRunning,omitempty"` + Age string `json:"age,omitempty"` + ReviewCount int `json:"reviewCount,omitempty"` + Worktrees []APIWorktree `json:"worktrees,omitempty"` } func (s *Server) handleAPIProjects(w http.ResponseWriter, r *http.Request) { @@ -659,6 +681,19 @@ func (s *Server) handleListAPIProjects(w http.ResponseWriter, r *http.Request) { result[i].Branch = p.Git.Branch result[i].Dirty = p.Git.Dirty } + // Include worktrees + if len(p.Worktrees) > 0 { + apiWTs := make([]APIWorktree, len(p.Worktrees)) + for j, wt := range p.Worktrees { + apiWTs[j] = APIWorktree{ + Name: wt.Name, + Path: wt.Path, + Branch: wt.Branch, + IsMain: wt.IsMain, + } + } + result[i].Worktrees = apiWTs + } // Count files in review for this project if reviews, err := s.comments.ListFilesInReview(qn); err == nil { result[i].ReviewCount = len(reviews) @@ -712,10 +747,21 @@ func (s *Server) handleAPIProjectFiles(w http.ResponseWriter, r *http.Request) { return } - // Ensure file titles are populated before serving - s.cache.EnrichTitles(qualifiedName) + worktree := r.URL.Query().Get("worktree") - cachedFiles := s.cache.ProjectFiles(qualifiedName) + var cachedFiles []cache.FileInfo + if worktree != "" { + wtPath := s.cache.WorktreePath(qualifiedName, worktree) + if wtPath == "" { + json.NewEncoder(w).Encode([]APIFileGroupView{}) + return + } + cachedFiles = cache.ScanProjectSourcesForWorktree(project, wtPath) + } else { + // Ensure file titles are populated before serving + s.cache.EnrichTitles(qualifiedName) + cachedFiles = s.cache.ProjectFiles(qualifiedName) + } fileGroups := buildFileGroups(project, cachedFiles) result := make([]APIFileGroupView, 0, len(fileGroups)) diff --git a/apps/penpal/internal/server/worktree_test.go b/apps/penpal/internal/server/worktree_test.go new file mode 100644 index 00000000..3c7c5ac1 --- /dev/null +++ b/apps/penpal/internal/server/worktree_test.go @@ -0,0 +1,225 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/loganj/penpal/internal/cache" + "github.com/loganj/penpal/internal/comments" + "github.com/loganj/penpal/internal/discovery" +) + +func TestAPIProjects_IncludesWorktrees(t *testing.T) { + s, c, _ := testServer(t) + + // Replace all projects with just our test project + c.SetProjects([]discovery.Project{{ + Name: "myrepo", + Path: "/tmp/myrepo", + Origin: "workspace", + WorkspaceName: "Dev", + Worktrees: []discovery.Worktree{ + {Name: "myrepo", Path: "/tmp/myrepo", Branch: "main", IsMain: true}, + {Name: "fancy", Path: "/tmp/myrepo/.claude/worktrees/fancy", Branch: "feat"}, + }, + }}) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/projects", nil) + s.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + var result []APIProject + if err := json.NewDecoder(rr.Body).Decode(&result); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(result) != 1 { + t.Fatalf("expected 1 project, got %d", len(result)) + } + + if len(result[0].Worktrees) != 2 { + t.Fatalf("expected 2 worktrees, got %d", len(result[0].Worktrees)) + } + + if result[0].Worktrees[0].Name != "myrepo" { + t.Errorf("worktree[0].Name = %q, want %q", result[0].Worktrees[0].Name, "myrepo") + } + if !result[0].Worktrees[0].IsMain { + t.Error("worktree[0].IsMain should be true") + } + if result[0].Worktrees[1].Name != "fancy" { + t.Errorf("worktree[1].Name = %q, want %q", result[0].Worktrees[1].Name, "fancy") + } + if result[0].Worktrees[1].Branch != "feat" { + t.Errorf("worktree[1].Branch = %q, want %q", result[0].Worktrees[1].Branch, "feat") + } +} + +func TestRawFile_Worktree(t *testing.T) { + s, c, _ := testServer(t) + + projDir := t.TempDir() + wtDir := filepath.Join(projDir, ".claude", "worktrees", "test-wt") + os.MkdirAll(wtDir, 0755) + + // Write different content in main vs worktree + mainFile := filepath.Join(projDir, "thoughts", "doc.md") + wtFile := filepath.Join(wtDir, "thoughts", "doc.md") + os.MkdirAll(filepath.Dir(mainFile), 0755) + os.MkdirAll(filepath.Dir(wtFile), 0755) + os.WriteFile(mainFile, []byte("# Main Content"), 0644) + os.WriteFile(wtFile, []byte("# Worktree Content"), 0644) + + project := seedProject(c, "Dev/myrepo", projDir, []cache.FileInfo{ + {Project: "Dev/myrepo", FullPath: "thoughts/doc.md", Name: "doc.md"}, + }) + + // Add worktree to project + projects := c.Projects() + for i := range projects { + if projects[i].QualifiedName() == project.QualifiedName() { + projects[i].Worktrees = []discovery.Worktree{ + {Name: filepath.Base(projDir), Path: projDir, Branch: "main", IsMain: true}, + {Name: "test-wt", Path: wtDir, Branch: "feat"}, + } + } + } + c.SetProjects(projects) + + // Request without worktree — should get main content + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/raw?project=Dev/myrepo&path=thoughts/doc.md", nil) + s.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("main status = %d, want 200", rr.Code) + } + if body := rr.Body.String(); body != "# Main Content" { + t.Errorf("main content = %q, want %q", body, "# Main Content") + } + + // Request with worktree — should get worktree content + rr2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/raw?project=Dev/myrepo&path=thoughts/doc.md&worktree=test-wt", nil) + s.ServeHTTP(rr2, req2) + + if rr2.Code != http.StatusOK { + t.Fatalf("worktree status = %d, want 200", rr2.Code) + } + if body := rr2.Body.String(); body != "# Worktree Content" { + t.Errorf("worktree content = %q, want %q", body, "# Worktree Content") + } +} + +func TestThreads_WorktreeIsolation(t *testing.T) { + s, c, cs := testServer(t) + + projDir := t.TempDir() + wtDir := filepath.Join(projDir, ".claude", "worktrees", "test-wt") + os.MkdirAll(wtDir, 0755) + + // Create the file in both main and worktree + mainFile := filepath.Join(projDir, "thoughts", "isolated.md") + wtFile := filepath.Join(wtDir, "thoughts", "isolated.md") + os.MkdirAll(filepath.Dir(mainFile), 0755) + os.MkdirAll(filepath.Dir(wtFile), 0755) + os.WriteFile(mainFile, []byte("main"), 0644) + os.WriteFile(wtFile, []byte("wt"), 0644) + + project := seedProject(c, "Dev/repo", projDir, nil) + + projects := c.Projects() + for i := range projects { + if projects[i].QualifiedName() == project.QualifiedName() { + projects[i].Worktrees = []discovery.Worktree{ + {Name: filepath.Base(projDir), Path: projDir, Branch: "main", IsMain: true}, + {Name: "test-wt", Path: wtDir, Branch: "feat"}, + } + } + } + c.SetProjects(projects) + + // Create thread in main + anchor := comments.Anchor{SelectedText: "main"} + cs.CreateThread("Dev/repo", "thoughts/isolated.md", anchor, comments.Comment{Author: "human", Role: "human", Body: "main comment"}) + + // Create thread in worktree + cs.CreateThreadForWorktree("Dev/repo", "thoughts/isolated.md", "test-wt", anchor, comments.Comment{Author: "human", Role: "human", Body: "wt comment"}) + + // GET threads for main + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/threads?project=Dev/repo&path=thoughts/isolated.md", nil) + s.ServeHTTP(rr, req) + + var mainThreads []threadResponse + json.NewDecoder(rr.Body).Decode(&mainThreads) + if len(mainThreads) != 1 { + t.Fatalf("expected 1 main thread, got %d", len(mainThreads)) + } + if mainThreads[0].Comments[0].Body != "main comment" { + t.Errorf("main thread body = %q", mainThreads[0].Comments[0].Body) + } + + // GET threads for worktree + rr2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/threads?project=Dev/repo&path=thoughts/isolated.md&worktree=test-wt", nil) + s.ServeHTTP(rr2, req2) + + var wtThreads []threadResponse + json.NewDecoder(rr2.Body).Decode(&wtThreads) + if len(wtThreads) != 1 { + t.Fatalf("expected 1 worktree thread, got %d", len(wtThreads)) + } + if wtThreads[0].Comments[0].Body != "wt comment" { + t.Errorf("wt thread body = %q", wtThreads[0].Comments[0].Body) + } +} + +func TestCreateThread_ViaAPI_Worktree(t *testing.T) { + s, c, _ := testServer(t) + + projDir := t.TempDir() + wtDir := filepath.Join(projDir, ".claude", "worktrees", "test-wt") + os.MkdirAll(filepath.Join(wtDir, "thoughts"), 0755) + os.WriteFile(filepath.Join(wtDir, "thoughts", "api-create.md"), []byte("# Test\n\nSome content here"), 0644) + + project := seedProject(c, "Dev/repo", projDir, nil) + projects := c.Projects() + for i := range projects { + if projects[i].QualifiedName() == project.QualifiedName() { + projects[i].Worktrees = []discovery.Worktree{ + {Name: filepath.Base(projDir), Path: projDir, Branch: "main", IsMain: true}, + {Name: "test-wt", Path: wtDir, Branch: "feat"}, + } + } + } + c.SetProjects(projects) + + body := `{"project":"Dev/repo","path":"thoughts/api-create.md","anchor":{"selectedText":"Some content"},"author":"human","role":"human","body":"Review this","worktree":"test-wt"}` + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/threads", strings.NewReader(body)) + s.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + // Verify sidecar is in worktree + wtSidecar := filepath.Join(wtDir, ".penpal", "comments", "thoughts", "api-create.md.json") + if _, err := os.Stat(wtSidecar); os.IsNotExist(err) { + t.Error("sidecar should exist in worktree dir") + } + mainSidecar := filepath.Join(projDir, ".penpal", "comments", "thoughts", "api-create.md.json") + if _, err := os.Stat(mainSidecar); !os.IsNotExist(err) { + t.Error("sidecar should NOT exist in main dir") + } +} diff --git a/apps/penpal/internal/watcher/watcher.go b/apps/penpal/internal/watcher/watcher.go index 8a7c6127..76e15d65 100644 --- a/apps/penpal/internal/watcher/watcher.go +++ b/apps/penpal/internal/watcher/watcher.go @@ -27,9 +27,10 @@ const ( // Event represents a change notification type Event struct { - Type EventType `json:"type"` - Project string `json:"project,omitempty"` - Path string `json:"path,omitempty"` + Type EventType `json:"type"` + Project string `json:"project,omitempty"` + Path string `json:"path,omitempty"` + Worktree string `json:"worktree,omitempty"` } // Watcher watches for filesystem changes and updates the cache @@ -125,6 +126,48 @@ func (w *Watcher) watchProject(p discovery.Project) { log.Printf("Warning: could not watch %s: %v", commentsDir, err) } } + + // Watch worktree directories for source and comment changes + for _, wt := range p.Worktrees { + if wt.IsMain { + continue + } + // Watch worktree source directories (thoughts/, etc.) + for _, st := range discovery.AllSourceTypes() { + if st.AutoDetectDir == "" { + continue + } + wtSourceDir := filepath.Join(wt.Path, st.AutoDetectDir) + if info, err := os.Stat(wtSourceDir); err == nil && info.IsDir() { + if err := w.watchDir(wtSourceDir); err != nil { + log.Printf("Warning: could not watch worktree source %s: %v", wtSourceDir, err) + } + } + } + // Watch remapped manual sources (sources with RootPath but no AutoDetectDir) + for _, src := range p.Sources { + if src.RootPath == "" { + continue + } + rel, err := filepath.Rel(p.Path, src.RootPath) + if err != nil { + continue + } + wtSourceDir := filepath.Join(wt.Path, rel) + if info, err := os.Stat(wtSourceDir); err == nil && info.IsDir() { + if err := w.watchDir(wtSourceDir); err != nil { + log.Printf("Warning: could not watch worktree source %s: %v", wtSourceDir, err) + } + } + } + // Watch worktree comments directory + wtCommentsDir := filepath.Join(wt.Path, ".penpal", "comments") + if info, err := os.Stat(wtCommentsDir); err == nil && info.IsDir() { + if err := w.watchDir(wtCommentsDir); err != nil { + log.Printf("Warning: could not watch worktree comments %s: %v", wtCommentsDir, err) + } + } + } } // watchDir recursively watches a directory and its subdirectories @@ -313,7 +356,8 @@ notAutoDetect: } // findProjectForPath finds which project a path belongs to by checking -// all source roots and .penpal directories. Returns the qualified name. +// all source roots, .penpal directories, and worktree directories. +// Returns the qualified name. func (w *Watcher) findProjectForPath(path string) string { for _, p := range w.cache.Projects() { // Check all source roots @@ -326,6 +370,15 @@ func (w *Watcher) findProjectForPath(path string) string { if strings.HasPrefix(path, p.Path+"/.penpal/") { return p.QualifiedName() } + // Check worktree directories + for _, wt := range p.Worktrees { + if wt.IsMain { + continue + } + if strings.HasPrefix(path, wt.Path+"/") { + return p.QualifiedName() + } + } } return "" } diff --git a/apps/penpal/internal/watcher/watcher_test.go b/apps/penpal/internal/watcher/watcher_test.go new file mode 100644 index 00000000..8bd71e34 --- /dev/null +++ b/apps/penpal/internal/watcher/watcher_test.go @@ -0,0 +1,91 @@ +package watcher + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/loganj/penpal/internal/discovery" +) + +func TestWatchProjectRemappedManualSources(t *testing.T) { + // Create a temporary project directory with a manual source + projectDir := t.TempDir() + manualDir := filepath.Join(projectDir, "docs", "api") + if err := os.MkdirAll(manualDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create a worktree directory with the same manual source path + wtDir := t.TempDir() + wtManualDir := filepath.Join(wtDir, "docs", "api") + if err := os.MkdirAll(wtManualDir, 0o755); err != nil { + t.Fatal(err) + } + // Also create the auto-detect dir in the worktree + wtThoughtsDir := filepath.Join(wtDir, "thoughts") + if err := os.MkdirAll(wtThoughtsDir, 0o755); err != nil { + t.Fatal(err) + } + + project := discovery.Project{ + Name: "proj", + Path: projectDir, + Sources: []discovery.FileSource{ + { + Name: "thoughts", + Type: "tree", + SourceTypeName: "thoughts", + RootPath: filepath.Join(projectDir, "thoughts"), + Auto: true, + }, + { + Name: "api-docs", + Type: "tree", + SourceTypeName: "manual", + RootPath: manualDir, + Auto: false, + }, + }, + Worktrees: []discovery.Worktree{ + {Name: "main", Path: projectDir, IsMain: true}, + {Name: "feature", Path: wtDir, IsMain: false}, + }, + } + + w, err := New(nil, nil) + if err != nil { + t.Fatal(err) + } + defer w.Stop() + + w.watchProject(project) + + watched := w.watcher.WatchList() + sort.Strings(watched) + + // The remapped manual source directory in the worktree should be watched + found := false + for _, p := range watched { + if p == wtManualDir { + found = true + break + } + } + if !found { + t.Errorf("expected worktree manual source dir %s to be watched, watched: %v", wtManualDir, watched) + } + + // The auto-detect dir in the worktree should also be watched + foundAuto := false + for _, p := range watched { + if p == wtThoughtsDir { + foundAuto = true + break + } + } + if !foundAuto { + t.Errorf("expected worktree auto-detect dir %s to be watched, watched: %v", wtThoughtsDir, watched) + } +} diff --git a/thoughts/plans/penpal-worktree-awareness.md b/thoughts/plans/penpal-worktree-awareness.md new file mode 100644 index 00000000..b301b5fc --- /dev/null +++ b/thoughts/plans/penpal-worktree-awareness.md @@ -0,0 +1,228 @@ +# Penpal Worktree Awareness + +## Problem + +Penpal currently treats each project as a single directory rooted at its filesystem path. It has no concept of git worktrees — in fact, it actively hides worktree directories from its file browser. When a developer (or their agents) works across multiple worktrees of the same repository, Penpal can't distinguish between them. Comments, threads, and file views are all scoped to the single "main" project path. + +This means: + +- **Agents in worktrees are invisible.** An agent running in `/repo/.claude/worktrees/foo/` calls `penpal_find_project` and either gets matched to the parent repo (wrong context) or fails to match at all. +- **Comments from worktree agents have no home.** If an agent creates a thread while working in a worktree, the thread is anchored to the main project's comment store, even though the file content may differ between worktrees. +- **No way to see what's happening across worktrees.** A developer reviewing agent work has no visibility into which worktree produced which comments or changes. +- **File content is always from the main checkout.** When viewing a file in Penpal, you always see the main worktree's version, never a branch-specific worktree version. + +## Goals + +1. A developer can see all active worktrees for a project and navigate between them. +2. An agent running in a worktree can find and interact with the correct Penpal project context automatically. +3. Comments and threads are scoped to the worktree they were created in. +4. The UI makes it clear which worktree you're looking at and provides easy switching. +5. Worktrees that are cleaned up disappear from the UI gracefully. + +## Non-Goals + +- Creating or deleting worktrees from the Penpal UI. +- Merging or diffing across worktrees (that's git tooling). +- Supporting worktrees from different remotes or unrelated repositories. + +## Key Concepts + +### Worktree as a Project Variant + +A worktree is not a separate project — it's a variant of the same project. The mental model should be: one project, multiple working copies. This is analogous to how a browser has one site with multiple tabs, not multiple sites. + +### Worktree Identity + +Each worktree has: +- A **filesystem path** (e.g., `/repo/.claude/worktrees/fancy-name/`) +- A **branch** it's checked out to +- A **name** derived from the directory name (e.g., `fancy-name`) +- A **qualified name** using the `@` suffix (e.g., `Development/repo@fancy-name`) +- A relationship to a **main worktree** (the original clone) + +The main worktree is the default and uses the bare qualified name (`Development/repo`) or equivalently `Development/repo@main`. Worktrees are additive — they appear alongside the main view, not instead of it. Resolution uses two-phase lookup: match the project by longest-prefix on the part before `@`, then resolve the worktree from the suffix. + +## Requirements + +### R1: Worktree Discovery + +Penpal must discover worktrees associated with each project. + +- **R1.1:** On project discovery, detect all git worktrees by parsing `.git/worktrees/` in the main repo or by running `git worktree list`. +- **R1.2:** Each discovered worktree becomes a navigable variant of its parent project, not a separate top-level project. +- **R1.3:** Worktree discovery must handle the `.claude/worktrees/` convention (where agent worktrees typically live) but also support worktrees at arbitrary paths. +- **R1.4:** The filesystem watcher must watch for worktree creation and deletion so the UI updates live. + +### R2: Project Resolution for Agents + +Agents running in worktrees must be able to resolve their project context correctly. + +- **R2.1:** `penpal_find_project` must resolve paths inside a worktree to the correct project + worktree identifier. For example, `/repo/.claude/worktrees/foo/thoughts/plan.md` should resolve to project `Development/repo` with worktree `foo`. +- **R2.2:** All MCP tools (`penpal_list_threads`, `penpal_create_thread`, `penpal_reply`, etc.) must accept an optional `worktree` parameter to scope operations to a specific worktree. +- **R2.3:** When `worktree` is omitted, tools operate on the main worktree (backward compatible). +- **R2.4:** `penpal_find_project` should return the worktree identifier in its response so agents don't need to compute it themselves. + +### R3: Worktree-Scoped Comments + +Comments and threads must be scoped to the worktree they belong to. + +- **R3.1:** Each worktree gets its own comment storage namespace. A thread created in worktree `foo` must not appear when viewing the main worktree's version of the same file. +- **R3.2:** Comment sidecar files for worktrees should be stored under the worktree's own `.penpal/comments/` directory (i.e., at the worktree path, not the main repo path). +- **R3.3:** When a worktree is deleted, its comments become orphaned but are not automatically deleted. They should be queryable via an archival mechanism or simply left on disk. + +### R4: UI Navigation + +The UI must make worktrees discoverable and navigable. + +- **R4.1:** The project view must show active worktrees — their name, branch, and file activity. +- **R4.2:** Provide a worktree switcher when viewing a file that exists in multiple worktrees. This could be a dropdown, tab bar, or sidebar element. +- **R4.3:** The URL structure must encode the worktree. Proposed: `/file/{qualifiedName}/@{worktree}/{filePath}`. The worktree segment is always required. The main worktree uses `@main`. Old URLs without a worktree segment are redirected to `@main` for backward compatibility. Examples: + - `/file/Development/repo/@main/thoughts/plan.md` — main worktree + - `/file/Development/repo/thoughts/plan.md` → redirects to `@main` variant + - `/file/Development/repo/@fancy-name/thoughts/plan.md` — worktree variant +- **R4.4:** The file browser sidebar, when scoped to a project, should show which worktree is currently selected and allow switching. +- **R4.5:** "In Review" and "Recent" views should indicate which worktree each file belongs to and allow filtering by worktree. + +### R5: Real-Time Updates + +SSE events must be worktree-aware. + +- **R5.1:** `EventFilesChanged` must include the worktree identifier so the frontend can update the correct view. +- **R5.2:** `EventCommentsChanged` must include the worktree identifier. +- **R5.3:** The filesystem watcher must watch worktree directories for file changes, not just the main project root. +- **R5.4:** `EventProjectsChanged` should fire when worktrees are created or removed. + +### R6: Agent Visibility + +The UI should show agent activity per-worktree. + +- **R6.1:** Agent heartbeats (from MCP tool calls) should be associated with their worktree. +- **R6.2:** The project overview should show which worktrees have active agents. + +### R7: Lifecycle + +Worktrees are ephemeral. The system must handle their transient nature. + +- **R7.1:** When a worktree directory is deleted, Penpal must stop watching it and remove it from the active worktree list within one watcher cycle. +- **R7.2:** Stale worktrees (directory gone) must not cause errors in the UI or API. +- **R7.3:** If a worktree is re-created at the same path, it should be treated as a new worktree (fresh comment state, since the old `.penpal/` directory would have been deleted with the worktree). + +## Data Model Changes + +### Project (updated) + +``` +Project { + ...existing fields... + Worktrees []Worktree // discovered worktrees for this project + IsWorktree bool // true if this project IS a worktree (for internal tracking) + MainWorktreePath string // if IsWorktree, path to the main repo +} +``` + +### Worktree (new) + +``` +Worktree { + Name string // directory name (e.g., "fancy-name") + Path string // absolute filesystem path + Branch string // checked-out branch + IsMain bool // true for the original clone + AgentCount int // number of active agents in this worktree +} +``` + +### Cache / FileInfo (updated) + +``` +FileInfo { + ...existing fields... + Worktree string // worktree name, empty for main +} +``` + +## API Changes + +### MCP Tools + +All existing tools gain an optional `worktree` parameter: + +| Tool | Change | +|------|--------| +| `penpal_find_project` | Response adds `worktree` field | +| `penpal_list_threads` | Add optional `worktree` param | +| `penpal_read_thread` | Add optional `worktree` param | +| `penpal_reply` | Add optional `worktree` param | +| `penpal_create_thread` | Add optional `worktree` param | +| `penpal_files_in_review` | Add optional `worktree` param | +| `penpal_wait_for_changes` | Add optional `worktree` param; events scoped to worktree | + +### REST API + +| Endpoint | Change | +|----------|--------| +| `GET /api/projects` | Include `worktrees` array per project | +| `GET /api/files/{project}` | Add `?worktree=` query param | +| `GET /api/file/{project}/{path}` | Add `?worktree=` query param; serves file from worktree path | +| `GET /events` | Events include `worktree` field where applicable | + +## Backward Compatibility & Migration + +This section covers how pre-existing Penpal installs with existing data will behave after the worktree-awareness update. + +### What Breaks (Acceptable) + +**MCP tools and skills** — MCP tool schemas change (new `worktree` parameter, new fields in responses). Agents using the old schema will still work because the `worktree` parameter is optional and defaults to main. However, agent skills (like `penpal:monitor`) will be updated to pass worktree context. This is acceptable because Penpal offers to upgrade skills on update. + +### What Must Not Break + +**Existing comment data.** Today, comments are stored as sidecar JSON files at: +``` +{project.Path}/.penpal/comments/{filePath}.json +``` + +After this change, main worktree comments stay at exactly this path — no migration needed. The main worktree's comment store IS the existing comment store. Worktree-specific comments go to a new location: +``` +{worktree.Path}/.penpal/comments/{filePath}.json +``` + +Since worktrees are new, there is no existing data at worktree paths. No data migration required. + +**Existing URLs and bookmarks.** Today, file URLs look like: +``` +/file/Development/repo/thoughts/plan.md +``` + +After this change, the canonical URL format becomes: +``` +/file/Development/repo/@main/thoughts/plan.md +``` + +Old URLs without the `@worktree` segment must be detected and **301-redirected** to the `@main` variant. Detection: if the path segment immediately after the qualified project name does not start with `@`, treat it as a legacy URL and redirect. This preserves bookmarks, shared links, and browser history. + +**Existing REST API calls.** All REST endpoints that gain a `?worktree=` query parameter default to the main worktree when the parameter is omitted. Existing API consumers (e.g., the current frontend) will continue to work without modification until they opt in to worktree support. + +**SSE event streams.** Existing events gain a new `worktree` field. Clients that don't read this field are unaffected — they'll receive events from all worktrees (same as today's behavior of receiving all events from the single main worktree). + +### Migration Steps + +1. **Automatic (no user action):** + - Existing comment sidecar files are recognized as main-worktree comments. No file moves or renames. + - Old-format URLs are redirected to `@main` URLs. + - MCP tools accept calls with or without `worktree` parameter. + +2. **On skill upgrade (prompted by Penpal):** + - Updated skills pass `worktree` context from `penpal_find_project` response. + - Old skill versions continue to work (they just always target main worktree). + +3. **No manual migration:** + - No database changes (comments are file-based). + - No config file changes needed. + - No re-indexing of existing projects. + +### Edge Cases + +- **Agent creates comments from a worktree path but doesn't pass `worktree` param:** Comments land in the main worktree's store. This is the safe default — worse case is comments appear on main instead of being lost. +- **Worktree deleted while comments exist:** Comment sidecar files inside the worktree directory are deleted with the worktree. This is intentional — worktree comments are ephemeral by design (they track agent work on a branch). If preservation is needed, the agent or user should resolve/export before cleanup. +- **Multiple worktrees on the same branch:** Each worktree has its own comment namespace keyed by worktree name (directory name), not branch. Two worktrees on `feature-x` have independent comment stores. +