worktree-risk.ts now returns the package's WorktreeRiskReport (local RiskReport interface removed); frame-emitter.ts imports WsFrame from @boocode/contracts/ws-frames (the deleted @boocode/server/ws-frames subpath). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
6.7 KiB
TypeScript
161 lines
6.7 KiB
TypeScript
/**
|
|
* 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-<id>` 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<string | null> {
|
|
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<WorktreeRiskReport> {
|
|
// 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-<id>) 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, "'\\''") + "'";
|
|
}
|