@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.
@@ -0,0 +1,126 @@
1
+ import { FleetError } from '../lib/errors.js';
2
+ import { getMainRepoRoot } from '../lib/git.js';
3
+ import { readState } from '../lib/state.js';
4
+ const SHELLS = ['bash', 'zsh', 'fish'];
5
+ /** command name -> short description (kept apostrophe-free for shell quoting). */
6
+ const COMMANDS = [
7
+ ['spawn', 'create an isolated worktree + branch for an agent'],
8
+ ['list', 'show all active agents'],
9
+ ['status', 'detailed view of one agent'],
10
+ ['check', 'flag files touched by more than one agent'],
11
+ ['diff', 'diff an agent branch against its base'],
12
+ ['sync', 'catch an agent branch up with its base'],
13
+ ['exec', 'run a command inside an agent worktree'],
14
+ ['merge', 'merge an agent branch into the current branch and clean up'],
15
+ ['pr', 'push an agent branch and open a pull request via gh'],
16
+ ['remove', 'remove an agent worktree'],
17
+ ['clean', 'remove fully merged agents'],
18
+ ['watch', 'live-updating agent table'],
19
+ ['doctor', 'diagnose and repair state drift'],
20
+ ['completion', 'output a shell completion script'],
21
+ ];
22
+ /** Commands whose first argument is an agent name. */
23
+ const AGENT_COMMANDS = ['status', 'diff', 'sync', 'exec', 'merge', 'pr', 'remove'];
24
+ const SNAPSHOT_NOTE = 'Agent names below are a snapshot of .fleet/state.json at generation time,\n' +
25
+ '# not dynamic — re-run `fleet completion <shell>` after spawning or removing\n' +
26
+ '# agents to refresh them.';
27
+ /**
28
+ * Print a completion script for the given shell, covering command names and
29
+ * the agent names currently registered in `.fleet/state.json`.
30
+ */
31
+ export async function completion(shell, options = {}) {
32
+ if (!SHELLS.includes(shell)) {
33
+ throw new FleetError(`Unsupported shell "${shell}". Supported: ${SHELLS.join(', ')}.`);
34
+ }
35
+ // Outside a repo (or with no agents) the script still works; the agent-name
36
+ // list is just empty.
37
+ let agents = [];
38
+ try {
39
+ const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
40
+ agents = Object.keys(readState(repoRoot).agents).sort();
41
+ }
42
+ catch {
43
+ agents = [];
44
+ }
45
+ let script;
46
+ switch (shell) {
47
+ case 'bash':
48
+ script = bashScript(agents);
49
+ break;
50
+ case 'zsh':
51
+ script = zshScript(agents);
52
+ break;
53
+ case 'fish':
54
+ script = fishScript(agents);
55
+ break;
56
+ }
57
+ console.log(script);
58
+ return script;
59
+ }
60
+ function bashScript(agents) {
61
+ const commands = COMMANDS.map(([name]) => name).join(' ');
62
+ return `# fleet completion (bash) — source this file or add to ~/.bashrc:
63
+ # eval "$(fleet completion bash)"
64
+ # ${SNAPSHOT_NOTE}
65
+ _fleet_completions() {
66
+ local cur prev
67
+ cur="\${COMP_WORDS[COMP_CWORD]}"
68
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
69
+ local commands="${commands}"
70
+ local agents="${agents.join(' ')}"
71
+ if [ "$COMP_CWORD" -eq 1 ]; then
72
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
73
+ return
74
+ fi
75
+ case "$prev" in
76
+ ${AGENT_COMMANDS.join('|')})
77
+ COMPREPLY=( $(compgen -W "$agents" -- "$cur") )
78
+ ;;
79
+ completion)
80
+ COMPREPLY=( $(compgen -W "${SHELLS.join(' ')}" -- "$cur") )
81
+ ;;
82
+ *)
83
+ COMPREPLY=()
84
+ ;;
85
+ esac
86
+ }
87
+ complete -F _fleet_completions fleet`;
88
+ }
89
+ function zshScript(agents) {
90
+ const commandLines = COMMANDS.map(([name, desc]) => ` '${name}:${desc}'`).join('\n');
91
+ return `#compdef fleet
92
+ # fleet completion (zsh) — write to a file on your $fpath, e.g.:
93
+ # fleet completion zsh > ~/.zsh/completions/_fleet
94
+ # ${SNAPSHOT_NOTE}
95
+ _fleet() {
96
+ local -a commands agents
97
+ commands=(
98
+ ${commandLines}
99
+ )
100
+ agents=(${agents.join(' ')})
101
+ if (( CURRENT == 2 )); then
102
+ _describe 'command' commands
103
+ return
104
+ fi
105
+ case "\${words[2]}" in
106
+ ${AGENT_COMMANDS.join('|')})
107
+ _describe 'agent' agents
108
+ ;;
109
+ completion)
110
+ _values 'shell' ${SHELLS.join(' ')}
111
+ ;;
112
+ esac
113
+ }
114
+ _fleet "$@"`;
115
+ }
116
+ function fishScript(agents) {
117
+ const commandLines = COMMANDS.map(([name, desc]) => `complete -c fleet -n '__fish_use_subcommand' -a ${name} -d '${desc}'`).join('\n');
118
+ const agentCondition = `__fish_seen_subcommand_from ${AGENT_COMMANDS.join(' ')}`;
119
+ return `# fleet completion (fish) — write to ~/.config/fish/completions/fleet.fish:
120
+ # fleet completion fish > ~/.config/fish/completions/fleet.fish
121
+ # ${SNAPSHOT_NOTE}
122
+ complete -c fleet -f
123
+ ${commandLines}
124
+ complete -c fleet -n '${agentCondition}' -a '${agents.join(' ')}'
125
+ complete -c fleet -n '__fish_seen_subcommand_from completion' -a '${SHELLS.join(' ')}'`;
126
+ }
@@ -0,0 +1,35 @@
1
+ import { FleetError } from '../lib/errors.js';
2
+ import { dim } from '../lib/format.js';
3
+ import { branchExists, defaultBaseBranch, getMainRepoRoot, gitAt, verifyBranch } from '../lib/git.js';
4
+ import { readState } from '../lib/state.js';
5
+ export async function diff(name, options = {}) {
6
+ const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
7
+ const git = gitAt(repoRoot);
8
+ const state = readState(repoRoot);
9
+ // Also works for fleet/* branches that exist but aren't in state (e.g. the
10
+ // state file was deleted): fall back to main/master as the base.
11
+ const record = state.agents[name];
12
+ const branch = record?.branch ?? `fleet/${name}`;
13
+ if (!(await branchExists(git, branch))) {
14
+ if (record) {
15
+ throw new FleetError(`Agent "${name}" is tracked but its branch "${branch}" no longer exists.\n` +
16
+ `Run \`fleet remove ${name}\` to clean up the stale entry.`);
17
+ }
18
+ throw new FleetError(`No agent named "${name}" and no branch "${branch}" exists. ` +
19
+ 'Run `fleet list` to see active agents.');
20
+ }
21
+ const base = options.base ?? record?.baseBranch ?? (await defaultBaseBranch(git));
22
+ await verifyBranch(git, base, 'Base');
23
+ const args = ['diff'];
24
+ if (process.stdout.isTTY)
25
+ args.push('--color=always');
26
+ args.push(`${base}...${branch}`);
27
+ const patch = await git.raw(args);
28
+ if (patch.trim().length === 0) {
29
+ console.log(dim(`No committed changes on ${branch} vs ${base}.`));
30
+ }
31
+ else {
32
+ console.log(patch.trimEnd());
33
+ }
34
+ return { branch, base, patch };
35
+ }
@@ -0,0 +1,248 @@
1
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { simpleGit } from 'simple-git';
4
+ import { dim, fail, ok, warn } from '../lib/format.js';
5
+ import { currentBranch, defaultBaseBranch, getMainRepoRoot, gitAt, pruneWorktrees, } from '../lib/git.js';
6
+ import { readState, worktreeAbsPath, worktreesDir, writeState } from '../lib/state.js';
7
+ /** Minimum git version Switchyard needs (`--path-format=absolute` support). */
8
+ export const MIN_GIT = { major: 2, minor: 31 };
9
+ /**
10
+ * Diagnose (and with --fix, repair) drift between `.fleet/state.json` and
11
+ * reality: corrupted state, orphaned worktrees, stale entries. Repairs are
12
+ * re-derived from actual `git worktree list` output, never guessed.
13
+ */
14
+ export async function doctor(options = {}) {
15
+ const fix = options.fix ?? false;
16
+ const checks = [];
17
+ checks.push(await checkGitVersion());
18
+ let repoRoot;
19
+ try {
20
+ repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
21
+ checks.push({ name: 'repository', ok: true, detail: `inside a git repository (${repoRoot})`, fixed: false });
22
+ }
23
+ catch {
24
+ checks.push({
25
+ name: 'repository',
26
+ ok: false,
27
+ detail: 'not inside a git repository — run fleet from within the repo it should manage',
28
+ fixed: false,
29
+ });
30
+ return finish(checks, options.json ?? false);
31
+ }
32
+ const git = gitAt(repoRoot);
33
+ const worktrees = await listGitWorktrees(git);
34
+ const fleetWorktrees = worktrees.filter((w) => isUnder(w.path, worktreesDir(repoRoot)) && w.branch !== null && w.branch.startsWith('fleet/'));
35
+ // --- state file -----------------------------------------------------------
36
+ let state = null;
37
+ let stateModified = false;
38
+ try {
39
+ state = readState(repoRoot);
40
+ checks.push({
41
+ name: 'state-file',
42
+ ok: true,
43
+ detail: `.fleet/state.json is valid (${Object.keys(state.agents).length} agents)`,
44
+ fixed: false,
45
+ });
46
+ }
47
+ catch {
48
+ if (fix) {
49
+ state = { version: 1, agents: {} };
50
+ for (const w of fleetWorktrees) {
51
+ const record = await adoptRecord(git, repoRoot, w.path, w.branch);
52
+ state.agents[record.name] = record;
53
+ }
54
+ stateModified = true;
55
+ checks.push({
56
+ name: 'state-file',
57
+ ok: false,
58
+ detail: `.fleet/state.json was corrupted — rebuilt from \`git worktree list\` (${Object.keys(state.agents).length} agents re-adopted; base branches and timestamps are re-derived, not original)`,
59
+ fixed: true,
60
+ });
61
+ }
62
+ else {
63
+ checks.push({
64
+ name: 'state-file',
65
+ ok: false,
66
+ detail: '.fleet/state.json is corrupted (not valid JSON / wrong shape) — run `fleet doctor --fix` to rebuild it from `git worktree list`',
67
+ fixed: false,
68
+ });
69
+ }
70
+ }
71
+ if (state === null) {
72
+ // Without a readable state the remaining cross-checks are meaningless.
73
+ checks.push({
74
+ name: 'orphaned-worktrees',
75
+ ok: false,
76
+ detail: 'skipped: state file unreadable',
77
+ fixed: false,
78
+ });
79
+ checks.push({
80
+ name: 'stale-entries',
81
+ ok: false,
82
+ detail: 'skipped: state file unreadable',
83
+ fixed: false,
84
+ });
85
+ return finish(checks, options.json ?? false);
86
+ }
87
+ // --- orphaned worktrees (on disk, not in state) ----------------------------
88
+ const trackedPaths = Object.values(state.agents).map((r) => worktreeAbsPath(repoRoot, r));
89
+ const orphanDetails = [];
90
+ let orphanCount = 0;
91
+ let orphansFixed = 0;
92
+ const wtDir = worktreesDir(repoRoot);
93
+ const entries = existsSync(wtDir) ? readdirSync(wtDir, { withFileTypes: true }) : [];
94
+ for (const entry of entries) {
95
+ if (!entry.isDirectory())
96
+ continue;
97
+ const abs = path.join(wtDir, entry.name);
98
+ if (trackedPaths.some((p) => samePath(p, abs)))
99
+ continue;
100
+ orphanCount += 1;
101
+ const registered = worktrees.find((w) => samePath(w.path, abs));
102
+ if (registered && registered.branch !== null && registered.branch.startsWith('fleet/')) {
103
+ if (fix) {
104
+ const record = await adoptRecord(git, repoRoot, abs, registered.branch);
105
+ state.agents[record.name] = record;
106
+ stateModified = true;
107
+ orphansFixed += 1;
108
+ orphanDetails.push(`${entry.name}: adopted (branch ${registered.branch})`);
109
+ }
110
+ else {
111
+ orphanDetails.push(`${entry.name}: valid fleet worktree not in state — --fix will adopt it`);
112
+ }
113
+ }
114
+ else if (registered) {
115
+ // A real worktree, but not on a fleet/* branch — not Switchyard's to manage.
116
+ orphanDetails.push(`${entry.name}: worktree on branch ${registered.branch ?? '(detached)'} — not a fleet/* branch; remove it yourself with \`git worktree remove\``);
117
+ }
118
+ else {
119
+ if (fix) {
120
+ rmSync(abs, { recursive: true, force: true });
121
+ orphansFixed += 1;
122
+ orphanDetails.push(`${entry.name}: not a git worktree — directory removed`);
123
+ }
124
+ else {
125
+ orphanDetails.push(`${entry.name}: not a git worktree (leftover directory) — --fix will remove it`);
126
+ }
127
+ }
128
+ }
129
+ checks.push({
130
+ name: 'orphaned-worktrees',
131
+ ok: orphanCount === 0,
132
+ detail: orphanCount === 0
133
+ ? 'no untracked directories in .fleet/worktrees/'
134
+ : orphanDetails.join('; '),
135
+ fixed: orphanCount > 0 && orphansFixed === orphanCount,
136
+ });
137
+ // --- stale state entries (in state, gone on disk) --------------------------
138
+ const staleNames = Object.values(state.agents)
139
+ .filter((r) => !existsSync(worktreeAbsPath(repoRoot, r)))
140
+ .map((r) => r.name);
141
+ if (staleNames.length > 0 && fix) {
142
+ for (const name of staleNames) {
143
+ delete state.agents[name];
144
+ }
145
+ await pruneWorktrees(git);
146
+ stateModified = true;
147
+ }
148
+ checks.push({
149
+ name: 'stale-entries',
150
+ ok: staleNames.length === 0,
151
+ detail: staleNames.length === 0
152
+ ? 'every state entry has a worktree on disk'
153
+ : `worktree directory missing for: ${staleNames.join(', ')}` +
154
+ (fix ? ' — entries pruned (branches kept)' : ' — --fix will prune the entries (branches are kept)'),
155
+ fixed: staleNames.length > 0 && fix,
156
+ });
157
+ if (fix && stateModified) {
158
+ writeState(repoRoot, state);
159
+ }
160
+ return finish(checks, options.json ?? false);
161
+ }
162
+ function finish(checks, json) {
163
+ const healthy = checks.every((c) => c.ok || c.fixed);
164
+ if (json) {
165
+ console.log(JSON.stringify({ checks, healthy }, null, 2));
166
+ return { checks, healthy };
167
+ }
168
+ for (const c of checks) {
169
+ const mark = c.ok ? ok('ok ') : c.fixed ? warn('fixed ') : fail('fail ');
170
+ console.log(`${mark} ${c.name.padEnd(20)} ${c.detail}`);
171
+ }
172
+ console.log('');
173
+ if (healthy) {
174
+ console.log(ok('No problems found.') + (checks.some((c) => c.fixed) ? dim(' (after fixes)') : ''));
175
+ }
176
+ else {
177
+ console.log(fail('Problems found.') + dim(' Re-run with --fix to repair what can be repaired.'));
178
+ }
179
+ return { checks, healthy };
180
+ }
181
+ async function checkGitVersion() {
182
+ const version = await simpleGit().version();
183
+ if (!version.installed) {
184
+ return { name: 'git-version', ok: false, detail: 'git is not installed or not on PATH', fixed: false };
185
+ }
186
+ const okVersion = version.major > MIN_GIT.major ||
187
+ (version.major === MIN_GIT.major && version.minor >= MIN_GIT.minor);
188
+ return {
189
+ name: 'git-version',
190
+ ok: okVersion,
191
+ detail: okVersion
192
+ ? `git ${version.major}.${version.minor} (minimum ${MIN_GIT.major}.${MIN_GIT.minor})`
193
+ : `git ${version.major}.${version.minor} is older than the required ${MIN_GIT.major}.${MIN_GIT.minor} — upgrade git`,
194
+ fixed: false,
195
+ };
196
+ }
197
+ /** Parse `git worktree list --porcelain` into path/branch pairs. */
198
+ async function listGitWorktrees(git) {
199
+ const out = await git.raw(['worktree', 'list', '--porcelain']);
200
+ const result = [];
201
+ let current = null;
202
+ for (const line of out.split('\n')) {
203
+ const trimmed = line.trim();
204
+ if (trimmed.startsWith('worktree ')) {
205
+ if (current)
206
+ result.push(current);
207
+ current = { path: path.resolve(trimmed.slice('worktree '.length)), branch: null };
208
+ }
209
+ else if (trimmed.startsWith('branch refs/heads/') && current) {
210
+ current.branch = trimmed.slice('branch refs/heads/'.length);
211
+ }
212
+ else if (trimmed === '' && current) {
213
+ result.push(current);
214
+ current = null;
215
+ }
216
+ }
217
+ if (current)
218
+ result.push(current);
219
+ return result;
220
+ }
221
+ /** Build a state record for an existing worktree found via `git worktree list`. */
222
+ async function adoptRecord(git, repoRoot, worktreeAbs, branch) {
223
+ return {
224
+ name: path.basename(worktreeAbs),
225
+ branch,
226
+ baseBranch: await fallbackBase(git),
227
+ worktreePath: path.relative(repoRoot, worktreeAbs).split(path.sep).join('/'),
228
+ createdAt: new Date().toISOString(),
229
+ };
230
+ }
231
+ /** Best guess at a base branch when the original one is unknowable. */
232
+ async function fallbackBase(git) {
233
+ try {
234
+ return await defaultBaseBranch(git);
235
+ }
236
+ catch {
237
+ return (await currentBranch(git)) ?? 'main';
238
+ }
239
+ }
240
+ function samePath(a, b) {
241
+ const ra = path.resolve(a);
242
+ const rb = path.resolve(b);
243
+ return process.platform === 'win32' ? ra.toLowerCase() === rb.toLowerCase() : ra === rb;
244
+ }
245
+ function isUnder(child, parent) {
246
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
247
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
248
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { FleetError } from '../lib/errors.js';
3
+ import { dim, fail, warn } from '../lib/format.js';
4
+ import { getMainRepoRoot } from '../lib/git.js';
5
+ import { runShell, shellJoin } from '../lib/proc.js';
6
+ import { getAgent, readState, worktreeAbsPath } from '../lib/state.js';
7
+ /**
8
+ * Run a shell command inside an agent's worktree (or every worktree with
9
+ * --all) without cd'ing there — `fleet exec claude -- npm test`. Under --all
10
+ * the runs are sequential so their output doesn't interleave.
11
+ */
12
+ export async function exec(agentName, cmdTokens, options = {}) {
13
+ const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
14
+ const state = readState(repoRoot);
15
+ if (cmdTokens.length === 0) {
16
+ throw new FleetError('No command given. Usage: fleet exec <agent> -- <command...>');
17
+ }
18
+ let targets;
19
+ if (options.all) {
20
+ targets = Object.values(state.agents).sort((a, b) => a.name.localeCompare(b.name));
21
+ }
22
+ else {
23
+ if (!agentName) {
24
+ throw new FleetError('Pass an agent name (or --all to run in every worktree).');
25
+ }
26
+ targets = [getAgent(state, agentName)];
27
+ }
28
+ const command = shellJoin(cmdTokens);
29
+ if (targets.length === 0) {
30
+ console.log('No active agents. Run `fleet spawn <name>` to create one.');
31
+ return { command, outcomes: [], ok: true };
32
+ }
33
+ const outcomes = [];
34
+ for (const record of targets) {
35
+ const abs = worktreeAbsPath(repoRoot, record);
36
+ if (!existsSync(abs)) {
37
+ if (!options.all) {
38
+ throw new FleetError(`Agent "${record.name}" has no worktree on disk (${abs}).\n` +
39
+ 'Run `fleet doctor --fix` to reconcile state, or `fleet remove` the agent.');
40
+ }
41
+ console.log(warn(`[${record.name}] skipped: worktree missing`));
42
+ outcomes.push({ name: record.name, exitCode: null, skipped: 'worktree-missing' });
43
+ continue;
44
+ }
45
+ console.log(dim(`[${record.name}] $ ${command}`));
46
+ const exitCode = await runShell(command, abs);
47
+ outcomes.push({ name: record.name, exitCode });
48
+ if (exitCode !== 0) {
49
+ console.log(fail(`[${record.name}] exited ${exitCode}`));
50
+ }
51
+ }
52
+ return { command, outcomes, ok: outcomes.every((o) => o.exitCode === 0) };
53
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { dim, relativeTime, table, warn } from '../lib/format.js';
4
+ import { aheadBehind, branchExists, getMainRepoRoot, gitAt, lastCommitISO, uncommittedFiles, } from '../lib/git.js';
5
+ import { readState, worktreeAbsPath } from '../lib/state.js';
6
+ /** Gather the per-agent data `fleet list` displays, without printing anything. */
7
+ export async function collectListings(options = {}) {
8
+ const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
9
+ const state = readState(repoRoot);
10
+ const agents = Object.values(state.agents).sort((a, b) => a.name.localeCompare(b.name));
11
+ // Each agent needs several independent, read-only git calls; running the
12
+ // agents concurrently keeps `fleet list` (and every `fleet watch` frame)
13
+ // fast as the fleet grows. Output order mirrors the sorted agents.
14
+ return Promise.all(agents.map((record) => describeAgent(repoRoot, record)));
15
+ }
16
+ /** Render listings as the `fleet list` table. Shared with `fleet watch`. */
17
+ export function buildListTable(listings) {
18
+ const rows = listings.map((l) => [
19
+ l.name,
20
+ l.branch,
21
+ l.baseBranch,
22
+ l.ahead === null ? '?' : `+${l.ahead}/-${l.behind}`,
23
+ l.worktreeMissing
24
+ ? warn('worktree missing')
25
+ : l.uncommitted === 0
26
+ ? dim('clean')
27
+ : warn(`${l.uncommitted} uncommitted`),
28
+ relativeTime(l.lastActivity),
29
+ l.worktreePath,
30
+ ]);
31
+ return table(['AGENT', 'BRANCH', 'BASE', '+/-', 'CHANGES', 'LAST ACTIVITY', 'WORKTREE'], rows);
32
+ }
33
+ export async function list(options = {}) {
34
+ const listings = await collectListings(options);
35
+ if (options.json) {
36
+ console.log(JSON.stringify(listings, null, 2));
37
+ return listings;
38
+ }
39
+ if (listings.length === 0) {
40
+ console.log('No active agents. Run `fleet spawn <name>` to create one.');
41
+ return [];
42
+ }
43
+ console.log(buildListTable(listings));
44
+ return listings;
45
+ }
46
+ async function describeAgent(repoRoot, record) {
47
+ const git = gitAt(repoRoot);
48
+ const abs = worktreeAbsPath(repoRoot, record);
49
+ const worktreeMissing = !existsSync(abs);
50
+ let ahead = null;
51
+ let behind = null;
52
+ if ((await branchExists(git, record.branch)) && (await branchExists(git, record.baseBranch))) {
53
+ ({ ahead, behind } = await aheadBehind(git, record.baseBranch, record.branch));
54
+ }
55
+ let uncommitted = 0;
56
+ let lastActivity = await lastCommitISO(git, record.branch);
57
+ if (!worktreeMissing) {
58
+ const files = await uncommittedFiles(abs);
59
+ uncommitted = files.length;
60
+ // Uncommitted edits postdate the last commit; use the newest file mtime.
61
+ let newest = lastActivity ? new Date(lastActivity).getTime() : 0;
62
+ for (const f of files) {
63
+ try {
64
+ const mtime = statSync(path.join(abs, f.path)).mtimeMs;
65
+ if (mtime > newest)
66
+ newest = mtime;
67
+ }
68
+ catch {
69
+ // Deleted or renamed while we looked — skip it.
70
+ }
71
+ }
72
+ if (newest > 0)
73
+ lastActivity = new Date(newest).toISOString();
74
+ }
75
+ return {
76
+ name: record.name,
77
+ branch: record.branch,
78
+ baseBranch: record.baseBranch,
79
+ worktreePath: record.worktreePath,
80
+ worktreeMissing,
81
+ ahead,
82
+ behind,
83
+ uncommitted,
84
+ lastActivity,
85
+ };
86
+ }
@@ -0,0 +1,116 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readConfig } from '../lib/config.js';
3
+ import { FleetError } from '../lib/errors.js';
4
+ import { dim, ok, plural, warn } from '../lib/format.js';
5
+ import { currentBranch, deleteBranch, getMainRepoRoot, gitAt, pruneWorktrees, removeWorktree, uncommittedFiles, verifyBranch, } from '../lib/git.js';
6
+ import { runShell } from '../lib/proc.js';
7
+ import { getAgent, readState, worktreeAbsPath, writeState } from '../lib/state.js';
8
+ import { check } from './check.js';
9
+ import { clean } from './clean.js';
10
+ /**
11
+ * Merge an agent's branch into the main worktree's current branch, guarded by
12
+ * a collision check, and clean up the agent on success. Never leaves the repo
13
+ * mid-merge: a conflicted merge is aborted before the error is reported.
14
+ */
15
+ export async function merge(name, options = {}) {
16
+ const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
17
+ const git = gitAt(repoRoot);
18
+ const state = readState(repoRoot);
19
+ const config = readConfig(repoRoot);
20
+ const record = getAgent(state, name);
21
+ await verifyBranch(git, record.branch, 'Agent');
22
+ const doClean = options.clean ?? true;
23
+ if (options.deleteBranch && !doClean) {
24
+ throw new FleetError('--delete-branch and --no-clean contradict each other; pick one.');
25
+ }
26
+ const into = await currentBranch(git);
27
+ if (!into) {
28
+ throw new FleetError('HEAD is detached in the main worktree. Check out the branch you want to merge into first.');
29
+ }
30
+ const mainStatus = await git.status();
31
+ if (mainStatus.conflicted.length > 0) {
32
+ throw new FleetError(`The main worktree already has an unresolved merge (${plural(mainStatus.conflicted.length, 'conflicted file')}). Resolve or abort it first.`);
33
+ }
34
+ // Collision gate: refuse while another still-active agent touches the same
35
+ // files. Reuses `fleet check` (which prints its table) rather than a second
36
+ // implementation of the cross-reference.
37
+ if (Object.keys(state.agents).length >= 2) {
38
+ const { collisions } = await check({ cwd: repoRoot });
39
+ const blocking = collisions.filter((c) => c.agents.includes(name));
40
+ if (blocking.length > 0) {
41
+ const lines = blocking
42
+ .map((c) => ` ${c.file} (${c.agents.filter((a) => a !== name).join(', ')})`)
43
+ .join('\n');
44
+ throw new FleetError(`Refusing to merge "${name}": ${plural(blocking.length, 'file is', 'files are')} also touched by other active agents:\n` +
45
+ `${lines}\n` +
46
+ 'Merge or remove those agents first (or resolve the overlap), then re-run.');
47
+ }
48
+ }
49
+ // preMerge hook: e.g. "npm test" in the agent's worktree. Runs after the
50
+ // collision gate and before the merge starts, so a failure aborts cleanly.
51
+ if (config.preMerge) {
52
+ const hookDir = worktreeAbsPath(repoRoot, record);
53
+ if (existsSync(hookDir)) {
54
+ console.log(dim(`preMerge: ${config.preMerge}`));
55
+ const exitCode = await runShell(config.preMerge, hookDir);
56
+ if (exitCode !== 0) {
57
+ throw new FleetError(`preMerge hook failed (exit ${exitCode}): ${config.preMerge}\n` +
58
+ `The merge was not started — ${into} is unchanged.`);
59
+ }
60
+ }
61
+ else {
62
+ console.log(warn(`preMerge hook skipped: worktree missing (${hookDir})`));
63
+ }
64
+ }
65
+ try {
66
+ await git.merge([record.branch]);
67
+ }
68
+ catch (err) {
69
+ const status = await git.status();
70
+ if (status.conflicted.length > 0) {
71
+ // Never leave the repo mid-merge: abort before reporting.
72
+ await git.raw(['merge', '--abort']);
73
+ throw new FleetError(`Merging ${record.branch} into ${into} conflicts in ${plural(status.conflicted.length, 'file')}:\n` +
74
+ status.conflicted.map((f) => ` ${f}`).join('\n') +
75
+ `\nThe merge was aborted — ${into} is unchanged. Resolve manually with \`git merge ${record.branch}\` when ready.`);
76
+ }
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ throw new FleetError(`git merge failed before starting: ${message}`);
79
+ }
80
+ console.log(ok(`Merged ${record.branch} into ${into}.`));
81
+ let cleaned = false;
82
+ let branchDeleted = false;
83
+ if (!doClean) {
84
+ console.log(dim(` --no-clean: worktree and ${record.branch} kept. Run \`fleet remove ${name} --delete-branch\` later.`));
85
+ }
86
+ else {
87
+ const abs = worktreeAbsPath(repoRoot, record);
88
+ const dirty = existsSync(abs) ? await uncommittedFiles(abs) : [];
89
+ if (dirty.length > 0) {
90
+ // The merge itself succeeded; don't fail it, and never discard work.
91
+ console.log(warn(` worktree kept: ${plural(dirty.length, 'uncommitted change')} in ${abs}`));
92
+ console.log(dim(` commit them, or run \`fleet remove ${name} --force\` to discard; ${record.branch} kept.`));
93
+ }
94
+ else {
95
+ if (existsSync(abs)) {
96
+ await removeWorktree(git, abs, false);
97
+ }
98
+ else {
99
+ await pruneWorktrees(git);
100
+ }
101
+ // Safe force-delete: the branch was just merged into `into`.
102
+ await deleteBranch(git, record.branch, true);
103
+ delete state.agents[name];
104
+ writeState(repoRoot, state);
105
+ cleaned = true;
106
+ branchDeleted = true;
107
+ console.log(` removed worktree and deleted ${record.branch}`);
108
+ }
109
+ }
110
+ let autoCleaned = [];
111
+ if (config.autoClean && doClean) {
112
+ const swept = await clean({ cwd: repoRoot });
113
+ autoCleaned = swept.cleaned.map((c) => c.name);
114
+ }
115
+ return { branch: record.branch, into, cleaned, branchDeleted, autoCleaned };
116
+ }