UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

278 lines 12.9 kB
import { join } from 'node:path'; import { nodeGitRunner } from '../project.js'; import { FRAMEWORK_DIR, BRANCHES_DIR, isSafeAgentId } from './agent-store.js'; import { worktreeDirName } from '../branch-names.js'; /** * Git-worktree lifecycle for concurrent agents (#453/#735): give each agent its own * checkout so N runs on one repo never fight over the working tree. Pure plumbing * over the existing {@link GitRunner} seam; no daemon wiring, no concurrency, no * dashboard changes (those are the sibling #453 slices). This module only knows * how to add, list, remove, and prune worktrees. */ /** The path an agent's worktree gets (#1580): `<repo>/.the-framework/branches/<run branch name>`. */ export function worktreePath(repo, agentId) { return join(repo, FRAMEWORK_DIR, BRANCHES_DIR, worktreeDirName(agentId)); } export { AGENT_BRANCH_PREFIX, LEGACY_AGENT_BRANCH_PREFIX, agentBranchName, legacyAgentBranchName, } from '../branch-names.js'; /** * Create a worktree for an agent on a fresh branch: `git worktree add -b <branch> * <path> [base]`. Git makes the leaf dir (and any missing parents) itself. The * `agentId` is validated as path-safe first so a caller can never traverse out of * `.the-framework/branches/`. Rejects on any git failure (a caller that wants a * run needs its checkout, so failure must surface, not be swallowed). */ export async function addWorktree(repo, opts, agent = nodeGitRunner()) { if (!isSafeAgentId(opts.agentId)) throw new Error(`unsafe run id: ${opts.agentId}`); const path = worktreePath(repo, opts.agentId); await agent(['worktree', 'add', '-b', opts.branch, path, ...(opts.base ? [opts.base] : [])], repo); return { path, branch: opts.branch }; } /** * Check an *existing* branch out into an agent's worktree (#762): `git worktree add <path> <branch>`, * no `-b`. Continuing an agent puts it back on the branch its work is already on, rather than * branching again from HEAD and stranding what it did last time. * * Rejects on git failure, like {@link addWorktree}: a continued agent needs its checkout. */ export async function attachWorktree(repo, opts, agent = nodeGitRunner()) { if (!isSafeAgentId(opts.agentId)) throw new Error(`unsafe run id: ${opts.agentId}`); const path = worktreePath(repo, opts.agentId); await agent(['worktree', 'add', path, opts.branch], repo); return { path, branch: opts.branch }; } /** * Every worktree registered for the repo (the main checkout included). Forgiving: * a non-repo / git failure yields `[]` so a reconcile scan never throws. */ export async function listWorktrees(repo, agent = nodeGitRunner()) { try { return parseWorktreeList(await agent(['worktree', 'list', '--porcelain'], repo)); } catch { return []; } } /** * Parse `git worktree list --porcelain`: blank-line-separated records, each with * a `worktree <path>` line, a `HEAD <sha>` line, and either `branch refs/heads/...` * or `detached`. Extra attributes (bare/locked/prunable) are ignored. Exported so * the parsing is unit-testable without a real repo. */ export function parseWorktreeList(porcelain) { const entries = []; for (const block of porcelain.split(/\n\s*\n/)) { let path; let head = ''; let branch; for (const line of block.split('\n')) { if (line.startsWith('worktree ')) path = line.slice('worktree '.length).trim(); else if (line.startsWith('HEAD ')) head = line.slice('HEAD '.length).trim(); else if (line.startsWith('branch ')) branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, ''); } if (path) entries.push({ path, head, ...(branch ? { branch } : {}) }); } return entries; } /** * Commit whatever the agent left behind, on the agent's own branch (#786). * * An agent that edits and stops without committing is behaving as instructed: the * system prompt has it commit *pre-existing* changes before it starts, never its own * work at the end. Removing that checkout would destroy the diff (the work was never * staged, so it is not recoverable from git afterwards), so teardown commits it first * and the branch outlives the worktree. * * Returns whether the checkout is safe to remove: true when it was already clean or * the work is now committed, false when the commit failed (no git identity, a hook * refusing it). False means keep the checkout, which is the safe direction. * * Retries before giving up (#1376): the daemon's conversation committer works in the same * checkout and is busiest exactly when this runs (session end), so a first attempt can lose * an `index.lock` race. That transient loss is how a session's real work got judged * "committed nothing" by the handoff while the teardown's identical commit, seconds later, * succeeded. A short wait outlasts the committer's hold; a persistent failure (identity, * hooks) still comes back false. */ export async function commitPendingWork(path, agent = nodeGitRunner(), retry = {}) { const attempts = Math.max(1, retry.attempts ?? 3); const delayMs = retry.delayMs ?? 300; for (let attempt = 1; attempt <= attempts; attempt++) { try { const status = await agent(['status', '--porcelain'], path); if (!status.trim()) return true; await agent(['add', '-A'], path); // Same wording as the install-time safety commit (install.ts), for one vocabulary. await agent(['commit', '-m', '[The Framework] uncommitted changes'], path); return true; } catch { if (attempt < attempts) await new Promise(resolve => setTimeout(resolve, delayMs)); } } return false; } /** * Remove an agent's worktree. Tolerant of an already-gone / never-registered path so * teardown stays idempotent (the agent child is detached; the daemon only holds its pid). * * Plain removal first: it refuses a checkout git considers unclean, which after * {@link commitPendingWork} means a state we did not anticipate. Falling back to * `--force` keeps teardown working (an ignored build artifact must not strand a * worktree forever), but it says so, because forcing past unknown state is exactly * how uncommitted work got deleted in the first place. */ export async function removeWorktree(repo, path, agent = nodeGitRunner()) { try { await agent(['worktree', 'remove', path], repo); return; } catch { // Unclean by git's reckoning, already removed, or never registered: try forcing. } try { await agent(['worktree', 'remove', '--force', path], repo); console.log(`[framework] forced removal of worktree ${path} (git called it unclean)`); } catch { // Already removed, or never registered: nothing to do. } } /** * The branch checked out at `path`, or `undefined` when detached / not a repo. * Forgiving, like {@link listWorktrees}: callers use it to decide, not to fail. */ export async function currentBranch(path, agent = nodeGitRunner()) { try { const name = (await agent(['rev-parse', '--abbrev-ref', 'HEAD'], path)).trim(); return name && name !== 'HEAD' ? name : undefined; } catch { return undefined; } } /** * Rename an agent's branch once the agent names the session (#736): the worktree is * created on `tf-agent-<agentId>` before a name exists, and this puts the * readable `tf-<sessionName>` on it. * * Only renames when `path` is still on `from`. The #326 system prompt currently * tells the agent to create and check out its own `tf-<name>` branch, * and until that step is dropped there (the prompt ships verbatim from the issue, * so it is not ours to edit) the agent may already have moved off `from` — in * which case it named the branch itself and there is nothing to rename. Returns * whether it renamed, and never throws: an agent must not die over a branch name. */ export async function renameAgentBranch(path, from, to, agent = nodeGitRunner()) { if ((await currentBranch(path, agent)) !== from) return false; try { await agent(['branch', '-m', from, to], path); return true; } catch { // Target name taken, or an invalid slug: keep the run-id branch. return false; } } /** * `git worktree prune`: drop administrative entries for worktree dirs a crash left * behind. Never removes a live worktree, so it is always safe. Forgiving. */ export async function pruneWorktrees(repo, agent = nodeGitRunner()) { try { await agent(['worktree', 'prune'], repo); } catch { // Not a repo / nothing to prune: no-op. } } /** A {@link SizeRunner} over `du -sk`: one process, and it does not follow the symlinked deps (#736). */ export function nodeSizeRunner() { return path => new Promise((resolvePromise, rejectPromise) => { void import('node:child_process').then(({ execFile }) => { execFile('du', ['-sk', path], { timeout: 5_000 }, (err, stdout) => err ? rejectPromise(err) : resolvePromise(stdout)); }); }); } /** * A worktree's size on disk in bytes, or undefined when it cannot be read (#798). Best-effort by * design: this only ever labels a "remove this" button, so a missing number costs nothing while a * throw or a hang would cost the panel it sits in. `du` is absent on Windows, which reads as * unknown like any other failure. */ export async function worktreeSize(path, agent = nodeSizeRunner()) { try { const kb = Number.parseInt((await agent(path)).trim().split(/\s+/)[0] ?? '', 10); return Number.isFinite(kb) ? kb * 1024 : undefined; } catch { return undefined; } } /** * Whether a branch is on the remote, with the local tip already there (E5). * * The one predicate the whole retention story is built on: nothing local is ever the last copy of * work, so anything the remote has may be deleted and anything it does not have stays. It replaced * three interacting rules — a clean finish removes the checkout, a failure keeps it, a merged * branch reclaims it later — each of which asked *what state did this session end in* rather than * *is this recoverable*. * * `git rev-parse` of the remote-tracking ref, then a merge-base check: the ref existing is not * enough, because a branch pushed and then committed to again has a tip the remote has never seen. * Reads only local refs (no fetch), so it is cheap enough to ask on every teardown — the remote ref * is written by the push this is checking for, which is what makes that sound. * * Anything unreadable answers `false`. A repo with no remote configured therefore keeps every * checkout, which is the honest outcome: there is nowhere for the work to be recoverable from. */ export async function branchPushed(repo, branch, agent = nodeGitRunner()) { try { const local = (await agent(['rev-parse', '--verify', `refs/heads/${branch}`], repo)).trim(); const remote = (await agent(['rev-parse', '--verify', `refs/remotes/origin/${branch}`], repo)).trim(); if (!local || !remote) return false; if (local === remote) return true; // The remote may be ahead (someone pushed on top): what matters is that our tip is in it. await agent(['merge-base', '--is-ancestor', local, remote], repo); return true; } catch { return false; } } /** * Whether the checkout has nothing uncommitted — the read half of {@link commitPendingWork}, for * a decision that must not commit on the way to its answer: removing a publish-nothing session's * checkout requires a clean tree, and grabbing someone's half-typed edits as a commit to find * that out would be the intrusion the question exists to avoid. Throws when git cannot answer, * so the caller keeps the checkout rather than guessing. */ export async function worktreeClean(path, agent = nodeGitRunner()) { return !(await agent(['status', '--porcelain'], path)).trim(); } /** * Whether the repo has any remote configured at all. What the sweep asks once per project: with * no remote, {@link branchPushed} is false for every checkout and the push cannot land, so the * whole per-checkout probe-and-push cycle is doomed before it starts — and that answer cannot * change between two rows of the same sweep. Anything unreadable answers `false`, like * {@link branchPushed}: keeping a checkout is the safe direction. */ export async function repoHasRemote(repo, agent = nodeGitRunner()) { try { return (await agent(['remote'], repo)).trim().length > 0; } catch { return false; } } //# sourceMappingURL=worktree.js.map