diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 89284c5e..6fee6762 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -63,7 +63,7 @@ import { startAnnotateServer, handleAnnotateServerReady, } from "@plannotator/server/annotate"; -import { type DiffType, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs"; +import { type DiffType, type FileMetadata, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs"; import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { urlToMarkdown } from "@plannotator/shared/url-to-markdown"; @@ -216,6 +216,7 @@ if (args[0] === "sessions") { let initialDiffType: DiffType | undefined; let agentCwd: string | undefined; let worktreeCleanup: (() => void | Promise) | undefined; + let initialFileMeta: Record | undefined; if (isPRMode) { // --- PR Review Mode --- @@ -388,11 +389,12 @@ if (args[0] === "sessions") { } else { // --- Local Review Mode --- gitContext = await getVcsContext(); - initialDiffType = gitContext.vcsType === "p4" ? "p4-default" : resolveDefaultDiffType(loadConfig()); + initialDiffType = gitContext.vcsType === "p4" ? "p4-default" : gitContext.vcsType === "gitbutler" ? "gitbutler:workspace" : resolveDefaultDiffType(loadConfig()); const diffResult = await runVcsDiff(initialDiffType, gitContext.defaultBranch); rawPatch = diffResult.patch; gitRef = diffResult.label; diffError = diffResult.error; + initialFileMeta = diffResult.fileMeta; } const reviewProject = (await detectProjectName()) ?? "_unknown"; @@ -402,6 +404,7 @@ if (args[0] === "sessions") { rawPatch, gitRef, error: diffError, + fileMeta: initialFileMeta, origin: detectedOrigin, diffType: gitContext ? (initialDiffType ?? "unstaged") : undefined, gitContext, diff --git a/bun.lock b/bun.lock index 633e9ebd..e743191a 100644 --- a/bun.lock +++ b/bun.lock @@ -629,9 +629,9 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rtVQB9/1XK8FWJgFtsOthbPifRMYypgJwxu+pK3NHx8WvFKmq7HcPDqNr8xLzGULjQEO7eAo2aOZfONOwYz+5g=="], - "@pierre/diffs": ["@pierre/diffs@1.1.12", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-InssHHM7f0nkazIRkuaiNCy6GkBLfwJlqc7LtTkMD/KSqsuc6bnL2V9sIQoG5PZu9jwinQiXUb/gT7itFa6U9A=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.19", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-XxGPKkVW+1t2KJQfgjmSnS+93nI9+ACJl1XjhF3Lo4BdQJOxV3pHeyix31ySn/m/1llq6O/7bXucE0OYCK6Kog=="], - "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -1997,7 +1997,7 @@ "openai": ["openai@6.10.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A=="], - "overlayscrollbars": ["overlayscrollbars@2.14.0", "", {}, "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig=="], + "overlayscrollbars": ["overlayscrollbars@2.15.1", "", {}, "sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA=="], "overlayscrollbars-react": ["overlayscrollbars-react@0.5.6", "", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "react": ">=16.8.0" } }, "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw=="], @@ -2589,8 +2589,12 @@ "@plannotator/portal/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@plannotator/review/@pierre/diffs": ["@pierre/diffs@1.1.15", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-Gj863E+aSpc0H3C4cH0fQTaF/tP9yYfhnilR7/dS72qq8thqNpR3fo3jURHRtRKz6KJJ10anxcurHP7b3ZUQkw=="], + "@plannotator/review/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@plannotator/review-editor/@pierre/diffs": ["@pierre/diffs@1.1.15", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-Gj863E+aSpc0H3C4cH0fQTaF/tP9yYfhnilR7/dS72qq8thqNpR3fo3jURHRtRKz6KJJ10anxcurHP7b3ZUQkw=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], @@ -2791,6 +2795,14 @@ "@plannotator/portal/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@plannotator/review-editor/@pierre/diffs/@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + + "@plannotator/review-editor/@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "@plannotator/review/@pierre/diffs/@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + + "@plannotator/review/@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "@plannotator/review/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@textlint/linter-formatter/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 86205844..91421fca 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -55,7 +55,7 @@ import { REVIEW_PR_COMMENTS_PANEL_ID, REVIEW_PR_CHECKS_PANEL_ID, } from './dock/reviewPanelTypes'; -import type { DiffFile } from './types'; +import type { DiffFile, FileMetadata } from './types'; import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types'; import type { PRMetadata } from '@plannotator/shared/pr-provider'; import { altKey } from '@plannotator/ui/utils/platform'; @@ -155,6 +155,7 @@ const ReviewApp: React.FC = () => { const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); const [diffType, setDiffType] = useState('uncommitted'); + const [fileMeta, setFileMeta] = useState | null>(null); const [gitContext, setGitContext] = useState(null); const [agentCwd, setAgentCwd] = useState(null); const [isLoadingDiff, setIsLoadingDiff] = useState(false); @@ -656,6 +657,7 @@ const ReviewApp: React.FC = () => { setFiles(apiFiles); if (data.origin) setOrigin(data.origin); if (data.diffType) setDiffType(data.diffType); + if (data.fileMeta) setFileMeta(data.fileMeta); if (data.gitContext) setGitContext(data.gitContext); if (data.agentCwd) setAgentCwd(data.agentCwd); if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled); @@ -912,6 +914,7 @@ const ReviewApp: React.FC = () => { setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef, diffType: data.diffType } : prev); setFiles(nextFiles); setDiffType(data.diffType); + setFileMeta(data.fileMeta ?? null); setActiveFileIndex(0); setPendingSelection(null); setDiffError(data.error || null); @@ -1662,6 +1665,9 @@ const ReviewApp: React.FC = () => { activeWorktreePath={activeWorktreePath} onSelectWorktree={handleWorktreeSwitch} currentBranch={gitContext?.currentBranch} + vcsType={gitContext?.vcsType} + fileMeta={fileMeta ?? undefined} + hideLaneLabel={diffType.startsWith('gitbutler:') && diffType !== 'gitbutler:workspace'} stagedFiles={stagedFiles} onCopyRawDiff={handleCopyDiff} canCopyRawDiff={!!diffData?.rawPatch} diff --git a/packages/review-editor/components/FileTree.tsx b/packages/review-editor/components/FileTree.tsx index 4abbc1f7..b03dda9d 100644 --- a/packages/review-editor/components/FileTree.tsx +++ b/packages/review-editor/components/FileTree.tsx @@ -4,7 +4,7 @@ import type { DiffOption, WorktreeInfo } from '@plannotator/shared/types'; import { buildFileTree, getAncestorPaths, getAllFolderPaths } from '../utils/buildFileTree'; import { FileTreeNodeItem } from './FileTreeNode'; import { getReviewSearchSideLabel, type ReviewSearchFileGroup, type ReviewSearchMatch } from '../utils/reviewSearch'; -import type { DiffFile } from '../types'; +import type { DiffFile, FileMetadata } from '../types'; import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; interface FileTreeProps { @@ -27,6 +27,9 @@ interface FileTreeProps { activeWorktreePath?: string | null; onSelectWorktree?: (path: string | null) => void; currentBranch?: string; + vcsType?: string; + fileMeta?: Record; + hideLaneLabel?: boolean; stagedFiles?: Set; onCopyRawDiff?: () => void; canCopyRawDiff?: boolean; @@ -66,6 +69,9 @@ export const FileTree: React.FC = ({ activeWorktreePath, onSelectWorktree, currentBranch, + vcsType, + fileMeta, + hideLaneLabel, stagedFiles, onCopyRawDiff, canCopyRawDiff = false, @@ -391,6 +397,8 @@ export const FileTree: React.FC = ({ hideViewedFiles={hideViewedFiles} getAnnotationCount={getAnnotationCount} stagedFiles={stagedFiles} + fileMeta={fileMeta} + hideLaneLabel={hideLaneLabel} /> )) )} diff --git a/packages/review-editor/components/FileTreeNode.tsx b/packages/review-editor/components/FileTreeNode.tsx index 254f3e22..fbaa71ee 100644 --- a/packages/review-editor/components/FileTreeNode.tsx +++ b/packages/review-editor/components/FileTreeNode.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { FileTreeNode as TreeNode } from '../utils/buildFileTree'; +import type { FileMetadata } from '../types'; interface FileTreeNodeProps { node: TreeNode; @@ -13,6 +14,8 @@ interface FileTreeNodeProps { hideViewedFiles: boolean; getAnnotationCount: (filePath: string) => number; stagedFiles?: Set; + fileMeta?: Record; + hideLaneLabel?: boolean; } function hasVisibleChildren( @@ -44,6 +47,8 @@ export const FileTreeNodeItem: React.FC = ({ hideViewedFiles, getAnnotationCount, stagedFiles, + fileMeta, + hideLaneLabel, }) => { const paddingLeft = 4 + node.depth * 8; @@ -92,6 +97,8 @@ export const FileTreeNodeItem: React.FC = ({ hideViewedFiles={hideViewedFiles} getAnnotationCount={getAnnotationCount} stagedFiles={stagedFiles} + fileMeta={fileMeta} + hideLaneLabel={hideLaneLabel} /> ))} @@ -103,6 +110,7 @@ export const FileTreeNodeItem: React.FC = ({ const isViewed = viewedFiles.has(node.path); const isStaged = stagedFiles?.has(node.path) ?? false; const annotationCount = getAnnotationCount(node.path); + const meta = fileMeta?.[node.path]; if (hideViewedFiles && isViewed && !isActive) { return null; @@ -141,6 +149,26 @@ export const FileTreeNodeItem: React.FC = ({ {isStaged && ( + )} + {meta && meta.lanes && (() => { + const srcChar = meta.source === 'committed' ? 'C' : meta.source === 'uncommitted' ? 'S' : meta.source === 'mixed' ? 'M' : null; + const srcWord = meta.source === 'committed' ? 'committed' : meta.source === 'uncommitted' ? 'staged' : meta.source === 'mixed' ? 'committed + staged' : null; + const srcColor = meta.source === 'committed' ? 'text-blue-400' : meta.source === 'uncommitted' ? 'text-orange-400' : meta.source === 'mixed' ? 'text-purple-400' : 'text-muted-foreground/60'; + const laneLabel = meta.lanes.length === 1 ? meta.lanes[0] : `${meta.lanes.length} lanes`; + const hoverTitle = (() => { + if (meta.laneDetails && meta.laneDetails.length > 1) { + return meta.laneDetails + .map((d) => `${d.source === 'committed' ? 'committed' : 'staged'} to ${d.lane}`) + .join(', '); + } + if (srcWord && laneLabel) return `${srcWord} to ${meta.lanes.join(', ')}`; + if (srcWord) return srcWord; + return meta.lanes.join(', '); + })(); + const displayLabel = [srcChar, hideLaneLabel ? null : laneLabel].filter(Boolean).join(' · '); + return displayLabel ? ( + {displayLabel} + ) : null; + })()} {annotationCount > 0 && ( {annotationCount} )} diff --git a/packages/review-editor/types.ts b/packages/review-editor/types.ts index 5e44f14f..9d10c5a0 100644 --- a/packages/review-editor/types.ts +++ b/packages/review-editor/types.ts @@ -5,3 +5,5 @@ export interface DiffFile { additions: number; deletions: number; } + +export type { FileMetadata } from "@plannotator/shared/types"; diff --git a/packages/server/gitbutler.ts b/packages/server/gitbutler.ts new file mode 100644 index 00000000..f646a48a --- /dev/null +++ b/packages/server/gitbutler.ts @@ -0,0 +1,440 @@ +/** + * GitButler utilities for code review + * + * Provides virtual-branch diff support for GitButler workspaces. + * Mirrors the structure of p4.ts for consistent VCS abstraction. + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + type DiffResult, + type DiffType, + type FileMetadata, + type GitContext, +} from "@plannotator/shared/review-core"; +import { + getCurrentBranch, + getDefaultBranch, + getFileContentsForDiff, +} from "./git"; + +// --- but command runner --- + +async function runBut( + args: string[], + cwd?: string, + timeoutMs = 30_000, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(["but", ...args], { + cwd, + stdin: new Uint8Array(0), + stdout: "pipe", + stderr: "pipe", + }); + + const processResult = Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode })); + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`but ${args[0]} timed out after ${timeoutMs}ms`)), + timeoutMs, + ), + ); + + try { + return await Promise.race([processResult, timeoutPromise]); + } catch (err) { + proc.kill(); + throw err; + } +} + +// --- Detection --- + +export async function detectGitButlerRepo(cwd?: string): Promise { + const dir = cwd ?? process.cwd(); + return existsSync(join(dir, ".git", "gitbutler", "but.sqlite")); +} + +// --- Context --- + +interface ButBranch { + cliId?: string; + name?: string; +} + +interface ButStatusChange { + filePath: string; + changeType: "added" | "modified" | "deleted" | string; +} + +interface ButStack { + cliId?: string; + branches?: ButBranch[]; + assignedChanges?: ButStatusChange[]; +} + +interface ButStatusJson { + stacks?: ButStack[]; + unassignedChanges?: ButStatusChange[]; + mergeBase?: { commitId?: string }; +} + +export async function getGitButlerContext(cwd?: string): Promise { + const [currentBranch, defaultBranch, statusResult] = await Promise.all([ + getCurrentBranch(), + getDefaultBranch(), + runBut(["status", "-j"], cwd), + ]); + + const diffOptions = [ + { id: "gitbutler:workspace", label: "Workspace (all changes)" }, + ]; + const virtualBranches: Array<{ id: string; name: string }> = []; + + if (statusResult.exitCode === 0) { + try { + const status = JSON.parse(statusResult.stdout) as ButStatusJson; + for (const stack of status.stacks ?? []) { + if (!stack.cliId) continue; + const branches = stack.branches ?? []; + const topBranchName = branches[0]?.name ?? stack.cliId; + virtualBranches.push({ id: stack.cliId, name: topBranchName }); + + if (branches.length > 1) { + // Multi-branch stack: show a combined stack option + individual branch options + diffOptions.push({ + id: `gitbutler:${stack.cliId}`, + label: `Stack: ${topBranchName} (${branches.length})`, + }); + for (const branch of branches) { + if (!branch.cliId || !branch.name) continue; + diffOptions.push({ + id: `gitbutler:${stack.cliId}:${branch.cliId}`, + label: ` › ${branch.name}`, + }); + } + } else { + // Single-branch stack: show as a plain branch option + diffOptions.push({ id: `gitbutler:${stack.cliId}`, label: topBranchName }); + } + } + } catch { + // ignore JSON parse errors — workspace option still available + } + } + + return { + currentBranch, + defaultBranch, + diffOptions, + worktrees: [], + virtualBranches, + vcsType: "gitbutler", + cwd, + }; +} + +// GitButler only: per-lane committed/uncommitted breakdown entry. +type LaneDetail = { lane: string; source: "committed" | "uncommitted" }; + +function addLaneDetail(map: Map, filePath: string, detail: LaneDetail): void { + const arr = map.get(filePath) ?? []; + if (!arr.some((d) => d.lane === detail.lane && d.source === detail.source)) arr.push(detail); + map.set(filePath, arr); +} + +function buildFileMeta(fileDetails: Map): Record { + const fileMeta: Record = {}; + for (const [filePath, details] of fileDetails) { + if (details.length === 0) { fileMeta[filePath] = {}; continue; } + const lanes = [...new Set(details.map((d) => d.lane))]; + const sources: Array<"committed" | "uncommitted"> = [...new Set(details.map((d) => d.source))]; + const source: FileMetadata["source"] = sources.length === 1 ? sources[0] : "mixed"; + fileMeta[filePath] = lanes.length > 1 + ? { source, lanes, laneDetails: details } + : { source, lanes }; + } + return fileMeta; +} + +// --- Diff --- + +interface ButHunk { + diff: string; +} + +interface ButDiffEntry { + path: string; + status: "added" | "modified" | "deleted" | string; + diff?: { hunks?: ButHunk[] }; +} + +interface ButDiffJson { + changes: ButDiffEntry[]; +} + +async function runGit( + args: string[], + cwd?: string, + timeoutMs = 30_000, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdin: new Uint8Array(0), + stdout: "pipe", + stderr: "pipe", + }); + + const processResult = Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]).then(([stdout, stderr, exitCode]) => ({ stdout, stderr, exitCode })); + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`git ${args[0]} timed out after ${timeoutMs}ms`)), + timeoutMs, + ), + ); + + try { + return await Promise.race([processResult, timeoutPromise]); + } catch (err) { + proc.kill(); + throw err; + } +} + +function hunkOldStart(hunkDiff: string): number { + const m = hunkDiff.match(/@@ -(\d+)/); + return m ? parseInt(m[1], 10) : 0; +} + +function mergeChanges(a: ButDiffEntry[], b: ButDiffEntry[]): ButDiffEntry[] { + const byPath = new Map(); + for (const entry of [...a, ...b]) { + const existing = byPath.get(entry.path); + if (!existing) { + byPath.set(entry.path, { ...entry, diff: { hunks: [...(entry.diff?.hunks ?? [])] } }); + } else { + const merged = existing.diff!.hunks!; + for (const hunk of entry.diff?.hunks ?? []) { + merged.push(hunk); + } + merged.sort((x, y) => hunkOldStart(x.diff) - hunkOldStart(y.diff)); + } + } + return [...byPath.values()]; +} + +function buildUnifiedPatch(changes: ButDiffEntry[]): string { + const parts: string[] = []; + for (const change of changes) { + const hunks = change.diff?.hunks; + if (!hunks || hunks.length === 0) continue; + const aPath = change.status === "added" ? "/dev/null" : `a/${change.path}`; + const bPath = change.status === "deleted" ? "/dev/null" : `b/${change.path}`; + parts.push(`diff --git a/${change.path} b/${change.path}\n`); + parts.push(`index 0000000..0000000 100644\n`); + parts.push(`--- ${aPath}\n+++ ${bPath}\n`); + for (const hunk of hunks) { + parts.push(hunk.diff); + } + } + return parts.join(""); +} + +async function getButStatus(cwd?: string): Promise { + const result = await runBut(["status", "-j"], cwd); + if (result.exitCode !== 0) return {}; + try { + return JSON.parse(result.stdout) as ButStatusJson; + } catch { + return {}; + } +} + +async function butDiffChanges(target: string, cwd?: string): Promise { + const result = await runBut(["diff", "-j", "--no-tui", target], cwd); + if (result.exitCode !== 0) return []; + try { + return (JSON.parse(result.stdout) as ButDiffJson).changes ?? []; + } catch { + return []; + } +} + +export async function runGitButlerDiff( + diffType: DiffType, + cwd?: string, +): Promise { + const rawTarget = + diffType === "gitbutler:workspace" + ? null + : (diffType as string).slice("gitbutler:".length); + + if (!rawTarget) { + // Workspace: diff from merge base (includes committed lane changes + working tree changes) + const status = await getButStatus(cwd); + const mergeBase = status.mergeBase?.commitId; + const ref = mergeBase ?? "HEAD"; + + const trackedResult = await runGit( + ["diff", "--no-ext-diff", ref, "--src-prefix=a/", "--dst-prefix=b/"], + cwd, + ); + if (trackedResult.exitCode !== 0) { + const msg = trackedResult.stderr.split("\n").find((l) => l.trim()) ?? trackedResult.stderr; + return { patch: "", label: "Workspace", error: msg.slice(0, 200) }; + } + + // git diff never includes untracked files — collect new files from but status + const allChanges = [ + ...(status.unassignedChanges ?? []), + ...(status.stacks ?? []).flatMap((s) => s.assignedChanges ?? []), + ]; + const newFiles = allChanges + .filter((c) => c.changeType === "added") + .map((c) => c.filePath); + + // Committed files per individual branch (for accurate lane attribution) + const committedByBranch = await Promise.all( + (status.stacks ?? []).flatMap((stack) => + (stack.branches ?? []) + .filter((b): b is ButBranch & { cliId: string; name: string } => Boolean(b.cliId && b.name)) + .map(async (branch) => { + const changes = await butDiffChanges(branch.cliId, cwd); + return { branchName: branch.name, paths: changes.map((c) => c.path) }; + }) + ), + ); + + const untrackedPatches = await Promise.all( + newFiles.map((file) => + runGit( + ["diff", "--no-ext-diff", "--no-index", "--src-prefix=a/", "--dst-prefix=b/", "/dev/null", file], + cwd, + ), + ), + ); + + const patch = [trackedResult.stdout, ...untrackedPatches.map((r) => r.stdout)].join(""); + + // Build fileMeta tracking per-lane source for accurate hover text + const fileDetails = new Map(); + for (const c of status.unassignedChanges ?? []) { + fileDetails.set(c.filePath, fileDetails.get(c.filePath) ?? []); + } + for (const stack of status.stacks ?? []) { + const laneName = stack.branches?.[0]?.name ?? stack.cliId ?? "unknown"; + for (const c of stack.assignedChanges ?? []) { + addLaneDetail(fileDetails, c.filePath, { lane: laneName, source: "uncommitted" }); + } + } + for (const { branchName, paths } of committedByBranch) { + for (const p of paths) { + addLaneDetail(fileDetails, p, { lane: branchName, source: "committed" }); + } + } + + return { patch, label: "Workspace (all changes)", fileMeta: buildFileMeta(fileDetails) }; + } + + // Determine whether this is a per-stack or individual-branch diff + const colonIdx = rawTarget.indexOf(":"); + const isIndividualBranch = colonIdx > -1; + + if (isIndividualBranch) { + // Individual branch: gitbutler:{stackId}:{branchId} — show only that branch's commits + const stackId = rawTarget.slice(0, colonIdx); + const branchId = rawTarget.slice(colonIdx + 1); + + const status = await getButStatus(cwd); + const stack = status.stacks?.find((s) => s.cliId === stackId); + const branch = stack?.branches?.find((b) => b.cliId === branchId); + const branchLabel = branch?.name ?? branchId; + + const committedChanges = await butDiffChanges(branchId, cwd); + + const fileMeta: Record = {}; + for (const c of committedChanges) fileMeta[c.path] = { source: "committed", lanes: [branchLabel] }; + + return { patch: buildUnifiedPatch(committedChanges), label: branchLabel, fileMeta }; + } + + // Per-stack: combine uncommitted (stack) + committed (all branches) diffs for full picture + const target = rawTarget; + const status = await getButStatus(cwd); + const stack = status.stacks?.find((s) => s.cliId === target); + const branchCliIds = stack?.branches?.map((b) => b.cliId).filter((id): id is string => Boolean(id)) ?? []; + + const [uncommittedChanges, ...committedChangeSets] = await Promise.all([ + butDiffChanges(target, cwd), + ...branchCliIds.map((id) => butDiffChanges(id, cwd)), + ]); + + const allCommittedChanges = committedChangeSets.flat(); + const merged = mergeChanges(uncommittedChanges, allCommittedChanges); + + const branches = stack?.branches ?? []; + const stackLabel = branches.length > 1 + ? `Stack: ${branches[0]?.name ?? target} (${branches.length})` + : (branches[0]?.name ?? target); + + const branchNameById = new Map( + branches + .filter((b): b is ButBranch & { cliId: string; name: string } => Boolean(b.cliId && b.name)) + .map((b) => [b.cliId, b.name]) + ); + + const stackFileDetails = new Map(); + + for (const c of uncommittedChanges) addLaneDetail(stackFileDetails, c.path, { lane: stackLabel, source: "uncommitted" }); + + for (let i = 0; i < branchCliIds.length; i++) { + const branchName = branchNameById.get(branchCliIds[i]) ?? branchCliIds[i]; + for (const c of committedChangeSets[i]) addLaneDetail(stackFileDetails, c.path, { lane: branchName, source: "committed" }); + } + + return { patch: buildUnifiedPatch(merged), label: stackLabel, fileMeta: buildFileMeta(stackFileDetails) }; +} + +// --- File contents --- + +export async function getGitButlerFileContents( + diffType: DiffType, + _defaultBranch: string, + filePath: string, + oldPath?: string, + cwd?: string, +): Promise<{ oldContent: string | null; newContent: string | null }> { + // Per-lane: return null to avoid @pierre/diffs trailing context mismatch + // (other lanes' hunks in the working tree shift line counts after the last hunk) + if (diffType !== "gitbutler:workspace") { + return { oldContent: null, newContent: null }; + } + + // Workspace: old = file at merge base (null for files added since), new = working tree + const status = await getButStatus(cwd); + const mergeBase = status.mergeBase?.commitId ?? "HEAD"; + const oldFilePath = oldPath ?? filePath; + + const [oldResult, newContent] = await Promise.all([ + runGit(["show", `${mergeBase}:${oldFilePath}`], cwd), + Bun.file(cwd ? `${cwd}/${filePath}` : filePath) + .text() + .catch(() => null), + ]); + + return { + oldContent: oldResult.exitCode === 0 ? oldResult.stdout : null, + newContent, + }; +} diff --git a/packages/server/review.ts b/packages/server/review.ts index c6b98ac0..2af2d2fd 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -11,7 +11,7 @@ import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import type { Origin } from "@plannotator/shared/agents"; -import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath } from "./vcs"; +import { type DiffType, type FileMetadata, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath } from "./vcs"; import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; @@ -53,6 +53,8 @@ export interface ReviewServerOptions { gitRef: string; /** Error message if git diff failed */ error?: string; + /** Per-file metadata (source, lane) for GitButler diffs */ + fileMeta?: Record; /** HTML content to serve for the UI */ htmlContent: string; /** Origin identifier for UI customization */ @@ -125,6 +127,7 @@ export async function startReviewServer( let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentError = options.error; + let currentFileMetadata: Record | undefined = options.fileMeta; // Agent jobs — background process manager (late-binds serverUrl via getter) let serverUrl = ""; @@ -370,6 +373,7 @@ export async function startReviewServer( ...(isPRMode && { prMetadata, platformUser }), ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), ...(currentError && { error: currentError }), + ...(currentFileMetadata && { fileMeta: currentFileMetadata }), serverConfig: getServerConfig(gitUser), }); } @@ -404,12 +408,14 @@ export async function startReviewServer( currentGitRef = result.label; currentDiffType = newDiffType; currentError = result.error; + currentFileMetadata = result.fileMeta; return Response.json({ rawPatch: currentPatch, gitRef: currentGitRef, diffType: currentDiffType, ...(currentError && { error: currentError }), + ...(currentFileMetadata && { fileMeta: currentFileMetadata }), }); } catch (err) { const message = diff --git a/packages/server/vcs.ts b/packages/server/vcs.ts index 5c15d02f..ea1f9b15 100644 --- a/packages/server/vcs.ts +++ b/packages/server/vcs.ts @@ -33,6 +33,13 @@ import { getP4FileContentsForDiff, } from "./p4"; +import { + detectGitButlerRepo, + getGitButlerContext, + runGitButlerDiff, + getGitButlerFileContents, +} from "./gitbutler"; + // --- VCS Provider interface --- export interface VcsProvider { @@ -143,18 +150,44 @@ const p4Provider: VcsProvider = { // P4 has no staging concept — stageFile/unstageFile intentionally omitted }; +// --- GitButler provider --- + +const gitbutlerProvider: VcsProvider = { + id: "gitbutler", + + detect: detectGitButlerRepo, + + ownsDiffType(diffType: string): boolean { + return diffType.startsWith("gitbutler:"); + }, + + getContext: getGitButlerContext, + + runDiff(diffType: DiffType, _defaultBranch: string, cwd?: string) { + return runGitButlerDiff(diffType, cwd); + }, + + getFileContents(diffType, defaultBranch, filePath, oldPath?, cwd?) { + return getGitButlerFileContents(diffType, defaultBranch, filePath, oldPath, cwd); + }, + + // No stageFile/unstageFile — GitButler manages its own staging via `but stage` +}; + // --- Provider registry --- /** Providers in detection priority order. First match wins. */ -const providers: VcsProvider[] = [gitProvider, p4Provider]; +const providers: VcsProvider[] = [gitbutlerProvider, gitProvider, p4Provider]; // Re-export types consumers need export type { DiffType, DiffOption, + FileMetadata, GitContext, WorktreeInfo, -} from "./git"; + VirtualBranchInfo, +} from "@plannotator/shared/review-core"; export { parseWorktreeDiffType, validateFilePath, runtime as gitRuntime } from "./git"; diff --git a/packages/shared/review-core.ts b/packages/shared/review-core.ts index 1a719513..98bab644 100644 --- a/packages/shared/review-core.ts +++ b/packages/shared/review-core.ts @@ -16,7 +16,8 @@ export type DiffType = | "merge-base" | `worktree:${string}` | "p4-default" - | `p4-changelist:${string}`; + | `p4-changelist:${string}` + | `gitbutler:${string}`; export interface DiffOption { id: string; @@ -29,19 +30,36 @@ export interface WorktreeInfo { head: string; } +/** GitButler virtual branch (stack branch). */ +export interface VirtualBranchInfo { + id: string; + name: string; +} + export interface GitContext { currentBranch: string; defaultBranch: string; diffOptions: DiffOption[]; worktrees: WorktreeInfo[]; cwd?: string; - vcsType?: "git" | "p4"; + vcsType?: "git" | "p4" | "gitbutler"; + /** GitButler only: virtual branches (stacks) active in the workspace. */ + virtualBranches?: VirtualBranchInfo[]; +} + +export interface FileMetadata { + source?: "committed" | "uncommitted" | "mixed"; + /** GitButler only: names of virtual branch lanes that own this file. */ + lanes?: string[]; + /** GitButler only: per-lane committed/uncommitted breakdown (populated when a file spans multiple lanes). */ + laneDetails?: Array<{ lane: string; source: "committed" | "uncommitted" }>; } export interface DiffResult { patch: string; label: string; error?: string; + fileMeta?: Record; } export interface GitCommandResult { diff --git a/packages/shared/types.ts b/packages/shared/types.ts index d12a50d0..da7f2cc7 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -12,6 +12,7 @@ export interface EditorAnnotation { // Git review types shared between server and client export type { DiffOption, + FileMetadata, WorktreeInfo, GitContext, } from "./review-core";