@switchyardhq/git-fleet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohammed Alkindi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
4
+ <img src="assets/logo.svg" alt="Switchyard logo" width="140">
5
+ </picture>
6
+ </p>
7
+
8
+ # Switchyard
9
+
10
+ > One repo, many AI coding agents, zero collisions — Switchyard's `fleet` CLI gives every agent an isolated git worktree, with collision detection before you merge.
11
+
12
+ [![npm version](https://img.shields.io/npm/v/@switchyardhq/git-fleet)](https://www.npmjs.com/package/@switchyardhq/git-fleet)
13
+ [![npm downloads](https://img.shields.io/npm/dm/@switchyardhq/git-fleet)](https://www.npmjs.com/package/@switchyardhq/git-fleet)
14
+ [![license](https://img.shields.io/github/license/MohammedAlkindi/Switchyard)](LICENSE)
15
+ [![CI](https://img.shields.io/github/actions/workflow/status/MohammedAlkindi/Switchyard/ci.yml?branch=main&label=CI)](https://github.com/MohammedAlkindi/Switchyard/actions/workflows/ci.yml)
16
+ ![coverage](https://img.shields.io/badge/coverage-80%25-green)
17
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](tsconfig.json)
18
+ [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](https://github.com/MohammedAlkindi/Switchyard/pulls)
19
+
20
+ <!-- The coverage badge is a static number: re-run `npm run test:coverage` and update it
21
+ when it drifts. Replace with a Codecov (or similar) badge once coverage upload is
22
+ wired into CI. -->
23
+
24
+
25
+ <p align="center">
26
+ <img src="assets/demo.gif" alt="30-second demo: fleet spawn isolates two agents, fleet list shows the whole fleet, fleet check catches the collision before anyone merges" width="830">
27
+ </p>
28
+
29
+ <p align="center">
30
+ <sub><code>fleet spawn</code> two agents · <code>fleet list</code> the whole fleet · <code>fleet check</code> catches the collision before anyone merges · <a href="assets/demo.mp4">full-quality mp4</a></sub>
31
+ </p>
32
+
33
+ <!-- The demo is rendered from demo.tape with vhs (see the comments at the top of that
34
+ file), producing assets/demo.mp4. The inline GIF is converted from it with:
35
+ ffmpeg -i assets/demo.mp4 -vf "fps=10,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5" -loop 0 assets/demo.gif
36
+ (GitHub only inline-plays mp4s uploaded via its web editor, not committed files —
37
+ a committed GIF is the reliable way to get a moving demo on the README.) -->
38
+
39
+ Two AI coding agents on one checkout ends badly. This project exists because Codex silently ran a `git reset` on `main` mid-merge while Claude Code was mid-task on the same files — the merge state vanished and neither agent noticed. The failure mode isn't exotic: two agents, one working tree, no isolation. Switchyard (published as `@switchyardhq/git-fleet`; the installed command is `fleet`) gives each agent its own git worktree and branch, tracks them centrally, and flags collisions between agents before anyone merges.
40
+
41
+ ## What it looks like
42
+
43
+ *(The same flow as the [demo video](assets/demo.mp4), in skimmable, copy-pasteable form.)*
44
+
45
+ ```console
46
+ $ fleet spawn claude
47
+ Spawned agent claude
48
+ branch: fleet/claude (from main)
49
+ worktree: ~/project/.fleet/worktrees/claude
50
+
51
+ Point your agent at it:
52
+ cd ~/project/.fleet/worktrees/claude
53
+
54
+ $ fleet list
55
+ ┌────────┬──────────────┬──────┬───────┬───────────────┬───────────────┬──────────────────────────┐
56
+ │ AGENT │ BRANCH │ BASE │ +/- │ CHANGES │ LAST ACTIVITY │ WORKTREE │
57
+ ├────────┼──────────────┼──────┼───────┼───────────────┼───────────────┼──────────────────────────┤
58
+ │ claude │ fleet/claude │ main │ +3/-0 │ clean │ 12m ago │ .fleet/worktrees/claude │
59
+ │ codex │ fleet/codex │ main │ +1/-2 │ 4 uncommitted │ just now │ .fleet/worktrees/codex │
60
+ └────────┴──────────────┴──────┴───────┴───────────────┴───────────────┴──────────────────────────┘
61
+
62
+ $ fleet check
63
+ 1 collision risk detected:
64
+ ┌───────────────────┬───────────────┐
65
+ │ FILE │ AGENTS │
66
+ ├───────────────────┼───────────────┤
67
+ │ src/api/routes.ts │ claude, codex │
68
+ └───────────────────┴───────────────┘
69
+ These files are touched by more than one agent (committed or uncommitted). Coordinate before merging.
70
+ ```
71
+
72
+ ## Installation
73
+
74
+ ```sh
75
+ npm install -g @switchyardhq/git-fleet
76
+ ```
77
+
78
+ Requires Node.js >= 18.17 and git >= 2.31. The installed command is `fleet`.
79
+
80
+ ## Quickstart
81
+
82
+ ```sh
83
+ cd your-repo
84
+ fleet spawn claude # isolated worktree on branch fleet/claude
85
+ cd .fleet/worktrees/claude # point your agent here and let it work
86
+ fleet check # any files also touched by other agents?
87
+ fleet sync claude # base moved on? catch the branch up
88
+ fleet exec claude -- npm test # run commands in the worktree without cd'ing
89
+ fleet diff claude # review the branch before merging
90
+ fleet merge claude # merge into your current branch + clean up the agent
91
+ fleet pr claude # …or push it and open a PR via gh instead
92
+ ```
93
+
94
+ ## Commands
95
+
96
+ | Command | Description | Key flags |
97
+ | --- | --- | --- |
98
+ | `fleet spawn <agent>` | Create a worktree in `.fleet/worktrees/<agent>/` on a new branch `fleet/<agent>`, then provision it (`copyOnSpawn` / `postSpawn` below) | `--from <branch>` base branch (default: current branch) |
99
+ | `fleet list` | All active agents: branch, base, ahead/behind, uncommitted count, last activity | `--json` machine-readable output |
100
+ | `fleet status <agent>` | One agent in detail: uncommitted files, diff stat vs base, ahead/behind | `--json` machine-readable output |
101
+ | `fleet check` | Table of files touched by more than one agent — collision risks before merging. Exits 1 if any are found (CI-friendly) | `--lines` only count overlapping line ranges, `--json` machine-readable output |
102
+ | `fleet diff <agent>` | Full diff of the agent's branch against its base | `--base <branch>` diff against a different branch |
103
+ | `fleet sync <agent>` | Merge the agent's base branch into its branch, catching it up. A conflicting merge is aborted — never left half-done | — |
104
+ | `fleet exec <agent> -- <cmd>` | Run a shell command inside the agent's worktree (e.g. `fleet exec claude -- npm test`) | `--all` run in every worktree sequentially; exits 1 if any run fails |
105
+ | `fleet merge <agent>` | Check for collisions, run the `preMerge` hook, merge the agent's branch into the current branch, then remove the worktree and branch. A conflicting merge is aborted — never left half-done | `--no-clean` keep the worktree and branch, `--delete-branch` explicit form of the default cleanup |
106
+ | `fleet pr <agent>` | Push the agent's branch to `origin` and open a pull request with the [GitHub CLI](https://cli.github.com) — the review-based alternative to a local merge | `--title <t>`, `--base <branch>`, `--draft` |
107
+ | `fleet remove <agent>` | Remove the worktree; refuses if there are uncommitted changes | `--force` discard changes, `--delete-branch` also delete the branch |
108
+ | `fleet clean` | Remove agents whose branches are fully merged into their base | `--dry-run` list only, `--stale <days>` also remove long-idle agents (clean worktrees only; their branches are kept) |
109
+ | `fleet watch` | `fleet list`, re-rendered live until Ctrl+C | `--interval <seconds>` refresh rate (default 3) |
110
+ | `fleet doctor` | Diagnose git version, state file validity, orphaned worktrees, and stale entries. Exits 1 if problems remain | `--fix` repair: rebuild state from `git worktree list`, adopt/remove orphans, prune stale entries; `--json` machine-readable output |
111
+ | `fleet completion <shell>` | Print a completion script for `bash`, `zsh`, or `fish` (agent names are a snapshot from generation time) | — |
112
+
113
+ All commands work from the main checkout **or** from inside any agent worktree.
114
+
115
+ ### Scripting and CI
116
+
117
+ `list`, `status`, `check`, and `doctor` all take `--json` for machine-readable output, so agents and CI can consume Switchyard state directly — e.g. a merge gate:
118
+
119
+ ```sh
120
+ fleet check --json || exit 1 # exit code alone is enough for CI
121
+ fleet list --json | jq -r '.[].name' # enumerate active agents
122
+ ```
123
+
124
+ `fleet check --lines` refines collision detection from files to line ranges: two agents editing disjoint parts of one file are reported separately instead of blocking. Ranges are computed against each pair's merge base — exact when both agents share a base, a documented heuristic otherwise (see [docs/architecture.md](docs/architecture.md)).
125
+
126
+ ## Configuration
127
+
128
+ An optional `.fleetrc.json` at the repo root sets per-repo defaults. Precedence everywhere: CLI flag > `.fleetrc.json` > built-in default.
129
+
130
+ ```json
131
+ {
132
+ "$schema": "https://unpkg.com/@switchyardhq/git-fleet/schema/fleetrc.schema.json",
133
+ "defaultBase": "main",
134
+ "watchInterval": 3,
135
+ "autoClean": false,
136
+ "copyOnSpawn": [".env"],
137
+ "postSpawn": "npm ci",
138
+ "preMerge": "npm test"
139
+ }
140
+ ```
141
+
142
+ - `$schema` — optional; points editors at the config's JSON schema for autocomplete and validation. The schema ships with the package (`node_modules/@switchyardhq/git-fleet/schema/fleetrc.schema.json`).
143
+ - `defaultBase` — base branch for `fleet spawn` when `--from` is not passed (built-in default: the current branch).
144
+ - `watchInterval` — refresh interval for `fleet watch`, in seconds (built-in default: 3).
145
+ - `autoClean` — when `true`, every successful `fleet merge` also runs a `fleet clean` sweep for other fully merged agents (built-in default: `false`).
146
+ - `copyOnSpawn` — repo-root-relative files/directories copied into every new worktree by `fleet spawn`. Worktrees don't carry gitignored files, so a fresh one has no `.env` or local config — this fixes that. Missing entries are skipped with a note.
147
+ - `postSpawn` — shell command run inside the new worktree after `fleet spawn` (e.g. `npm ci`), so the worktree is ready to work in. A failing hook is reported but the worktree is kept.
148
+ - `preMerge` — shell command run inside the agent's worktree before `fleet merge` starts (e.g. `npm test`). A non-zero exit aborts the merge before anything is touched.
149
+
150
+ A malformed config file is a hard error with the offending key named; a missing one is fine.
151
+
152
+ > [!NOTE]
153
+ > `postSpawn` and `preMerge` run shell commands straight from the repo's `.fleetrc.json` — the same trust you already extend to a repo's npm scripts or git hooks. Review that file before running `fleet spawn` or `fleet merge` in a repository you didn't author (details in [SECURITY.md](SECURITY.md)).
154
+
155
+ ## How it works
156
+
157
+ `fleet spawn` runs `git worktree add` under the hood: each agent gets a real, separate directory with its own checkout of a dedicated `fleet/<agent>` branch, so one agent's `git reset` physically cannot touch another agent's files. A single gitignored `.fleet/state.json` in the main repo maps each agent to its branch, base, and worktree, and `fleet check` uses it to diff every agent branch against its base (`git diff base...branch`, plus uncommitted edits) and cross-reference the changed files. Switchyard also adds `.fleet/` to `.git/info/exclude` automatically, so it never dirties the repos it manages. Design rationale and limitations live in [docs/architecture.md](docs/architecture.md).
158
+
159
+ ## Contributing
160
+
161
+ PRs are welcome. Clone, `npm install`, `npm test` — every command is tested against real throwaway git repositories, and any change to a command must come with such a test (no git mocks; see [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md) for the ground rules). Commits follow [Conventional Commits](https://www.conventionalcommits.org/). Read [docs/architecture.md](docs/architecture.md) before structural changes, and [docs/deployment.md](docs/deployment.md) for the release process.
162
+
163
+ ## License
164
+
165
+ MIT © Mohammed Alkindi — see [LICENSE](LICENSE).
package/dist/cli.js ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import chalk from 'chalk';
4
+ import { Command } from 'commander';
5
+ import { check } from './commands/check.js';
6
+ import { clean } from './commands/clean.js';
7
+ import { completion } from './commands/completion.js';
8
+ import { diff } from './commands/diff.js';
9
+ import { doctor } from './commands/doctor.js';
10
+ import { exec } from './commands/exec.js';
11
+ import { list } from './commands/list.js';
12
+ import { merge } from './commands/merge.js';
13
+ import { pr } from './commands/pr.js';
14
+ import { remove } from './commands/remove.js';
15
+ import { spawn } from './commands/spawn.js';
16
+ import { status } from './commands/status.js';
17
+ import { sync } from './commands/sync.js';
18
+ import { watch } from './commands/watch.js';
19
+ import { FleetError } from './lib/errors.js';
20
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
21
+ /** Run a command action, mapping expected failures to a clean exit-1 message. */
22
+ async function run(fn) {
23
+ try {
24
+ await fn();
25
+ }
26
+ catch (err) {
27
+ process.exitCode = 1;
28
+ if (err instanceof FleetError) {
29
+ console.error(chalk.red(`error: ${err.message}`));
30
+ return;
31
+ }
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ console.error(chalk.red(`unexpected error: ${message}`));
34
+ if (process.env.FLEET_DEBUG && err instanceof Error && err.stack) {
35
+ console.error(err.stack);
36
+ }
37
+ else {
38
+ console.error(chalk.dim('Re-run with FLEET_DEBUG=1 for a stack trace.'));
39
+ }
40
+ }
41
+ }
42
+ const program = new Command();
43
+ // Required for `fleet exec` passThroughOptions; only affects program-level
44
+ // options (--version/--help), which must now precede the subcommand.
45
+ program.enablePositionalOptions();
46
+ program
47
+ .name('fleet')
48
+ .description('Manage multiple AI coding agents working on the same git repository.\n' +
49
+ 'Each agent gets an isolated worktree + branch; Switchyard tracks them and\n' +
50
+ 'flags file-level collisions before you merge.')
51
+ .version(pkg.version)
52
+ .showHelpAfterError('(run `fleet --help` for usage)');
53
+ program
54
+ .command('spawn')
55
+ .description('create an isolated worktree + branch (fleet/<name>) for an agent')
56
+ .argument('<agent-name>', 'name for the agent, e.g. claude, codex, cursor')
57
+ .option('--from <branch>', 'base branch to spawn from (default: current branch)')
58
+ .action((name, opts) => run(() => spawn(name, opts)));
59
+ program
60
+ .command('list')
61
+ .description('show all active agents: branch, ahead/behind, changes, last activity')
62
+ .option('--json', 'print machine-readable JSON instead of a table')
63
+ .action((opts) => run(() => list(opts)));
64
+ program
65
+ .command('status')
66
+ .description("detailed view of one agent's worktree and branch vs its base")
67
+ .argument('<agent-name>', 'agent to inspect')
68
+ .option('--json', 'print machine-readable JSON instead of the summary')
69
+ .action((name, opts) => run(() => status(name, opts)));
70
+ program
71
+ .command('check')
72
+ .description('flag files touched by more than one agent (exits 1 if any are found)')
73
+ .option('--lines', 'only count files whose edited line ranges actually overlap')
74
+ .option('--json', 'print machine-readable JSON instead of a table')
75
+ .action((opts) => run(async () => {
76
+ const result = await check(opts);
77
+ if (result.collisions.length > 0)
78
+ process.exitCode = 1;
79
+ }));
80
+ program
81
+ .command('diff')
82
+ .description("show an agent branch's full diff against its base branch")
83
+ .argument('<agent-name>', 'agent to diff')
84
+ .option('--base <branch>', 'diff against this branch instead of the recorded base')
85
+ .action((name, opts) => run(() => diff(name, opts)));
86
+ program
87
+ .command('sync')
88
+ .description("merge an agent's base branch into its branch, catching it up")
89
+ .argument('<agent-name>', 'agent to sync')
90
+ .action((name) => run(() => sync(name)));
91
+ program
92
+ .command('exec')
93
+ .description("run a shell command inside an agent's worktree (or all worktrees)")
94
+ .argument('[agent-name]', 'agent whose worktree to run in (omit with --all)')
95
+ .argument('[command...]', 'command to run, e.g. `fleet exec claude -- npm test`')
96
+ .option('--all', "run in every agent's worktree, sequentially")
97
+ .passThroughOptions()
98
+ .action((name, command, opts) => run(async () => {
99
+ // With --all the first positional is part of the command, not an agent.
100
+ let tokens = opts.all && name !== undefined ? [name, ...command] : command;
101
+ // commander keeps the literal `--` separator in pass-through args.
102
+ if (tokens[0] === '--')
103
+ tokens = tokens.slice(1);
104
+ const result = await exec(opts.all ? undefined : name, tokens, { all: opts.all });
105
+ if (!result.ok)
106
+ process.exitCode = 1;
107
+ }));
108
+ program
109
+ .command('pr')
110
+ .description("push an agent's branch to origin and open a pull request via gh")
111
+ .argument('<agent-name>', 'agent to open a PR for')
112
+ .option('--title <title>', 'PR title (default: gh --fill from the last commit)')
113
+ .option('--base <branch>', "PR base branch (default: the agent's recorded base)")
114
+ .option('--draft', 'open the PR as a draft')
115
+ .action((name, opts) => run(() => pr(name, opts)));
116
+ program
117
+ .command('watch')
118
+ .description('live-updating `fleet list`, re-rendered on an interval until Ctrl+C')
119
+ .option('--interval <seconds>', 'refresh interval in seconds (default: 3)', parseFloat)
120
+ .action((opts) => run(() => watch(opts)));
121
+ program
122
+ .command('merge')
123
+ .description("merge an agent's branch into the current branch, then clean up the agent")
124
+ .argument('<agent-name>', 'agent to merge')
125
+ .option('--delete-branch', 'delete the branch after merging (already the default cleanup)')
126
+ .option('--no-clean', 'keep the worktree and branch after merging')
127
+ .action((name, opts) => run(() => merge(name, opts)));
128
+ program
129
+ .command('doctor')
130
+ .description('diagnose state/reality drift; exits 1 if problems remain unfixed')
131
+ .option('--fix', 'repair what can be repaired (rebuild state, adopt/remove orphans, prune stale entries)')
132
+ .option('--json', 'print machine-readable JSON instead of the report')
133
+ .action((opts) => run(async () => {
134
+ const result = await doctor(opts);
135
+ if (!result.healthy)
136
+ process.exitCode = 1;
137
+ }));
138
+ program
139
+ .command('completion')
140
+ .description('output a shell completion script (agent names are a snapshot)')
141
+ .argument('<shell>', 'bash, zsh, or fish')
142
+ .action((shell) => run(() => completion(shell)));
143
+ program
144
+ .command('remove')
145
+ .description("remove an agent's worktree (branch is kept unless --delete-branch)")
146
+ .argument('<agent-name>', 'agent to remove')
147
+ .option('--force', 'discard uncommitted changes / delete an unmerged branch')
148
+ .option('--delete-branch', "also delete the agent's fleet/<name> branch")
149
+ .action((name, opts) => run(() => remove(name, opts)));
150
+ program
151
+ .command('clean')
152
+ .description('remove agents whose branches are fully merged into their base')
153
+ .option('--dry-run', 'list what would be cleaned without removing anything')
154
+ .option('--stale <days>', 'also remove agents idle for this many days (clean worktree only; branch kept)', parseFloat)
155
+ .action((opts) => run(() => clean(opts)));
156
+ program.addHelpText('after', '\nExamples:\n' +
157
+ ' fleet spawn claude spawn an agent off the current branch\n' +
158
+ ' fleet spawn codex --from main spawn a second agent off main\n' +
159
+ ' fleet check --lines any files touched by both, line-precise?\n' +
160
+ ' fleet sync claude catch fleet/claude up with its base\n' +
161
+ ' fleet exec claude -- npm test run tests inside the claude worktree\n' +
162
+ ' fleet diff claude review before merging fleet/claude\n' +
163
+ ' fleet merge claude merge fleet/claude and clean it up\n' +
164
+ ' fleet pr claude push fleet/claude and open a PR via gh\n' +
165
+ ' fleet remove codex --force drop a worktree, discarding its changes\n' +
166
+ ' fleet clean --stale 14 sweep merged agents + 2-week-idle ones\n' +
167
+ ' fleet doctor --fix repair state drift after manual surgery\n' +
168
+ ' fleet list --json agent table as JSON, for scripts and CI\n');
169
+ program.parseAsync(process.argv).catch((err) => {
170
+ console.error(err);
171
+ process.exit(1);
172
+ });
@@ -0,0 +1,144 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dim, fail, ok, plural, table } from '../lib/format.js';
3
+ import { branchExists, changedFilesVsBase, getMainRepoRoot, gitAt, uncommittedFiles, } from '../lib/git.js';
4
+ import { formatRanges, parseUnifiedDiff, rangesOverlap } from '../lib/lines.js';
5
+ import { readState, worktreeAbsPath } from '../lib/state.js';
6
+ /**
7
+ * Cross-reference every agent branch's changed files (committed vs base, plus
8
+ * uncommitted edits in the worktree) and flag files touched by more than one
9
+ * agent — the collision risks to resolve before anyone merges.
10
+ */
11
+ export async function check(options = {}) {
12
+ const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
13
+ const git = gitAt(repoRoot);
14
+ const state = readState(repoRoot);
15
+ const agents = Object.values(state.agents).sort((a, b) => a.name.localeCompare(b.name));
16
+ const json = options.json ?? false;
17
+ if (agents.length < 2) {
18
+ const result = { collisions: [], agentsChecked: agents.length };
19
+ if (options.lines)
20
+ result.disjoint = [];
21
+ if (json) {
22
+ console.log(JSON.stringify(result, null, 2));
23
+ }
24
+ else {
25
+ console.log(`Nothing to check: ${plural(agents.length, 'active agent')} ` +
26
+ '(collisions need at least 2).');
27
+ }
28
+ return result;
29
+ }
30
+ const agentsByFile = new Map();
31
+ // --lines only: file -> agent -> edited ranges (merge-base coordinates).
32
+ const rangesByFile = new Map();
33
+ for (const record of agents) {
34
+ const files = new Set();
35
+ if ((await branchExists(git, record.branch)) &&
36
+ (await branchExists(git, record.baseBranch))) {
37
+ for (const f of await changedFilesVsBase(git, record.baseBranch, record.branch)) {
38
+ files.add(f);
39
+ }
40
+ }
41
+ const abs = worktreeAbsPath(repoRoot, record);
42
+ if (existsSync(abs)) {
43
+ for (const f of await uncommittedFiles(abs))
44
+ files.add(f.path);
45
+ }
46
+ for (const file of files) {
47
+ const touchers = agentsByFile.get(file) ?? [];
48
+ touchers.push(record.name);
49
+ agentsByFile.set(file, touchers);
50
+ }
51
+ if (options.lines) {
52
+ const ranges = await collectAgentRanges(git, repoRoot, record, files);
53
+ for (const [file, fileRanges] of ranges) {
54
+ const perAgent = rangesByFile.get(file) ?? new Map();
55
+ perAgent.set(record.name, fileRanges);
56
+ rangesByFile.set(file, perAgent);
57
+ }
58
+ }
59
+ }
60
+ const multiAgent = [...agentsByFile.entries()]
61
+ .filter(([, names]) => names.length > 1)
62
+ .map(([file, names]) => ({ file, agents: [...names].sort() }))
63
+ .sort((a, b) => a.file.localeCompare(b.file));
64
+ let collisions;
65
+ let disjoint;
66
+ if (options.lines) {
67
+ collisions = [];
68
+ disjoint = [];
69
+ for (const { file, agents: names } of multiAgent) {
70
+ const perAgent = rangesByFile.get(file);
71
+ // An agent with no parsed ranges has no net change vs merge-base.
72
+ const entries = names.map((n) => perAgent?.get(n) ?? []);
73
+ const overlap = rangesOverlap(entries);
74
+ if (overlap === 'whole') {
75
+ collisions.push({ file, agents: names, overlap: 'whole-file' });
76
+ }
77
+ else if (overlap.length > 0) {
78
+ collisions.push({ file, agents: names, overlap: formatRanges(overlap) });
79
+ }
80
+ else {
81
+ disjoint.push({ file, agents: names });
82
+ }
83
+ }
84
+ }
85
+ else {
86
+ collisions = multiAgent;
87
+ }
88
+ const result = { collisions, agentsChecked: agents.length };
89
+ if (disjoint !== undefined)
90
+ result.disjoint = disjoint;
91
+ if (json) {
92
+ console.log(JSON.stringify(result, null, 2));
93
+ return result;
94
+ }
95
+ if (collisions.length === 0) {
96
+ console.log(ok(`No collisions across ${agents.length} agents.`));
97
+ }
98
+ else {
99
+ console.log(fail(`${plural(collisions.length, 'collision risk')} detected:`));
100
+ console.log(options.lines
101
+ ? table(['FILE', 'AGENTS', 'LINES'], collisions.map((c) => [c.file, c.agents.join(', '), c.overlap ?? '']))
102
+ : table(['FILE', 'AGENTS'], collisions.map((c) => [c.file, c.agents.join(', ')])));
103
+ console.log(dim(options.lines
104
+ ? 'Line ranges are relative to the merge base — exact when the agents share a base, a heuristic otherwise.'
105
+ : 'These files are touched by more than one agent (committed or uncommitted). ' +
106
+ 'Coordinate before merging.'));
107
+ }
108
+ if (disjoint && disjoint.length > 0) {
109
+ console.log(dim(`${plural(disjoint.length, 'shared file')} with disjoint line edits (not counted as collisions):`));
110
+ for (const d of disjoint) {
111
+ console.log(dim(` ${d.file} (${d.agents.join(', ')})`));
112
+ }
113
+ }
114
+ return result;
115
+ }
116
+ /**
117
+ * Edited line ranges for every file an agent touched, in merge-base
118
+ * coordinates: one `git diff -U0 <merge-base>` run inside the worktree covers
119
+ * committed and uncommitted work at once; untracked files (and anything else
120
+ * whose lines can't be resolved) are marked 'whole'.
121
+ */
122
+ async function collectAgentRanges(git, repoRoot, record, files) {
123
+ const abs = worktreeAbsPath(repoRoot, record);
124
+ const worktreeExists = existsSync(abs);
125
+ const branchesExist = (await branchExists(git, record.branch)) && (await branchExists(git, record.baseBranch));
126
+ if (!branchesExist) {
127
+ // No merge base to anchor line numbers to — fall back to whole-file.
128
+ return new Map([...files].map((f) => [f, 'whole']));
129
+ }
130
+ const mergeBase = (await git.raw(['merge-base', record.baseBranch, record.branch])).trim();
131
+ const diffText = worktreeExists
132
+ ? await gitAt(abs).raw(['diff', '-U0', '--no-color', mergeBase])
133
+ : await git.raw(['diff', '-U0', '--no-color', mergeBase, record.branch]);
134
+ const ranges = parseUnifiedDiff(diffText);
135
+ if (worktreeExists) {
136
+ for (const f of await uncommittedFiles(abs)) {
137
+ // Untracked files never appear in `git diff`; both agents adding the
138
+ // same new file is a real collision, so mark them whole-file.
139
+ if (f.status === '??')
140
+ ranges.set(f.path, 'whole');
141
+ }
142
+ }
143
+ return ranges;
144
+ }
@@ -0,0 +1,133 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { FleetError } from '../lib/errors.js';
3
+ import { dim, ok, plural } from '../lib/format.js';
4
+ import { branchExists, deleteBranch, getMainRepoRoot, gitAt, isMergedInto, lastCommitISO, pruneWorktrees, removeWorktree, uncommittedFiles, } from '../lib/git.js';
5
+ import { readState, worktreeAbsPath, writeState } from '../lib/state.js';
6
+ /**
7
+ * Remove agents whose branches are fully merged into their base — worktree,
8
+ * branch, and state entry. Never touches unmerged work or dirty worktrees.
9
+ */
10
+ export async function clean(options = {}) {
11
+ const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
12
+ const git = gitAt(repoRoot);
13
+ const state = readState(repoRoot);
14
+ const agents = Object.values(state.agents).sort((a, b) => a.name.localeCompare(b.name));
15
+ const dryRun = options.dryRun ?? false;
16
+ if (options.stale !== undefined && (!Number.isFinite(options.stale) || options.stale <= 0)) {
17
+ throw new FleetError('Invalid --stale. Pass a positive number of days, e.g. --stale 14.');
18
+ }
19
+ if (agents.length === 0) {
20
+ console.log('Nothing to clean: no active agents.');
21
+ return { cleaned: [], kept: [] };
22
+ }
23
+ const cleaned = [];
24
+ const kept = [];
25
+ for (const record of agents) {
26
+ const abs = worktreeAbsPath(repoRoot, record);
27
+ if (!(await branchExists(git, record.branch))) {
28
+ // Branch was deleted outside Switchyard; only the stale state entry is left.
29
+ cleaned.push({
30
+ name: record.name,
31
+ branch: record.branch,
32
+ baseBranch: record.baseBranch,
33
+ reason: 'branch-missing',
34
+ });
35
+ if (!dryRun) {
36
+ await pruneWorktrees(git);
37
+ delete state.agents[record.name];
38
+ }
39
+ continue;
40
+ }
41
+ if (!(await branchExists(git, record.baseBranch))) {
42
+ kept.push({
43
+ name: record.name,
44
+ reason: `base branch "${record.baseBranch}" no longer exists`,
45
+ });
46
+ continue;
47
+ }
48
+ if (!(await isMergedInto(git, record.branch, record.baseBranch))) {
49
+ // Unmerged agents are never cleaned by default; --stale removes their
50
+ // worktree and state entry after long inactivity, but keeps the branch
51
+ // so the work stays recoverable.
52
+ if (options.stale !== undefined) {
53
+ const dirty = existsSync(abs) ? await uncommittedFiles(abs) : [];
54
+ if (dirty.length > 0) {
55
+ kept.push({
56
+ name: record.name,
57
+ reason: `stale check skipped: ${plural(dirty.length, 'uncommitted change')}`,
58
+ });
59
+ continue;
60
+ }
61
+ const last = await lastCommitISO(git, record.branch);
62
+ const ageDays = last
63
+ ? (Date.now() - new Date(last).getTime()) / (24 * 60 * 60 * 1000)
64
+ : Number.POSITIVE_INFINITY;
65
+ if (ageDays > options.stale) {
66
+ cleaned.push({
67
+ name: record.name,
68
+ branch: record.branch,
69
+ baseBranch: record.baseBranch,
70
+ reason: 'stale',
71
+ });
72
+ if (!dryRun) {
73
+ if (existsSync(abs)) {
74
+ await removeWorktree(git, abs, false);
75
+ }
76
+ else {
77
+ await pruneWorktrees(git);
78
+ }
79
+ delete state.agents[record.name];
80
+ }
81
+ continue;
82
+ }
83
+ }
84
+ kept.push({ name: record.name, reason: `has unmerged commits vs ${record.baseBranch}` });
85
+ continue;
86
+ }
87
+ const dirty = existsSync(abs) ? await uncommittedFiles(abs) : [];
88
+ if (dirty.length > 0) {
89
+ kept.push({ name: record.name, reason: plural(dirty.length, 'uncommitted change') });
90
+ continue;
91
+ }
92
+ cleaned.push({
93
+ name: record.name,
94
+ branch: record.branch,
95
+ baseBranch: record.baseBranch,
96
+ reason: 'merged',
97
+ });
98
+ if (!dryRun) {
99
+ if (existsSync(abs)) {
100
+ await removeWorktree(git, abs, false);
101
+ }
102
+ else {
103
+ await pruneWorktrees(git);
104
+ }
105
+ // Merged into base was verified above; -D avoids `git branch -d`'s
106
+ // stricter merged-into-HEAD check, which depends on the current branch.
107
+ await deleteBranch(git, record.branch, true);
108
+ delete state.agents[record.name];
109
+ }
110
+ }
111
+ if (!dryRun) {
112
+ writeState(repoRoot, state);
113
+ }
114
+ const verb = dryRun ? 'Would clean' : 'Cleaned';
115
+ if (cleaned.length === 0) {
116
+ console.log(ok('Nothing to clean: no fully merged agents.'));
117
+ }
118
+ else {
119
+ console.log(ok(`${verb} ${plural(cleaned.length, 'agent')}:`));
120
+ for (const c of cleaned) {
121
+ const why = c.reason === 'merged'
122
+ ? `merged into ${c.baseBranch}`
123
+ : c.reason === 'stale'
124
+ ? `stale ${options.stale}+ days — worktree removed, branch kept`
125
+ : 'branch already deleted';
126
+ console.log(` ${c.name} ${dim(`(${c.branch} — ${why})`)}`);
127
+ }
128
+ }
129
+ for (const k of kept) {
130
+ console.log(dim(` kept ${k.name}: ${k.reason}`));
131
+ }
132
+ return { cleaned, kept };
133
+ }