diff --git a/packages/agent-driver/src/actions-zip.SPEC.md b/packages/agent-driver/src/actions-zip.SPEC.md index 3e4da0a5..09256f85 100644 --- a/packages/agent-driver/src/actions-zip.SPEC.md +++ b/packages/agent-driver/src/actions-zip.SPEC.md @@ -1,4 +1,4 @@ -Reads the files out of a zip archive, so an agent that ran on the `actions` run target can have its transcript recovered: GitHub only hands back a workflow run's uploaded artifact as a zip, and that download is the sole way to read what happened on a GitHub Actions runner. Only the shapes GitHub's artifact upload produces are supported — stored or deflated files, no encryption. Anything unrecognized is reported as a failure instead of yielding a partial archive, because a silently truncated transcript would read as an agent that said less than it actually did. +Reads the files out of a zip archive, so an agent that ran under the GitHub Actions driver can have its transcript recovered: GitHub only hands back a workflow run's uploaded artifact as a zip, and that download is the sole way to read what happened on a GitHub Actions runner. Only the shapes GitHub's artifact upload produces are supported — stored or deflated files, no encryption. Anything unrecognized is reported as a failure instead of yielding a partial archive, because a silently truncated transcript would read as an agent that said less than it actually did. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/actions.SPEC.md b/packages/agent-driver/src/actions.SPEC.md index bc289909..92bd13a7 100644 --- a/packages/agent-driver/src/actions.SPEC.md +++ b/packages/agent-driver/src/actions.SPEC.md @@ -1,4 +1,4 @@ -The driver for the `actions` run target: instead of running the coding-agent CLI on this device, it hands each turn to a GitHub Actions runner — dispatch the project's agent workflow, wait for the run to finish, then read back the transcript the run uploaded. To the caller this looks exactly like any other driver; only the tempo differs. +The GitHub Actions driver (`github-actions`): instead of running the coding-agent CLI on this device, it hands each turn to a GitHub Actions runner — dispatch the project's agent workflow, wait for the run to finish, then read back the transcript the run uploaded. To the caller this looks exactly like any other driver; only the tempo differs. ## User story @@ -60,7 +60,7 @@ Dispatching a workflow returns no identifier for the run it creates. Each turn t #### User story -The caller's UI shows an agent's tool calls and messages as an event stream, the same way for every run target. +The caller's UI shows an agent's tool calls and messages as an event stream, the same way for every driver. #### Business logic diff --git a/packages/agent-driver/src/actions.test.SPEC.md b/packages/agent-driver/src/actions.test.SPEC.md index 5bc71ae5..00b4d41e 100644 --- a/packages/agent-driver/src/actions.test.SPEC.md +++ b/packages/agent-driver/src/actions.test.SPEC.md @@ -1,4 +1,4 @@ -What the tests cover: an agent on the `actions` run target dispatches its workflow, keeps polling while the run is queued and in progress, and turns the run's uploaded transcript into a finished turn with the agent's final message, its session id, and its usage figures. +What the tests cover: an agent under the GitHub Actions driver dispatches its workflow, keeps polling while the run is queued and in progress, and turns the run's uploaded transcript into a finished turn with the agent's final message, its session id, and its usage figures. - The prompt is dispatched with the system prompt framing in front of the task text, plus a per-turn correlation id that is the only way back to a run GitHub reports no identifier for; two agents never share a correlation prefix, so a freshly started process cannot latch onto another agent's workflow run. - Each run is asked to push to the same branch for the whole agent, and every turn after the first is dispatched from the branch the previous run pushed, so later work builds on earlier work. diff --git a/packages/agent-driver/src/index.SPEC.md b/packages/agent-driver/src/index.SPEC.md index 3fb0be07..229aa65f 100644 --- a/packages/agent-driver/src/index.SPEC.md +++ b/packages/agent-driver/src/index.SPEC.md @@ -1,4 +1,4 @@ -The package's main entry point: the driver contract itself and the set of implementation ids, the three drivers that satisfy it (Claude Code, Codex, the `actions` run target), the fake driver, reading where the account's quota stands, the process engine a further CLI-backed driver would be built on, and the event-stream helper a driver implemented outside the package builds its events with. No business logic of its own. The Actions run target's internal archive reader is deliberately not offered, since nothing outside that driver uses it. +The package's main entry point: the driver contract itself and the set of implementation ids, the three drivers that satisfy it (Claude Code, Codex, GitHub Actions), the fake driver, reading where the account's quota stands, the process engine a further CLI-backed driver would be built on, and the event-stream helper a driver implemented outside the package builds its events with. No business logic of its own. The GitHub Actions driver's internal archive reader is deliberately not offered, since nothing outside that driver uses it. ## Before modifying/creating SPEC.md files diff --git a/packages/agent-driver/src/types.SPEC.md b/packages/agent-driver/src/types.SPEC.md index a65314ac..19b03d98 100644 --- a/packages/agent-driver/src/types.SPEC.md +++ b/packages/agent-driver/src/types.SPEC.md @@ -13,7 +13,7 @@ The user picks which coding agent does the work — Claude Code or Codex — and ## Business logic — TL;DR - **The CLI is a black box** - the caller prompts it, lets its own loop run to completion, and then reads the code it produced. It never gates on which tool the agent reached for. -- **Every pass is a fresh prompt** - review, security, QA, UX: each is its own prompt with its own fresh context, and a role is framing added to the prompt rather than a separate kind of agent. +- **Every prompt is a fresh context** - each prompt the caller sends is its own turn with its own fresh context, and a role is framing added to the prompt rather than a separate kind of agent. - **Capabilities are optional, never faked** - reading the account's quota, reading a file from the agent's workspace, and continuing a previous conversation are each offered only by drivers that can genuinely do them; a driver that cannot simply does not, and the product copes. - **Events are for looking, not for deciding** - everything the agent streams reaches the caller's UI for visibility, and no control flow ever branches on it. - **Spend, traffic light, and proportion are three different things** - what an agent spent, whether the account may still spend, and how much of the allowance is gone are kept strictly apart and never substituted for one another. @@ -36,17 +36,17 @@ The contract is deliberately tiny: start an agent bound to a checkout, prompt it #### Rationale -Two identities are kept apart. The **driver** is the user's choice — Claude Code or Codex. Each driver has a separate implementation for each place it can run, since running on a GitHub Actions runner or in a cloud session is a different mechanism from running on this device, and the run target is a fact about *where*, not about *which agent*. The contract fixes the set of implementation ids (`claude-code`, `codex`, `claude-web`, `github-actions`, `fake`), because an agent's record names the implementation that ran it and every reader of that record must agree on the vocabulary; mapping an id back to the user's choice is the caller's business, not the package's. +Two identities are kept apart. The **driver** is the user's choice — Claude Code or Codex. Each driver has a separate implementation for each place it can run, since running on a GitHub Actions runner or in a cloud session is a different mechanism from running on this device, and where it runs is a fact about *where*, not about *which agent*. The contract fixes the set of implementation ids (`claude-code`, `codex`, `claude-web`, `github-actions`, `fake`), because an agent's record names the implementation that ran it and every reader of that record must agree on the vocabulary; mapping an id back to the user's choice is the caller's business, not the package's. -### Every pass is a fresh prompt +### Every prompt is a fresh context #### User story -An agent plans, then builds, then reviews its own work for quality, security, and user experience — and each of those passes should think about the task with a clear head rather than dragging the previous pass's context along. +The caller sends an agent several prompts in a row — a plan, then the build, then a review of its own work — and wants each to think about the task with a clear head rather than dragging the previous prompt's context along. #### Business logic -Each pass is its own prompt, and a prompt is the unit of fresh context. Everything runs through the driver — there is no second execution path — and a role is delivered as framing prepended to the prompt rather than as a separate kind of agent. Framing exists at two levels: framing that applies to every prompt of an agent, and framing added for one prompt only. +A prompt is the unit of fresh context. Everything runs through the driver — there is no second execution path — and a role is delivered as framing prepended to the prompt rather than as a separate kind of agent. Framing exists at two levels: framing that applies to every prompt of an agent, and framing added for one prompt only. The one deliberate exception is continuing a conversation: a prompt may ask to pick up the agent's previous turn instead of starting fresh, which is how a live chat message lands in the ongoing conversation with its full history, and how a finished agent is revived from the caller's UI with everything it already knew. diff --git a/packages/skill-branches/SKILL.SPEC.md b/packages/skill-branches/SKILL.SPEC.md index 814a52b6..d2922904 100644 --- a/packages/skill-branches/SKILL.SPEC.md +++ b/packages/skill-branches/SKILL.SPEC.md @@ -1,8 +1,8 @@ -The skill's instructions: what an agent is told about living inside its own checkout, under the package's conventions. Given to every agent a caller starts in a checkout it created, and installable by a skills catalogue as `skills/branches.md`. +The skill's instructions: what an agent is told about living inside its own checkout, under the package's conventions. Found by every agent a caller starts in a checkout the package created, as a skill of its harness (`src/skill-links`). ## User story -- The user starts an agent from the dashboard and expects its work on a branch named after what it did, reviewable as a pull request, with nothing touched in the user's own checkout. +- The user starts an agent from the caller's UI and expects its work on a branch named after what it did, reviewable as a pull request, with nothing touched in the user's own checkout. ## Business logic — TL;DR diff --git a/packages/skill-branches/src/cli.test.SPEC.md b/packages/skill-branches/src/cli.test.SPEC.md index 3938f23e..b0ab10a6 100644 --- a/packages/skill-branches/src/cli.test.SPEC.md +++ b/packages/skill-branches/src/cli.test.SPEC.md @@ -2,7 +2,7 @@ What the tests cover: every command of the command line against real git, and th - **`create`** - makes the checkout on `agent-`, links the parent's dependency directory in, hides `.branches/` from the project's git through the exclude file; works from inside another checkout; `--base` puts the branch on the stated commit. - **Ids** - an id that could escape the branches directory is refused by `create` and `remove` with nothing created. -- **`name`** - renames from a subdirectory of the checkout, leaves no second branch, moves the `branches/` link to the new name without moving the checkout; the same name again is a no-op and another name renames again, dropping the old link; a name taken locally or only on the remote gets a numeric suffix that the caller reads back; a name outside `[a-z0-9-]+` is refused with the branch untouched; the project's main checkout is refused and keeps its branch. +- **`name`** - renames from a subdirectory of the checkout, leaves no second branch, moves the `.branches/` link to the new name without moving the checkout; the same name again is a no-op and another name renames again, dropping the old link; a name taken locally or only on the remote gets a numeric suffix that the caller reads back; a name outside `[a-z0-9-]+` is refused with the branch untouched; the project's main checkout is refused and keeps its branch. - **`status`** - reads dirty before the agent commits, clean after, on the remote after a push; accepts an absolute or a relative path; refuses a leftover directory that is not a checkout. - **`list`** - empty for a project with no checkouts; each checkout with the branch it is on now, renamed or not; a number per checkout with `--sizes`. - **`remove`** - a dirty checkout is kept with the reason on stderr; a committed one is pushed, removed, and its link dropped; a second removal reports no checkout; with `--no-push` an unpushed checkout is kept and nothing reaches the remote, until someone pushes it by hand. @@ -13,7 +13,7 @@ What the tests cover: every command of the command line against real git, and th - **A git failure** - reported with git's own line, exit code 1. - **The executable** - runs by name from the exported bin directory: JSON on stdout, the reason on stderr, the exit codes 0, 1 and 2. - **Naming again** - asking again for the name the checkout already carries changes nothing, whether it got the plain name or a suffixed one, and whether or not its own branch has been pushed. -- **Reserved names** - `data` and any `agent-…` name are refused, the branch stays, and no phantom checkout appears in the listing. +- **A name spelled like a checkout directory** - `agent-` as a session name is a name like any other: the branch becomes `agent-agent-`, its link follows, and the link is never listed as a checkout. - **The project from its layout** - a project that is itself a linked worktree gets its checkouts under its own directory, lists them from inside one of them, and its links follow a rename; the main checkout it was made from sees none of them. - **Before the project is looked for** - a bad id is refused even outside a repository; `status ` on a directory outside a repository answers "not a checkout"; `attach` outside a repository is refused as such. - **Not commands** - names of built-in object properties are not commands. diff --git a/packages/skill-branches/src/git.ts b/packages/skill-branches/src/git.ts index d27e333b..8b425ee4 100644 --- a/packages/skill-branches/src/git.ts +++ b/packages/skill-branches/src/git.ts @@ -144,15 +144,15 @@ export function nodeGitRunner(): GitRunner { * Forgiving in one direction only: an unreadable / missing git reads as "no repo", which is the * conservative answer for the caller that treats a repo's failure as fatal. */ -export async function isGitRepo(cwd: string, agent: GitRunner = nodeGitRunner()): Promise { - return agent(['rev-parse', '--is-inside-work-tree'], cwd) +export async function isGitRepo(cwd: string, git: GitRunner = nodeGitRunner()): Promise { + return git(['rev-parse', '--is-inside-work-tree'], cwd) .then(out => out.trim() === 'true') .catch(() => false) } /** The root of the checkout `cwd` is in — an agent's own, from anywhere under it. Rejects outside a repo. */ -export async function checkoutRoot(cwd: string, agent: GitRunner = nodeGitRunner()): Promise { - return (await agent(['rev-parse', '--show-toplevel'], cwd)).trim() +export async function checkoutRoot(cwd: string, git: GitRunner = nodeGitRunner()): Promise { + return (await git(['rev-parse', '--show-toplevel'], cwd)).trim() } /** diff --git a/packages/skill-branches/src/index.SPEC.md b/packages/skill-branches/src/index.SPEC.md index 3b3f5801..79a574b9 100644 --- a/packages/skill-branches/src/index.SPEC.md +++ b/packages/skill-branches/src/index.SPEC.md @@ -1,4 +1,4 @@ -The package's main entry point: it gathers the git runner, the naming and layout conventions, the checkout lifecycle, the checkout-as-an-agent-gets-it sequence, the dependency linking, the branch-name links, the reclaim rule, the command line and the executable's directory into one place for a caller to import. No business logic of its own. The naming conventions are also reachable on their own (`branch-names`), for code that runs in a browser and must not pull in git; and the skill's text (`SKILL.md`) is exported for a caller that puts it in an agent's prompt. +The package's main entry point: it gathers the git runner, the naming and layout conventions, the checkout lifecycle, the checkout-as-an-agent-gets-it sequence, the dependency linking, the branch-name links, the skill links, the reclaim rule, the command line and the executable's directory into one place for a caller to import. No business logic of its own. The naming conventions are also reachable on their own (`branch-names`), for code that runs in a browser and must not pull in git. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-branches/src/worktree.test.ts b/packages/skill-branches/src/worktree.test.ts index b6607a25..3d852df1 100644 --- a/packages/skill-branches/src/worktree.test.ts +++ b/packages/skill-branches/src/worktree.test.ts @@ -56,9 +56,9 @@ test('addWorktree appends the base ref when given', async () => { assert.deepEqual(git.calls[0]?.args, ['worktree', 'add', '-b', 'b', worktreePath(REPO, 'run1'), 'origin/main']) }) -test('addWorktree rejects an unsafe run id before touching git (no traversal out of worktrees/)', async () => { +test('addWorktree rejects an unsafe agent id before touching git (no traversal out of .branches/)', async () => { const git = recordingGit() - await assert.rejects(() => addWorktree(REPO, { agentId: '../evil', branch: 'b' }, git), /unsafe run id/) + await assert.rejects(() => addWorktree(REPO, { agentId: '../evil', branch: 'b' }, git), /unsafe agent id/) assert.equal(git.calls.length, 0) }) @@ -251,7 +251,7 @@ test('a run worktree reads dirty until the agent commits; the branch then outliv } }) -test('agentBranchName names the branch after the run id (#736)', () => { +test('agentBranchName names the branch after the agent id (#736)', () => { assert.equal(agentBranchName('2026-07-19T10-00-00-000Z'), 'agent-2026-07-19T10-00-00-000Z') }) diff --git a/packages/skill-branches/src/worktree.ts b/packages/skill-branches/src/worktree.ts index 43bde355..84898041 100644 --- a/packages/skill-branches/src/worktree.ts +++ b/packages/skill-branches/src/worktree.ts @@ -5,10 +5,9 @@ import { BRANCHES_DIR, AGENT_BRANCH_PREFIX, isSafeAgentId, isAgentBranch, agentB /** * 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. + * checkout so N agents on one repo never fight over the working tree. Pure plumbing + * over the {@link GitRunner} seam: this module only knows how to add, list, name, + * remove, and prune worktrees, and to read what a retention decision needs. */ /** The path an agent's worktree gets (#1580): `/.branches/`. */ @@ -92,11 +91,11 @@ export interface AddedWorktree { export async function addWorktree( repo: string, opts: AddWorktreeOptions, - agent: GitRunner = nodeGitRunner(), + git: GitRunner = nodeGitRunner(), ): Promise { - if (!isSafeAgentId(opts.agentId)) throw new Error(`unsafe run id: ${opts.agentId}`) + if (!isSafeAgentId(opts.agentId)) throw new Error(`unsafe agent id: ${opts.agentId}`) const path = worktreePath(repo, opts.agentId) - await agent(['worktree', 'add', '-b', opts.branch, path, ...(opts.base ? [opts.base] : [])], repo) + await git(['worktree', 'add', '-b', opts.branch, path, ...(opts.base ? [opts.base] : [])], repo) return { path, branch: opts.branch } } @@ -113,21 +112,21 @@ export async function addWorktree( export async function attachWorktree( repo: string, opts: { agentId: string; branch: string }, - agent: GitRunner = nodeGitRunner(), + git: GitRunner = nodeGitRunner(), ): Promise { - if (!isSafeAgentId(opts.agentId)) throw new Error(`unsafe run id: ${opts.agentId}`) + if (!isSafeAgentId(opts.agentId)) throw new Error(`unsafe agent id: ${opts.agentId}`) const path = worktreePath(repo, opts.agentId) try { - await agent(['worktree', 'add', path, opts.branch], repo) + await git(['worktree', 'add', path, opts.branch], repo) } catch (err) { // `worktree add ` also resolves a remote-only `origin/`, so the existence // check comes after the attempt, not before it. - const exists = await agent(['show-ref', '--verify', '--quiet', `refs/heads/${opts.branch}`], repo).then( + const exists = await git(['show-ref', '--verify', '--quiet', `refs/heads/${opts.branch}`], repo).then( () => true, () => false, ) if (exists) throw err - await agent(['worktree', 'add', '-b', opts.branch, path], repo) + await git(['worktree', 'add', '-b', opts.branch, path], repo) } return { path, branch: opts.branch } } @@ -136,9 +135,9 @@ export async function attachWorktree( * 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: string, agent: GitRunner = nodeGitRunner()): Promise { +export async function listWorktrees(repo: string, git: GitRunner = nodeGitRunner()): Promise { try { - return parseWorktreeList(await agent(['worktree', 'list', '--porcelain'], repo)) + return parseWorktreeList(await git(['worktree', 'list', '--porcelain'], repo)) } catch { return [] } @@ -176,15 +175,15 @@ export function parseWorktreeList(porcelain: string): WorktreeInfo[] { * 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: string, path: string, agent: GitRunner = nodeGitRunner()): Promise { +export async function removeWorktree(repo: string, path: string, git: GitRunner = nodeGitRunner()): Promise { try { - await agent(['worktree', 'remove', path], repo) + await git(['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) + await git(['worktree', 'remove', '--force', path], repo) console.log(`[branches] forced removal of worktree ${path} (git called it unclean)`) } catch { // Already removed, or never registered: nothing to do. @@ -197,8 +196,8 @@ export async function removeWorktree(repo: string, path: string, agent: GitRunne * Forgiving: the checkout is already gone by the time this runs, and a branch that would not * delete is a leftover name, not lost work. */ -export async function deleteBranch(repo: string, branch: string, agent: GitRunner = nodeGitRunner()): Promise { - await agent(['branch', '-D', branch], repo).catch(() => undefined) +export async function deleteBranch(repo: string, branch: string, git: GitRunner = nodeGitRunner()): Promise { + await git(['branch', '-D', branch], repo).catch(() => undefined) } /** @@ -210,9 +209,9 @@ export async function deleteBranch(repo: string, branch: string, agent: GitRunne * branch. The one question that tells the two apart is whether git's top level is this very * directory. False on any failure, and the caller leaves the directory alone. */ -export async function isWorktreeRoot(path: string, agent: GitRunner = nodeGitRunner()): Promise { +export async function isWorktreeRoot(path: string, git: GitRunner = nodeGitRunner()): Promise { try { - const top = (await agent(['rev-parse', '--show-toplevel'], path)).trim() + const top = (await git(['rev-parse', '--show-toplevel'], path)).trim() if (!top) return false // Both sides resolved: macOS's tmpdir sits behind the /var -> /private/var link, and git // reports the resolved path. @@ -227,17 +226,17 @@ export async function isWorktreeRoot(path: string, agent: GitRunner = nodeGitRun * the read every consumer of a `.branches/` directory wants, so none of them can take the * enclosing repo's branch for the run's. */ -export async function worktreeBranch(path: string, agent: GitRunner = nodeGitRunner()): Promise { - return (await isWorktreeRoot(path, agent)) ? currentBranch(path, agent) : undefined +export async function worktreeBranch(path: string, git: GitRunner = nodeGitRunner()): Promise { + return (await isWorktreeRoot(path, git)) ? currentBranch(path, git) : undefined } /** * 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: string, agent: GitRunner = nodeGitRunner()): Promise { +export async function currentBranch(path: string, git: GitRunner = nodeGitRunner()): Promise { try { - const name = (await agent(['rev-parse', '--abbrev-ref', 'HEAD'], path)).trim() + const name = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], path)).trim() return name && name !== 'HEAD' ? name : undefined } catch { return undefined @@ -251,13 +250,13 @@ export async function currentBranch(path: string, agent: GitRunner = nodeGitRunn * from git's common dir, so a project that is itself a linked worktree, or a submodule, answers * with the directory the caller registered. Rejects outside a repo. */ -export async function projectRoot(cwd: string, agent: GitRunner = nodeGitRunner()): Promise { - const checkout = await checkoutRoot(cwd, agent) +export async function projectRoot(cwd: string, git: GitRunner = nodeGitRunner()): Promise { + const checkout = await checkoutRoot(cwd, git) const parent = dirname(checkout) return basename(parent) === BRANCHES_DIR ? dirname(parent) : checkout } -/** A session name as the agent picks it: the charset the system prompt asks for. */ +/** A session name as the agent picks it: the charset the skill asks for. */ export function isSessionName(name: string): boolean { return /^[a-z0-9-]+$/.test(name) } @@ -298,21 +297,21 @@ const NAME_ATTEMPTS = 3 * same moment race on the rename itself; the loser reads the branches again and takes the next * free suffix. */ -export async function nameBranch(path: string, name: string, agent: GitRunner = nodeGitRunner()): Promise { +export async function nameBranch(path: string, name: string, git: GitRunner = nodeGitRunner()): Promise { if (!isSessionName(name)) return { ok: false, reason: 'invalid-name' } const wanted = `${AGENT_BRANCH_PREFIX}${name}` - if (!(await isWorktreeRoot(path, agent))) return { ok: false, reason: 'not-a-worktree' } - const current = await currentBranch(path, agent) + if (!(await isWorktreeRoot(path, git))) return { ok: false, reason: 'not-a-worktree' } + const current = await currentBranch(path, git) if (!current) return { ok: false, reason: 'no-branch' } if (!isAgentBranch(current)) return { ok: false, reason: 'not-an-agent-branch' } for (let attempt = 1; ; attempt++) { - const taken = await branchNames(path, agent) + const taken = await branchNames(path, git) taken.delete(current) let branch = wanted for (let n = 2; taken.has(branch); n++) branch = `${wanted}-${n}` if (branch === current) return { ok: true, branch } try { - await agent(['branch', '-m', current, branch], path) + await git(['branch', '-m', current, branch], path) return { ok: true, branch } } catch (err) { if (attempt >= NAME_ATTEMPTS || !/already exists/.test(err instanceof Error ? err.message : String(err))) throw err @@ -321,8 +320,8 @@ export async function nameBranch(path: string, name: string, agent: GitRunner = } /** Every branch name the repo knows, local and remote-tracking, without the remote's prefix. */ -async function branchNames(path: string, agent: GitRunner): Promise> { - const out = await agent(['for-each-ref', '--format=%(refname)', 'refs/heads/', 'refs/remotes/'], path) +async function branchNames(path: string, git: GitRunner): Promise> { + const out = await git(['for-each-ref', '--format=%(refname)', 'refs/heads/', 'refs/remotes/'], path) const names = new Set() for (const ref of out.split('\n')) { const heads = ref.match(/^refs\/heads\/(.+)$/) @@ -337,9 +336,9 @@ async function branchNames(path: string, agent: GitRunner): Promise> * `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: string, agent: GitRunner = nodeGitRunner()): Promise { +export async function pruneWorktrees(repo: string, git: GitRunner = nodeGitRunner()): Promise { try { - await agent(['worktree', 'prune'], repo) + await git(['worktree', 'prune'], repo) } catch { // Not a repo / nothing to prune: no-op. } @@ -366,9 +365,9 @@ export function nodeSizeRunner(): SizeRunner { * 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: string, agent: SizeRunner = nodeSizeRunner()): Promise { +export async function worktreeSize(path: string, size: SizeRunner = nodeSizeRunner()): Promise { try { - const kb = Number.parseInt((await agent(path)).trim().split(/\s+/)[0] ?? '', 10) + const kb = Number.parseInt((await size(path)).trim().split(/\s+/)[0] ?? '', 10) return Number.isFinite(kb) ? kb * 1024 : undefined } catch { return undefined @@ -395,15 +394,15 @@ export async function worktreeSize(path: string, agent: SizeRunner = nodeSizeRun export async function branchPushed( repo: string, branch: string, - agent: GitRunner = nodeGitRunner(), + git: GitRunner = nodeGitRunner(), ): Promise { 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() + const local = (await git(['rev-parse', '--verify', `refs/heads/${branch}`], repo)).trim() + const remote = (await git(['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) + await git(['merge-base', '--is-ancestor', local, remote], repo) return true } catch { return false @@ -415,8 +414,8 @@ export async function branchPushed( * commits nothing on an agent's behalf, so a checkout holding uncommitted work is one the caller * keeps. Throws when git cannot answer, so the caller keeps the checkout rather than guessing. */ -export async function worktreeClean(path: string, agent: GitRunner = nodeGitRunner()): Promise { - return !(await agent(['status', '--porcelain'], path)).trim() +export async function worktreeClean(path: string, git: GitRunner = nodeGitRunner()): Promise { + return !(await git(['status', '--porcelain'], path)).trim() } /** @@ -426,9 +425,9 @@ export async function worktreeClean(path: string, agent: GitRunner = nodeGitRunn * 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: string, agent: GitRunner = nodeGitRunner()): Promise { +export async function repoHasRemote(repo: string, git: GitRunner = nodeGitRunner()): Promise { try { - return (await agent(['remote'], repo)).trim().length > 0 + return (await git(['remote'], repo)).trim().length > 0 } catch { return false }