/** * Worktree work-at-risk assessment (split out of `worktrees.ts`, v2.7 audit * reshape). The git-worktree create/diff/remove lifecycle stays in `worktrees.ts`; * this module owns the orthogonal "would deleting this worktree lose work?" gate * the server consults before a session delete, plus the recoverable stash escape. * * Session delete itself lives in apps/server (Docker), which CANNOT see the host * worktree dirs or run git on them — only BooCoder (host systemd) can — so the * server calls the routes that wrap these helpers. Behavior is unchanged from the * original worktrees.ts implementation. */ import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk'; import { hostExec } from './host-exec.js'; /** * Resolve the project's default branch as a git-usable ref (e.g. "origin/main"). * * `refs/remotes/origin/HEAD` lives in the repo's COMMON git dir and is shared * across every linked worktree, so reading it from the session worktree returns * the REMOTE's default branch — never this worktree's own `session-` branch * (that would be `symbolic-ref HEAD`, a different ref). Falls back to probing * common defaults by verified existence when origin/HEAD isn't set (e.g. a repo * that never ran `git remote set-head`). Returns null if none resolve, in which * case the unmerged check is skipped (dirty + unpushed still protect the work). */ async function detectDefaultBranchRef( worktreePath: string, opts?: { signal?: AbortSignal }, ): Promise { const head = await hostExec( `git -C ${shellEscape(worktreePath)} symbolic-ref --short refs/remotes/origin/HEAD`, { signal: opts?.signal, timeoutMs: 10_000 }, ); if (head.exitCode === 0) { const ref = head.stdout.trim(); // e.g. "origin/main" if (ref) { const verify = await hostExec( `git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(ref + '^{commit}')}`, { signal: opts?.signal, timeoutMs: 10_000 }, ); if (verify.exitCode === 0 && verify.stdout.trim()) return ref; } } // origin/HEAD unset or unresolvable — probe common defaults. Prefer the // remote-tracking ref (always resolvable in a fresh worktree) over the local // head, which may not exist if the default branch lives only in the main tree. for (const cand of ['origin/main', 'origin/master', 'main', 'master']) { const verify = await hostExec( `git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(cand + '^{commit}')}`, { signal: opts?.signal, timeoutMs: 10_000 }, ); if (verify.exitCode === 0 && verify.stdout.trim()) return cand; } return null; } /** * Inspect a worktree for work that would be lost if its session were deleted. * Three checks, all via the audited hostExec + shellEscape path (every * interpolated value — paths, refs — is single-quote-escaped; no bare * interpolation). Any unexpected git failure is treated as at-risk, never a * silent pass. */ export async function checkWorktreeWorkAtRisk( worktreePath: string, opts?: { signal?: AbortSignal }, ): Promise { // Branch name — also doubles as the "is this still a git worktree?" probe. const br = await hostExec( `git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`, { signal: opts?.signal, timeoutMs: 10_000 }, ); if (br.exitCode !== 0) { return { worktreePath, branch: '', dirty: false, unpushed: 0, unmerged: 0, atRisk: true, error: `git rev-parse failed: ${br.stderr.trim() || 'not a git worktree'}`, }; } const branch = br.stdout.trim(); // (a) Uncommitted (dirty working tree, including untracked files). const st = await hostExec( `git -C ${shellEscape(worktreePath)} status --porcelain`, { signal: opts?.signal, timeoutMs: 15_000 }, ); if (st.exitCode !== 0) { return { worktreePath, branch, dirty: false, unpushed: 0, unmerged: 0, atRisk: true, error: `git status failed: ${st.stderr.trim()}`, }; } const dirty = st.stdout.trim().length > 0; // (b) Unpushed commits. No upstream configured => work exists only locally; // treat as unpushed-by-definition (-1) rather than an error. const up = await hostExec( `git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape('@{u}..HEAD')}`, { signal: opts?.signal, timeoutMs: 15_000 }, ); const unpushed = up.exitCode === 0 ? (parseInt(up.stdout.trim() || '0', 10) || 0) : -1; // (c) Unmerged commits — on this branch but not in the project default branch. const defaultRef = await detectDefaultBranchRef(worktreePath, opts); let unmerged = 0; if (defaultRef) { const rl = await hostExec( `git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape(defaultRef + '..HEAD')}`, { signal: opts?.signal, timeoutMs: 15_000 }, ); if (rl.exitCode === 0) unmerged = parseInt(rl.stdout.trim() || '0', 10) || 0; } // unpushed only contributes when an upstream actually exists. Session branches // (session-) never have one (unpushed === -1), and any real local-only work // there already surfaces as unmerged > 0 — so the no-upstream case adds no // protection, only friction (it flagged every pristine worktree-backed session). // The unpushed > 0 arm stays forward-compatible with P1.5 pushable branches. const hasUpstream = unpushed !== -1; const atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0); return { worktreePath, branch, dirty, unpushed, unmerged, atRisk }; } /** * Stash a worktree's uncommitted changes (including untracked, via -u) so the * working tree is clean. Stash entries live in the repo's common git dir, so * they survive worktree-dir removal — this is the recoverable, safe-by-default * escape. Note it only clears the *dirty* risk; unpushed/unmerged commits * remain on the branch, so a re-attempted delete may still block on those. */ export async function stashWorktree( worktreePath: string, opts?: { signal?: AbortSignal }, ): Promise<{ stashed: boolean; error?: string }> { const r = await hostExec( `git -C ${shellEscape(worktreePath)} stash push -u -m ${shellEscape('boocode: pre-delete stash')}`, { signal: opts?.signal, timeoutMs: 30_000 }, ); if (r.exitCode !== 0) { return { stashed: false, error: r.stderr.trim() || r.stdout.trim() }; } // "No local changes to save" => exit 0, nothing stashed — not an error. const stashed = !/no local changes to save/i.test(r.stdout); return { stashed }; } /** Minimal shell escape for paths (single-quote wrapping). */ function shellEscape(s: string): string { // Replace single quotes with escaped version, wrap in single quotes return "'" + s.replace(/'/g, "'\\''") + "'"; }