feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
365
src/http/diff.ts
Normal file
365
src/http/diff.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* src/http/diff.ts (N-diff-be, B1) — read-only structured git diff.
|
||||
*
|
||||
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
|
||||
* `git diff` in a directory and PARSES its text into DiffFile/DiffLine. Parsing
|
||||
* lives ONLY here (public/diff.ts is render-only — review #2). The frontend never
|
||||
* re-derives diff structure; it only renders these objects with textContent.
|
||||
*
|
||||
* Security (SP4, §B1.4):
|
||||
* - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS (SEC-M9).
|
||||
* - the trailing `--` terminates options so a path can't be read as a flag.
|
||||
* (path → repo three-way validation lives in the ROUTE layer, SEC-H7.)
|
||||
* - parsers NEVER throw: garbage lines degrade to `context`; getDiff is
|
||||
* best-effort and returns an empty result rather than rejecting (house style).
|
||||
* - diff content is carried verbatim in DiffLine.text — the FE renders it as
|
||||
* inert text (AC-B1.4), never HTML.
|
||||
*
|
||||
* FR-B1.9 (`?base=<rev>`) is intentionally deferred to P2 (review #13): it needs
|
||||
* a `git rev-parse --verify` allow-list before any revision reaches the CLI.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type {
|
||||
Config,
|
||||
DiffFile,
|
||||
DiffHunk,
|
||||
DiffLine,
|
||||
DiffLineKind,
|
||||
DiffResult,
|
||||
FileStatus,
|
||||
} from '../types.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// ── numstat (pure) ──────────────────────────────────────────────────────────
|
||||
|
||||
/** One `git diff --numstat` row: `<added>\t<removed>\t<path>`; binary = `-\t-`. */
|
||||
export interface NumstatEntry {
|
||||
added: number
|
||||
removed: number
|
||||
binary: boolean
|
||||
}
|
||||
|
||||
/** Non-negative integer or 0 for `-`/junk (never NaN). */
|
||||
function toCount(field: string): number {
|
||||
const n = Number.parseInt(field, 10)
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a numstat path field into its old/new forms. A rename is shown either
|
||||
* as `old => new` or, with a shared prefix/suffix, as `pre/{old => new}/suf`.
|
||||
* Non-renames return the same path for both.
|
||||
*/
|
||||
function expandNumstatPath(raw: string): { oldPath: string; newPath: string } {
|
||||
const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(raw)
|
||||
if (braced !== null) {
|
||||
const [, pre = '', oldMid = '', newMid = '', suf = ''] = braced
|
||||
const collapse = (s: string): string => (pre + s + suf).replace(/\/{2,}/g, '/')
|
||||
return { oldPath: collapse(oldMid), newPath: collapse(newMid) }
|
||||
}
|
||||
const arrow = raw.split(' => ')
|
||||
if (arrow.length === 2) {
|
||||
return { oldPath: arrow[0]?.trim() ?? raw, newPath: arrow[1]?.trim() ?? raw }
|
||||
}
|
||||
return { oldPath: raw, newPath: raw }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff --numstat` output into a path → counts map. Renames are keyed
|
||||
* under BOTH old and new paths so the unified-diff parser can find them by
|
||||
* whichever path it derived. Malformed lines are skipped; never throws.
|
||||
*/
|
||||
export function parseNumstat(out: string): Map<string, NumstatEntry> {
|
||||
const map = new Map<string, NumstatEntry>()
|
||||
if (typeof out !== 'string') return map
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.trim() === '') continue
|
||||
const parts = line.split('\t')
|
||||
if (parts.length < 3) continue
|
||||
const addStr = parts[0] ?? ''
|
||||
const remStr = parts[1] ?? ''
|
||||
const rawPath = parts.slice(2).join('\t')
|
||||
const binary = addStr === '-' && remStr === '-'
|
||||
const entry: NumstatEntry = {
|
||||
added: binary ? 0 : toCount(addStr),
|
||||
removed: binary ? 0 : toCount(remStr),
|
||||
binary,
|
||||
}
|
||||
const { oldPath, newPath } = expandNumstatPath(rawPath)
|
||||
map.set(newPath, entry)
|
||||
if (oldPath !== newPath) map.set(oldPath, entry)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ── unified diff (pure) ──────────────────────────────────────────────────────
|
||||
|
||||
/** Strip git's `a/`/`b/` prefix and optional C-quoting; `/dev/null` is kept. */
|
||||
function stripDiffPath(raw: string): string {
|
||||
let s = raw.trim()
|
||||
if (s === '/dev/null') return s
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
||||
}
|
||||
if (s.startsWith('a/') || s.startsWith('b/')) s = s.slice(2)
|
||||
return s
|
||||
}
|
||||
|
||||
/** Best-effort path extraction from a `diff --git a/x b/y` header line. The
|
||||
* authoritative paths come from ---/+++/rename lines, which override this. */
|
||||
function parseDiffGitLine(line: string): { oldPath: string; newPath: string } {
|
||||
const rest = line.slice('diff --git '.length)
|
||||
const sep = rest.indexOf(' b/')
|
||||
if (sep !== -1) {
|
||||
return { oldPath: stripDiffPath(rest.slice(0, sep)), newPath: stripDiffPath(rest.slice(sep + 1)) }
|
||||
}
|
||||
const p = stripDiffPath(rest)
|
||||
return { oldPath: p, newPath: p }
|
||||
}
|
||||
|
||||
/** Classify one in-hunk line by its leading marker; unknown → context (spec). */
|
||||
function classifyHunkLine(line: string): DiffLine {
|
||||
const marker = line.charAt(0)
|
||||
if (marker === '+') return { kind: 'added', text: line.slice(1) }
|
||||
if (marker === '-') return { kind: 'removed', text: line.slice(1) }
|
||||
if (marker === ' ') return { kind: 'context', text: line.slice(1) }
|
||||
if (marker === '\\') return { kind: 'meta', text: line.slice(1).trim() }
|
||||
return { kind: 'context', text: line }
|
||||
}
|
||||
|
||||
interface BlockState {
|
||||
oldPath: string
|
||||
newPath: string
|
||||
binary: boolean
|
||||
isNew: boolean
|
||||
isDeleted: boolean
|
||||
isRename: boolean
|
||||
}
|
||||
|
||||
/** Apply one pre-hunk header line to the accumulating block state. */
|
||||
function applyHeaderLine(st: BlockState, line: string): void {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
const p = parseDiffGitLine(line)
|
||||
st.oldPath = p.oldPath
|
||||
st.newPath = p.newPath
|
||||
} else if (line.startsWith('new file')) st.isNew = true
|
||||
else if (line.startsWith('deleted file')) st.isDeleted = true
|
||||
else if (line.startsWith('rename from ')) {
|
||||
st.oldPath = stripDiffPath(line.slice('rename from '.length))
|
||||
st.isRename = true
|
||||
} else if (line.startsWith('rename to ')) {
|
||||
st.newPath = stripDiffPath(line.slice('rename to '.length))
|
||||
st.isRename = true
|
||||
} else if (line.startsWith('copy from ')) st.oldPath = stripDiffPath(line.slice('copy from '.length))
|
||||
else if (line.startsWith('copy to ')) st.newPath = stripDiffPath(line.slice('copy to '.length))
|
||||
else if (line.startsWith('--- ')) applyOldPath(st, line.slice(4))
|
||||
else if (line.startsWith('+++ ')) applyNewPath(st, line.slice(4))
|
||||
else if (line.startsWith('Binary files')) st.binary = true
|
||||
}
|
||||
|
||||
function applyOldPath(st: BlockState, raw: string): void {
|
||||
if (raw.trim() === '/dev/null') st.isNew = true
|
||||
else st.oldPath = stripDiffPath(raw)
|
||||
}
|
||||
|
||||
function applyNewPath(st: BlockState, raw: string): void {
|
||||
if (raw.trim() === '/dev/null') st.isDeleted = true
|
||||
else st.newPath = stripDiffPath(raw)
|
||||
}
|
||||
|
||||
function deriveStatus(st: BlockState, binary: boolean): FileStatus {
|
||||
if (st.isRename) return 'renamed'
|
||||
if (st.isNew) return 'added'
|
||||
if (st.isDeleted) return 'deleted'
|
||||
if (binary) return 'binary'
|
||||
return 'modified'
|
||||
}
|
||||
|
||||
function countKind(hunks: readonly DiffHunk[], kind: DiffLineKind): number {
|
||||
let n = 0
|
||||
for (const h of hunks) for (const l of h.lines) if (l.kind === kind) n += 1
|
||||
return n
|
||||
}
|
||||
|
||||
function finalizeFile(
|
||||
st: BlockState,
|
||||
hunks: DiffHunk[],
|
||||
numstat?: Map<string, NumstatEntry>,
|
||||
): DiffFile {
|
||||
const stat = numstat?.get(st.newPath) ?? numstat?.get(st.oldPath)
|
||||
const binary = st.binary || stat?.binary === true
|
||||
const added = stat?.added ?? countKind(hunks, 'added')
|
||||
const removed = stat?.removed ?? countKind(hunks, 'removed')
|
||||
return {
|
||||
oldPath: st.oldPath,
|
||||
newPath: st.newPath,
|
||||
status: deriveStatus(st, binary),
|
||||
added,
|
||||
removed,
|
||||
binary,
|
||||
hunks,
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse one `diff --git` block (header lines + hunks) into a DiffFile. */
|
||||
function parseFileBlock(block: readonly string[], numstat?: Map<string, NumstatEntry>): DiffFile | null {
|
||||
const st: BlockState = {
|
||||
oldPath: '',
|
||||
newPath: '',
|
||||
binary: false,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
isRename: false,
|
||||
}
|
||||
const hunks: DiffHunk[] = []
|
||||
let current: DiffHunk | null = null
|
||||
for (const line of block) {
|
||||
if (line.startsWith('@@')) {
|
||||
current = { header: line, lines: [] }
|
||||
hunks.push(current)
|
||||
} else if (current === null) {
|
||||
applyHeaderLine(st, line)
|
||||
} else {
|
||||
current.lines.push(classifyHunkLine(line))
|
||||
}
|
||||
}
|
||||
if (st.oldPath === '' && st.newPath === '') return null
|
||||
return finalizeFile(st, hunks, numstat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a full unified `git diff` patch into DiffFile[]. `numstat` (optional)
|
||||
* supplies authoritative +/- counts and binary flags; without it counts are
|
||||
* derived from the hunk bodies. Empty / non-diff input → []; never throws.
|
||||
*/
|
||||
export function parseUnifiedDiff(patch: string, numstat?: Map<string, NumstatEntry>): DiffFile[] {
|
||||
if (typeof patch !== 'string' || patch.length === 0) return []
|
||||
const lines = patch.replace(/\n$/, '').split('\n')
|
||||
const files: DiffFile[] = []
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
if (lines[i]?.startsWith('diff --git ') !== true) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const start = i
|
||||
i += 1
|
||||
while (i < lines.length && lines[i]?.startsWith('diff --git ') !== true) i += 1
|
||||
const file = parseFileBlock(lines.slice(start, i), numstat)
|
||||
if (file !== null) files.push(file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// ── getDiff (git runner) ─────────────────────────────────────────────────────
|
||||
|
||||
/** Just the diff limits getDiff needs; the full Config satisfies this Pick. */
|
||||
export interface GetDiffOptions {
|
||||
staged: boolean
|
||||
cfg: Pick<Config, 'diffTimeoutMs' | 'diffMaxBytes' | 'diffMaxFiles'>
|
||||
}
|
||||
|
||||
interface ExecErrorShape {
|
||||
code?: string
|
||||
stdout?: string
|
||||
}
|
||||
|
||||
function asExecError(err: unknown): ExecErrorShape {
|
||||
if (err === null || typeof err !== 'object') return {}
|
||||
const e = err as { code?: unknown; stdout?: unknown }
|
||||
return {
|
||||
code: typeof e.code === 'string' ? e.code : undefined,
|
||||
stdout: typeof e.stdout === 'string' ? e.stdout : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
interface GitOutput {
|
||||
out: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a read-only git command, capturing stdout. Bounded by timeout + maxBuffer
|
||||
* (DoS guard). On a maxBuffer overflow the partial stdout is returned with
|
||||
* `truncated:true`; any other failure yields empty output (best-effort).
|
||||
*/
|
||||
async function runGit(
|
||||
cwd: string,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
maxBytes: number,
|
||||
): Promise<GitOutput> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: maxBytes,
|
||||
})
|
||||
return { out: stdout, truncated: stdout.length >= maxBytes }
|
||||
} catch (err: unknown) {
|
||||
const { code, stdout } = asExecError(err)
|
||||
if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
||||
return { out: stdout ?? '', truncated: true }
|
||||
}
|
||||
return { out: '', truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal unquote of a C-quoted porcelain path (`"a\"b"` → `a"b`). */
|
||||
function unquotePorcelain(raw: string): string {
|
||||
const s = raw.trim()
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/** Untracked files (`git status --porcelain` `??` rows) as `untracked` DiffFiles.
|
||||
* Content is not diffed (we avoid the `--no-index` two-operand trap, L3). */
|
||||
async function listUntracked(cwd: string, timeoutMs: number, maxBytes: number): Promise<DiffFile[]> {
|
||||
const { out } = await runGit(cwd, ['status', '--porcelain', '--'], timeoutMs, maxBytes)
|
||||
const files: DiffFile[] = []
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line.startsWith('?? ')) continue
|
||||
const p = unquotePorcelain(line.slice(3))
|
||||
if (p === '') continue
|
||||
files.push({
|
||||
oldPath: p,
|
||||
newPath: p,
|
||||
status: 'untracked',
|
||||
added: 0,
|
||||
removed: 0,
|
||||
binary: false,
|
||||
hunks: [],
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a repo's diff (working tree or `--staged`) as structured DiffResult.
|
||||
* `repoPath` must already be a validated absolute git directory (route layer,
|
||||
* SEC-H7). Best-effort: git failures yield an empty result rather than throwing.
|
||||
*/
|
||||
export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise<DiffResult> {
|
||||
const { staged, cfg } = opts
|
||||
const { diffTimeoutMs: timeout, diffMaxBytes: maxBytes, diffMaxFiles } = cfg
|
||||
const stagedArg = staged ? ['--staged'] : []
|
||||
|
||||
const patch = await runGit(repoPath, ['diff', '--no-color', ...stagedArg, '--'], timeout, maxBytes)
|
||||
const num = await runGit(repoPath, ['diff', '--numstat', ...stagedArg, '--'], timeout, maxBytes)
|
||||
|
||||
const files = parseUnifiedDiff(patch.out, parseNumstat(num.out))
|
||||
if (!staged) {
|
||||
files.push(...(await listUntracked(repoPath, timeout, maxBytes)))
|
||||
}
|
||||
|
||||
let truncated = patch.truncated || num.truncated
|
||||
const bounded =
|
||||
files.length > diffMaxFiles ? ((truncated = true), files.slice(0, diffMaxFiles)) : files
|
||||
|
||||
return { files: bounded, staged, truncated }
|
||||
}
|
||||
Reference in New Issue
Block a user