@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 +21 -0
- package/README.md +165 -0
- package/dist/cli.js +172 -0
- package/dist/commands/check.js +144 -0
- package/dist/commands/clean.js +133 -0
- package/dist/commands/completion.js +126 -0
- package/dist/commands/diff.js +35 -0
- package/dist/commands/doctor.js +248 -0
- package/dist/commands/exec.js +53 -0
- package/dist/commands/list.js +86 -0
- package/dist/commands/merge.js +116 -0
- package/dist/commands/pr.js +50 -0
- package/dist/commands/remove.js +61 -0
- package/dist/commands/spawn.js +105 -0
- package/dist/commands/status.js +46 -0
- package/dist/commands/sync.js +55 -0
- package/dist/commands/watch.js +46 -0
- package/dist/lib/config.js +86 -0
- package/dist/lib/errors.js +10 -0
- package/dist/lib/format.js +44 -0
- package/dist/lib/git.js +142 -0
- package/dist/lib/lines.js +106 -0
- package/dist/lib/proc.js +42 -0
- package/dist/lib/state.js +59 -0
- package/package.json +65 -0
- package/schema/fleetrc.schema.json +46 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { FleetError } from '../lib/errors.js';
|
|
2
|
+
import { dim, ok } from '../lib/format.js';
|
|
3
|
+
import { getMainRepoRoot, gitAt, verifyBranch } from '../lib/git.js';
|
|
4
|
+
import { runFile } from '../lib/proc.js';
|
|
5
|
+
import { getAgent, readState } from '../lib/state.js';
|
|
6
|
+
/**
|
|
7
|
+
* Push an agent's branch to `origin` and open a pull request via the GitHub
|
|
8
|
+
* CLI — the review-based alternative to a local `fleet merge`. gh is invoked
|
|
9
|
+
* as an external binary, never bundled; its availability is verified before
|
|
10
|
+
* anything is pushed.
|
|
11
|
+
*/
|
|
12
|
+
export async function pr(name, options = {}) {
|
|
13
|
+
const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
|
|
14
|
+
const git = gitAt(repoRoot);
|
|
15
|
+
const state = readState(repoRoot);
|
|
16
|
+
const record = getAgent(state, name);
|
|
17
|
+
await verifyBranch(git, record.branch, 'Agent');
|
|
18
|
+
const base = options.base ?? record.baseBranch;
|
|
19
|
+
const remotes = await git.getRemotes();
|
|
20
|
+
if (!remotes.some((r) => r.name === 'origin')) {
|
|
21
|
+
throw new FleetError('No "origin" remote is configured, so there is nowhere to push the branch.\n' +
|
|
22
|
+
'Add one with `git remote add origin <url>` and re-run.');
|
|
23
|
+
}
|
|
24
|
+
// FLEET_GH exists for tests, which substitute a recording stub for the real
|
|
25
|
+
// gh binary (network CLIs can't run against a throwaway repo).
|
|
26
|
+
const [ghBin = 'gh', ...ghPrefix] = (process.env.FLEET_GH ?? 'gh').split(' ');
|
|
27
|
+
if ((await runFile(ghBin, [...ghPrefix, '--version'], repoRoot, { quiet: true })) !== 0) {
|
|
28
|
+
throw new FleetError('GitHub CLI (gh) not found. Install it from https://cli.github.com, ' +
|
|
29
|
+
'or push and open the PR manually:\n' +
|
|
30
|
+
` git push -u origin ${record.branch}`);
|
|
31
|
+
}
|
|
32
|
+
await git.raw(['push', '-u', 'origin', record.branch]);
|
|
33
|
+
console.log(ok(`Pushed ${record.branch} to origin.`));
|
|
34
|
+
const args = [...ghPrefix, 'pr', 'create', '--head', record.branch, '--base', base];
|
|
35
|
+
if (options.title) {
|
|
36
|
+
args.push('--title', options.title, '--body', '');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
args.push('--fill');
|
|
40
|
+
}
|
|
41
|
+
if (options.draft)
|
|
42
|
+
args.push('--draft');
|
|
43
|
+
console.log(dim(`$ ${ghBin} ${args.join(' ')}`));
|
|
44
|
+
const exitCode = await runFile(ghBin, args, repoRoot);
|
|
45
|
+
if (exitCode !== 0) {
|
|
46
|
+
throw new FleetError(`gh pr create failed (exit ${exitCode}). The branch was pushed — ` +
|
|
47
|
+
'you can re-run, or open the PR in the browser.');
|
|
48
|
+
}
|
|
49
|
+
return { branch: record.branch, base, pushed: true, created: true };
|
|
50
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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, pruneWorktrees, removeWorktree, uncommittedFiles, } from '../lib/git.js';
|
|
5
|
+
import { getAgent, readState, worktreeAbsPath, writeState } from '../lib/state.js';
|
|
6
|
+
export async function remove(name, options = {}) {
|
|
7
|
+
const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
|
|
8
|
+
const git = gitAt(repoRoot);
|
|
9
|
+
const state = readState(repoRoot);
|
|
10
|
+
const record = getAgent(state, name);
|
|
11
|
+
const abs = worktreeAbsPath(repoRoot, record);
|
|
12
|
+
const force = options.force ?? false;
|
|
13
|
+
// Validate everything before touching anything, so a refusal leaves the
|
|
14
|
+
// agent fully intact.
|
|
15
|
+
if (existsSync(abs) && !force) {
|
|
16
|
+
const dirty = await uncommittedFiles(abs);
|
|
17
|
+
if (dirty.length > 0) {
|
|
18
|
+
throw new FleetError(`Agent "${name}" has ${plural(dirty.length, 'uncommitted change')} in ${abs}.\n` +
|
|
19
|
+
'Commit them in the worktree first, or pass --force to discard them.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const branchStillExists = await branchExists(git, record.branch);
|
|
23
|
+
if (options.deleteBranch && branchStillExists && !force) {
|
|
24
|
+
const baseExists = await branchExists(git, record.baseBranch);
|
|
25
|
+
const merged = baseExists && (await isMergedInto(git, record.branch, record.baseBranch));
|
|
26
|
+
if (!merged) {
|
|
27
|
+
throw new FleetError(`Branch "${record.branch}" is not fully merged into ${record.baseBranch}.\n` +
|
|
28
|
+
'Merge it first, or pass --force to delete it anyway (this discards its commits).');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let worktreeRemoved = false;
|
|
32
|
+
if (existsSync(abs)) {
|
|
33
|
+
await removeWorktree(git, abs, force);
|
|
34
|
+
worktreeRemoved = true;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
// Directory was deleted manually; clear git's stale bookkeeping so the
|
|
38
|
+
// branch is no longer considered checked out.
|
|
39
|
+
await pruneWorktrees(git);
|
|
40
|
+
}
|
|
41
|
+
let branchDeleted = false;
|
|
42
|
+
if (options.deleteBranch && branchStillExists) {
|
|
43
|
+
// Merge safety was verified above (or --force was passed), so -D is safe here.
|
|
44
|
+
await deleteBranch(git, record.branch, true);
|
|
45
|
+
branchDeleted = true;
|
|
46
|
+
}
|
|
47
|
+
delete state.agents[name];
|
|
48
|
+
writeState(repoRoot, state);
|
|
49
|
+
console.log(ok(`Removed agent ${name}.`));
|
|
50
|
+
console.log(` worktree: ${worktreeRemoved ? 'removed' : 'already gone (pruned)'}`);
|
|
51
|
+
if (branchDeleted) {
|
|
52
|
+
console.log(` branch: ${record.branch} deleted`);
|
|
53
|
+
}
|
|
54
|
+
else if (!branchStillExists) {
|
|
55
|
+
console.log(` branch: ${record.branch} was already deleted`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
console.log(` branch: ${record.branch} kept ${dim('(use --delete-branch to delete it)')}`);
|
|
59
|
+
}
|
|
60
|
+
return { name, worktreeRemoved, branchDeleted };
|
|
61
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readConfig } from '../lib/config.js';
|
|
4
|
+
import { FleetError } from '../lib/errors.js';
|
|
5
|
+
import { bold, dim, ok, warn } from '../lib/format.js';
|
|
6
|
+
import { runShell } from '../lib/proc.js';
|
|
7
|
+
import { addWorktree, branchExists, currentBranch, ensureFleetExcluded, getMainRepoRoot, gitAt, verifyBranch, } from '../lib/git.js';
|
|
8
|
+
import { readState, worktreesDir, writeState } from '../lib/state.js';
|
|
9
|
+
// Names become branch names and directory names, so keep them filesystem- and
|
|
10
|
+
// ref-safe: no slashes, no leading dots, no whitespace.
|
|
11
|
+
const AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
12
|
+
// Windows refuses these as file names (even with an extension), which would
|
|
13
|
+
// break the worktree directory. Rejected on every platform so a fleet set up
|
|
14
|
+
// on macOS/Linux stays usable from Windows checkouts of the same repo.
|
|
15
|
+
const WINDOWS_RESERVED_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
|
|
16
|
+
function validateAgentName(name) {
|
|
17
|
+
if (!AGENT_NAME_RE.test(name)) {
|
|
18
|
+
throw new FleetError(`Invalid agent name "${name}". Use letters, digits, ".", "_" or "-" ` +
|
|
19
|
+
'(max 64 chars, starting with a letter or digit).');
|
|
20
|
+
}
|
|
21
|
+
// git check-ref-format rules the character set alone doesn't catch: catching
|
|
22
|
+
// them here gives a clean error instead of a raw `git worktree add` failure.
|
|
23
|
+
if (name.includes('..') || name.endsWith('.') || name.endsWith('.lock')) {
|
|
24
|
+
throw new FleetError(`Invalid agent name "${name}". It must form a valid branch name: ` +
|
|
25
|
+
'no "..", and no trailing "." or ".lock".');
|
|
26
|
+
}
|
|
27
|
+
if (WINDOWS_RESERVED_RE.test(name)) {
|
|
28
|
+
throw new FleetError(`Invalid agent name "${name}". It is a reserved device name on Windows ` +
|
|
29
|
+
'(CON, PRN, AUX, NUL, COM1-9, LPT1-9) and cannot be a worktree directory.');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function spawn(name, options = {}) {
|
|
33
|
+
validateAgentName(name);
|
|
34
|
+
const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
|
|
35
|
+
const git = gitAt(repoRoot);
|
|
36
|
+
const state = readState(repoRoot);
|
|
37
|
+
const branch = `fleet/${name}`;
|
|
38
|
+
const existing = state.agents[name];
|
|
39
|
+
if (existing) {
|
|
40
|
+
throw new FleetError(`Agent "${name}" already exists (branch ${existing.branch}, worktree ${existing.worktreePath}).\n` +
|
|
41
|
+
`Pick a different name, or remove it first with \`fleet remove ${name}\`.`);
|
|
42
|
+
}
|
|
43
|
+
if (await branchExists(git, branch)) {
|
|
44
|
+
throw new FleetError(`Branch "${branch}" already exists but is not tracked by Switchyard.\n` +
|
|
45
|
+
`Delete it (\`git branch -d ${branch}\`) or pick a different agent name.`);
|
|
46
|
+
}
|
|
47
|
+
const worktreeAbs = path.join(worktreesDir(repoRoot), name);
|
|
48
|
+
if (existsSync(worktreeAbs)) {
|
|
49
|
+
throw new FleetError(`Worktree directory already exists: ${worktreeAbs}\n` +
|
|
50
|
+
'Remove it or pick a different agent name.');
|
|
51
|
+
}
|
|
52
|
+
const config = readConfig(repoRoot);
|
|
53
|
+
// Precedence: --from flag > .fleetrc.json defaultBase > current branch.
|
|
54
|
+
let base = options.from ?? config.defaultBase;
|
|
55
|
+
if (base) {
|
|
56
|
+
await verifyBranch(git, base, 'Base');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
base = (await currentBranch(git)) ?? undefined;
|
|
60
|
+
if (!base) {
|
|
61
|
+
throw new FleetError('HEAD is detached, so there is no branch to base the agent on. ' +
|
|
62
|
+
'Pass one explicitly with --from <branch>.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await ensureFleetExcluded(repoRoot);
|
|
66
|
+
mkdirSync(worktreesDir(repoRoot), { recursive: true });
|
|
67
|
+
await addWorktree(git, worktreeAbs, branch, base);
|
|
68
|
+
const record = {
|
|
69
|
+
name,
|
|
70
|
+
branch,
|
|
71
|
+
baseBranch: base,
|
|
72
|
+
worktreePath: path.relative(repoRoot, worktreeAbs).split(path.sep).join('/'),
|
|
73
|
+
createdAt: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
state.agents[name] = record;
|
|
76
|
+
writeState(repoRoot, state);
|
|
77
|
+
console.log(ok(`Spawned agent ${bold(name)}`));
|
|
78
|
+
console.log(` branch: ${branch} ${dim(`(from ${base})`)}`);
|
|
79
|
+
console.log(` worktree: ${worktreeAbs}`);
|
|
80
|
+
// Provision the worktree: gitignored essentials first, then the hook, so
|
|
81
|
+
// e.g. `postSpawn: "npm ci"` can rely on a copied .npmrc or .env.
|
|
82
|
+
const copied = [];
|
|
83
|
+
for (const entry of config.copyOnSpawn ?? []) {
|
|
84
|
+
const source = path.join(repoRoot, entry);
|
|
85
|
+
if (!existsSync(source)) {
|
|
86
|
+
console.log(dim(` copyOnSpawn: ${entry} not found in the repo root — skipped`));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
cpSync(source, path.join(worktreeAbs, entry), { recursive: true });
|
|
90
|
+
copied.push(entry);
|
|
91
|
+
console.log(` copied: ${entry}`);
|
|
92
|
+
}
|
|
93
|
+
let postSpawnExitCode;
|
|
94
|
+
if (config.postSpawn) {
|
|
95
|
+
console.log(dim(` postSpawn: ${config.postSpawn}`));
|
|
96
|
+
postSpawnExitCode = await runShell(config.postSpawn, worktreeAbs);
|
|
97
|
+
if (postSpawnExitCode !== 0) {
|
|
98
|
+
console.log(warn(` postSpawn hook failed (exit ${postSpawnExitCode}) — the worktree was kept; finish setting it up manually.`));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log('Point your agent at it:');
|
|
103
|
+
console.log(` cd ${worktreeAbs}`);
|
|
104
|
+
return { record, worktreePath: worktreeAbs, copied, postSpawnExitCode };
|
|
105
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { bold, dim, ok, warn } from '../lib/format.js';
|
|
3
|
+
import { aheadBehind, getMainRepoRoot, gitAt, uncommittedFiles, verifyBranch, } from '../lib/git.js';
|
|
4
|
+
import { getAgent, readState, worktreeAbsPath } from '../lib/state.js';
|
|
5
|
+
export async function status(name, options = {}) {
|
|
6
|
+
const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
|
|
7
|
+
const git = gitAt(repoRoot);
|
|
8
|
+
const state = readState(repoRoot);
|
|
9
|
+
const record = getAgent(state, name);
|
|
10
|
+
const abs = worktreeAbsPath(repoRoot, record);
|
|
11
|
+
const worktreeMissing = !existsSync(abs);
|
|
12
|
+
await verifyBranch(git, record.branch, 'Agent');
|
|
13
|
+
await verifyBranch(git, record.baseBranch, 'Base');
|
|
14
|
+
const { ahead, behind } = await aheadBehind(git, record.baseBranch, record.branch);
|
|
15
|
+
const diffStat = (await git.raw(['diff', '--stat', `${record.baseBranch}...${record.branch}`])).trimEnd();
|
|
16
|
+
const uncommitted = worktreeMissing ? [] : await uncommittedFiles(abs);
|
|
17
|
+
const result = { record, ahead, behind, uncommitted, diffStat, worktreeMissing };
|
|
18
|
+
if (options.json) {
|
|
19
|
+
console.log(JSON.stringify(result, null, 2));
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
console.log(bold(`Agent ${record.name}`));
|
|
23
|
+
console.log(` branch: ${record.branch}`);
|
|
24
|
+
console.log(` base: ${record.baseBranch} (${ahead} ahead, ${behind} behind)`);
|
|
25
|
+
console.log(` worktree: ${abs}${worktreeMissing ? ` ${warn('(missing)')}` : ''}`);
|
|
26
|
+
console.log(` created: ${record.createdAt}`);
|
|
27
|
+
console.log('');
|
|
28
|
+
if (uncommitted.length > 0) {
|
|
29
|
+
console.log(bold(`Uncommitted changes (${uncommitted.length}):`));
|
|
30
|
+
for (const f of uncommitted) {
|
|
31
|
+
console.log(` ${warn(f.status.padEnd(2))} ${f.path}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (!worktreeMissing) {
|
|
35
|
+
console.log(ok('Working tree clean.'));
|
|
36
|
+
}
|
|
37
|
+
console.log('');
|
|
38
|
+
if (diffStat) {
|
|
39
|
+
console.log(bold(`Committed changes vs ${record.baseBranch}:`));
|
|
40
|
+
console.log(diffStat);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log(dim(`No committed changes vs ${record.baseBranch} yet.`));
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { FleetError } from '../lib/errors.js';
|
|
3
|
+
import { ok, plural } from '../lib/format.js';
|
|
4
|
+
import { aheadBehind, getMainRepoRoot, gitAt, verifyBranch } from '../lib/git.js';
|
|
5
|
+
import { getAgent, readState, worktreeAbsPath } from '../lib/state.js';
|
|
6
|
+
/**
|
|
7
|
+
* Catch an agent's branch up with its base by merging the base into the
|
|
8
|
+
* agent's worktree. Same safety contract as `fleet merge`: a conflicted merge
|
|
9
|
+
* is aborted before the error is reported, never left half-done.
|
|
10
|
+
*/
|
|
11
|
+
export async function sync(name, options = {}) {
|
|
12
|
+
const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd());
|
|
13
|
+
const git = gitAt(repoRoot);
|
|
14
|
+
const state = readState(repoRoot);
|
|
15
|
+
const record = getAgent(state, name);
|
|
16
|
+
await verifyBranch(git, record.branch, 'Agent');
|
|
17
|
+
await verifyBranch(git, record.baseBranch, 'Base');
|
|
18
|
+
const abs = worktreeAbsPath(repoRoot, record);
|
|
19
|
+
if (!existsSync(abs)) {
|
|
20
|
+
throw new FleetError(`Agent "${name}" has no worktree on disk (${abs}).\n` +
|
|
21
|
+
'Run `fleet doctor --fix` to reconcile state, or `fleet remove` the agent.');
|
|
22
|
+
}
|
|
23
|
+
const wtGit = gitAt(abs);
|
|
24
|
+
const wtStatus = await wtGit.status();
|
|
25
|
+
if (wtStatus.conflicted.length > 0) {
|
|
26
|
+
throw new FleetError(`Agent "${name}" has an unresolved merge in its worktree. Resolve or abort it first.`);
|
|
27
|
+
}
|
|
28
|
+
if (wtStatus.files.length > 0) {
|
|
29
|
+
throw new FleetError(`Agent "${name}" has ${plural(wtStatus.files.length, 'uncommitted change')} in ${abs}.\n` +
|
|
30
|
+
'Commit them in the worktree first, then re-run `fleet sync`.');
|
|
31
|
+
}
|
|
32
|
+
const { behind } = await aheadBehind(git, record.baseBranch, record.branch);
|
|
33
|
+
if (behind === 0) {
|
|
34
|
+
console.log(ok(`${record.branch} is already up to date with ${record.baseBranch}.`));
|
|
35
|
+
return { name, branch: record.branch, base: record.baseBranch, behind: 0, updated: false };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await wtGit.merge([record.baseBranch]);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const status = await wtGit.status();
|
|
42
|
+
if (status.conflicted.length > 0) {
|
|
43
|
+
// Never leave the worktree mid-merge: abort before reporting.
|
|
44
|
+
await wtGit.raw(['merge', '--abort']);
|
|
45
|
+
throw new FleetError(`Merging ${record.baseBranch} into ${record.branch} conflicts in ${plural(status.conflicted.length, 'file')}:\n` +
|
|
46
|
+
status.conflicted.map((f) => ` ${f}`).join('\n') +
|
|
47
|
+
`\nThe merge was aborted — ${record.branch} is unchanged. Resolve manually inside ${abs} when ready.`);
|
|
48
|
+
}
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
throw new FleetError(`git merge failed before starting: ${message}`);
|
|
51
|
+
}
|
|
52
|
+
console.log(ok(`Merged ${record.baseBranch} into ${record.branch}`) +
|
|
53
|
+
` (caught up ${plural(behind, 'commit')}).`);
|
|
54
|
+
return { name, branch: record.branch, base: record.baseBranch, behind, updated: true };
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { DEFAULT_WATCH_INTERVAL, readConfig } from '../lib/config.js';
|
|
2
|
+
import { FleetError } from '../lib/errors.js';
|
|
3
|
+
import { CLEAR_SCREEN, dim } from '../lib/format.js';
|
|
4
|
+
import { getMainRepoRoot } from '../lib/git.js';
|
|
5
|
+
import { buildListTable, collectListings } from './list.js';
|
|
6
|
+
/** Precedence: --interval flag > .fleetrc.json watchInterval > built-in default. */
|
|
7
|
+
export function resolveWatchInterval(flag, config) {
|
|
8
|
+
if (flag !== undefined && (!Number.isFinite(flag) || flag <= 0)) {
|
|
9
|
+
throw new FleetError('Invalid --interval. Pass a positive number of seconds, e.g. --interval 5.');
|
|
10
|
+
}
|
|
11
|
+
return flag ?? config.watchInterval ?? DEFAULT_WATCH_INTERVAL;
|
|
12
|
+
}
|
|
13
|
+
/** One frame of `fleet watch`: a header plus the exact table `fleet list` prints. */
|
|
14
|
+
export async function renderWatchFrame(options = {}, now = new Date()) {
|
|
15
|
+
const listings = await collectListings(options);
|
|
16
|
+
const header = dim(`fleet watch — ${now.toISOString().slice(11, 19)} UTC — Ctrl+C to exit`);
|
|
17
|
+
const body = listings.length === 0
|
|
18
|
+
? 'No active agents. Run `fleet spawn <name>` to create one.'
|
|
19
|
+
: buildListTable(listings);
|
|
20
|
+
return `${header}\n${body}`;
|
|
21
|
+
}
|
|
22
|
+
/** Re-render `fleet list` on an interval until Ctrl+C. */
|
|
23
|
+
export async function watch(options = {}) {
|
|
24
|
+
const cwd = options.cwd ?? process.cwd();
|
|
25
|
+
const repoRoot = await getMainRepoRoot(cwd);
|
|
26
|
+
const config = readConfig(repoRoot);
|
|
27
|
+
const intervalSeconds = resolveWatchInterval(options.interval, config);
|
|
28
|
+
const render = async () => {
|
|
29
|
+
const frame = await renderWatchFrame({ cwd });
|
|
30
|
+
process.stdout.write(CLEAR_SCREEN);
|
|
31
|
+
console.log(frame);
|
|
32
|
+
console.log(dim(`refreshing every ${intervalSeconds}s`));
|
|
33
|
+
};
|
|
34
|
+
await render();
|
|
35
|
+
// Chained timeouts rather than setInterval, so a render that takes longer
|
|
36
|
+
// than the interval can never overlap the next one. Resolves never; the
|
|
37
|
+
// process ends on Ctrl+C (or the promise rejects on a render error).
|
|
38
|
+
return new Promise((_resolve, reject) => {
|
|
39
|
+
const scheduleNext = () => {
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
render().then(scheduleNext, reject);
|
|
42
|
+
}, intervalSeconds * 1000);
|
|
43
|
+
};
|
|
44
|
+
scheduleNext();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { FleetError } from './errors.js';
|
|
4
|
+
export const CONFIG_FILE = '.fleetrc.json';
|
|
5
|
+
export const DEFAULT_WATCH_INTERVAL = 3;
|
|
6
|
+
export const DEFAULT_AUTO_CLEAN = false;
|
|
7
|
+
const VALID_KEYS = 'defaultBase, watchInterval, autoClean, copyOnSpawn, postSpawn, preMerge';
|
|
8
|
+
export function configPath(repoRoot) {
|
|
9
|
+
return path.join(repoRoot, CONFIG_FILE);
|
|
10
|
+
}
|
|
11
|
+
/** Read and validate `.fleetrc.json`. A missing file is fine; a broken one is not. */
|
|
12
|
+
export function readConfig(repoRoot) {
|
|
13
|
+
const file = configPath(repoRoot);
|
|
14
|
+
if (!existsSync(file)) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
// Strip a UTF-8 BOM: Windows editors add one and JSON.parse rejects it.
|
|
18
|
+
const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new FleetError(`Config file is not valid JSON: ${file}\nFix the syntax or delete the file to fall back to defaults.`);
|
|
25
|
+
}
|
|
26
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
27
|
+
throw new FleetError(`Config file must contain a JSON object: ${file}`);
|
|
28
|
+
}
|
|
29
|
+
const config = {};
|
|
30
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
31
|
+
switch (key) {
|
|
32
|
+
case '$schema':
|
|
33
|
+
// Editor-only pointer to a JSON schema (shipped as schema/fleetrc.schema.json).
|
|
34
|
+
if (typeof value !== 'string') {
|
|
35
|
+
throw new FleetError(`"$schema" in ${CONFIG_FILE} must be a string (schema URL).`);
|
|
36
|
+
}
|
|
37
|
+
break;
|
|
38
|
+
case 'defaultBase':
|
|
39
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
40
|
+
throw new FleetError(`"defaultBase" in ${CONFIG_FILE} must be a non-empty string (branch name).`);
|
|
41
|
+
}
|
|
42
|
+
config.defaultBase = value;
|
|
43
|
+
break;
|
|
44
|
+
case 'watchInterval':
|
|
45
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
46
|
+
throw new FleetError(`"watchInterval" in ${CONFIG_FILE} must be a positive number of seconds.`);
|
|
47
|
+
}
|
|
48
|
+
config.watchInterval = value;
|
|
49
|
+
break;
|
|
50
|
+
case 'autoClean':
|
|
51
|
+
if (typeof value !== 'boolean') {
|
|
52
|
+
throw new FleetError(`"autoClean" in ${CONFIG_FILE} must be true or false.`);
|
|
53
|
+
}
|
|
54
|
+
config.autoClean = value;
|
|
55
|
+
break;
|
|
56
|
+
case 'copyOnSpawn': {
|
|
57
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
58
|
+
throw new FleetError(`"copyOnSpawn" in ${CONFIG_FILE} must be an array of non-empty path strings.`);
|
|
59
|
+
}
|
|
60
|
+
const entries = value;
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) {
|
|
63
|
+
throw new FleetError(`"copyOnSpawn" entry "${entry}" in ${CONFIG_FILE} must be a relative path inside the repository.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
config.copyOnSpawn = entries;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'postSpawn':
|
|
70
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
71
|
+
throw new FleetError(`"postSpawn" in ${CONFIG_FILE} must be a non-empty command string.`);
|
|
72
|
+
}
|
|
73
|
+
config.postSpawn = value;
|
|
74
|
+
break;
|
|
75
|
+
case 'preMerge':
|
|
76
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
77
|
+
throw new FleetError(`"preMerge" in ${CONFIG_FILE} must be a non-empty command string.`);
|
|
78
|
+
}
|
|
79
|
+
config.preMerge = value;
|
|
80
|
+
break;
|
|
81
|
+
default:
|
|
82
|
+
throw new FleetError(`Unknown key "${key}" in ${CONFIG_FILE}. Valid keys: ${VALID_KEYS}.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return config;
|
|
86
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An expected, user-facing failure (bad input, missing agent, dirty worktree, …).
|
|
3
|
+
* The CLI prints the message in red and exits 1 — no stack trace.
|
|
4
|
+
*/
|
|
5
|
+
export class FleetError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'FleetError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
export function table(head, rows) {
|
|
4
|
+
const t = new Table({
|
|
5
|
+
head: head.map((h) => chalk.bold(h)),
|
|
6
|
+
style: { head: [], border: [] },
|
|
7
|
+
wordWrap: true,
|
|
8
|
+
});
|
|
9
|
+
t.push(...rows);
|
|
10
|
+
return t.toString();
|
|
11
|
+
}
|
|
12
|
+
/** ANSI clear screen + scrollback + cursor home; used between `fleet watch` frames. */
|
|
13
|
+
export const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
|
|
14
|
+
export const ok = (msg) => chalk.green(msg);
|
|
15
|
+
export const warn = (msg) => chalk.yellow(msg);
|
|
16
|
+
export const fail = (msg) => chalk.red(msg);
|
|
17
|
+
export const dim = (msg) => chalk.dim(msg);
|
|
18
|
+
export const bold = (msg) => chalk.bold(msg);
|
|
19
|
+
export function plural(n, singular, pluralForm = `${singular}s`) {
|
|
20
|
+
return `${n} ${n === 1 ? singular : pluralForm}`;
|
|
21
|
+
}
|
|
22
|
+
/** "just now", "5m ago", "3h ago", "2d ago", then a date for anything older. */
|
|
23
|
+
export function relativeTime(iso, now = new Date()) {
|
|
24
|
+
if (!iso)
|
|
25
|
+
return '—';
|
|
26
|
+
const then = new Date(iso);
|
|
27
|
+
if (Number.isNaN(then.getTime()))
|
|
28
|
+
return '—';
|
|
29
|
+
const seconds = Math.round((now.getTime() - then.getTime()) / 1000);
|
|
30
|
+
if (seconds < 0)
|
|
31
|
+
return then.toISOString();
|
|
32
|
+
if (seconds < 60)
|
|
33
|
+
return 'just now';
|
|
34
|
+
const minutes = Math.round(seconds / 60);
|
|
35
|
+
if (minutes < 60)
|
|
36
|
+
return `${minutes}m ago`;
|
|
37
|
+
const hours = Math.round(minutes / 60);
|
|
38
|
+
if (hours < 24)
|
|
39
|
+
return `${hours}h ago`;
|
|
40
|
+
const days = Math.round(hours / 24);
|
|
41
|
+
if (days < 30)
|
|
42
|
+
return `${days}d ago`;
|
|
43
|
+
return then.toISOString().slice(0, 10);
|
|
44
|
+
}
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { simpleGit } from 'simple-git';
|
|
4
|
+
import { FleetError } from './errors.js';
|
|
5
|
+
export function gitAt(dir) {
|
|
6
|
+
return simpleGit({ baseDir: dir });
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the root of the *main* repository from any directory inside it —
|
|
10
|
+
* including from inside a linked worktree, so every `fleet` command works when
|
|
11
|
+
* run from `.fleet/worktrees/<agent>/`.
|
|
12
|
+
*/
|
|
13
|
+
export async function getMainRepoRoot(cwd) {
|
|
14
|
+
let out;
|
|
15
|
+
try {
|
|
16
|
+
out = await simpleGit({ baseDir: cwd }).raw([
|
|
17
|
+
'rev-parse',
|
|
18
|
+
'--path-format=absolute',
|
|
19
|
+
'--show-toplevel',
|
|
20
|
+
'--git-common-dir',
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new FleetError(`Not inside a git repository: ${cwd}\n` +
|
|
25
|
+
'Switchyard manages worktrees of an existing repository. ' +
|
|
26
|
+
'`cd` into one (or run `git init`) and try again.');
|
|
27
|
+
}
|
|
28
|
+
const [toplevel, commonDir] = out
|
|
29
|
+
.trim()
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.trim());
|
|
32
|
+
if (!toplevel) {
|
|
33
|
+
throw new FleetError(`Could not resolve the repository root from ${cwd}.`);
|
|
34
|
+
}
|
|
35
|
+
// In a linked worktree, --show-toplevel is the worktree itself; the main
|
|
36
|
+
// repo root is the parent of the shared .git directory.
|
|
37
|
+
if (commonDir && path.basename(commonDir) === '.git') {
|
|
38
|
+
return path.resolve(path.dirname(commonDir));
|
|
39
|
+
}
|
|
40
|
+
return path.resolve(toplevel);
|
|
41
|
+
}
|
|
42
|
+
export async function branchExists(git, branch) {
|
|
43
|
+
// Decide from stdout, not the exit code: quiet git commands exit non-zero
|
|
44
|
+
// with empty stderr, which simple-git does not surface as an error.
|
|
45
|
+
const out = await git.raw(['branch', '--list', branch, '--format=%(refname:short)']);
|
|
46
|
+
return out.trim().length > 0;
|
|
47
|
+
}
|
|
48
|
+
export async function verifyBranch(git, branch, label) {
|
|
49
|
+
if (!(await branchExists(git, branch))) {
|
|
50
|
+
throw new FleetError(`${label} branch "${branch}" does not exist in this repository.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Current branch name, or null when HEAD is detached. */
|
|
54
|
+
export async function currentBranch(git) {
|
|
55
|
+
const name = (await git.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
56
|
+
return name === 'HEAD' ? null : name;
|
|
57
|
+
}
|
|
58
|
+
/** Fall back to main/master when an agent has no recorded base branch. */
|
|
59
|
+
export async function defaultBaseBranch(git) {
|
|
60
|
+
for (const candidate of ['main', 'master']) {
|
|
61
|
+
if (await branchExists(git, candidate))
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
throw new FleetError('Could not find a "main" or "master" branch to diff against. Pass one explicitly with --base <branch>.');
|
|
65
|
+
}
|
|
66
|
+
export async function addWorktree(git, worktreePath, branch, base) {
|
|
67
|
+
await git.raw(['worktree', 'add', worktreePath, '-b', branch, base]);
|
|
68
|
+
}
|
|
69
|
+
export async function removeWorktree(git, worktreePath, force) {
|
|
70
|
+
const args = ['worktree', 'remove'];
|
|
71
|
+
if (force)
|
|
72
|
+
args.push('--force');
|
|
73
|
+
args.push(worktreePath);
|
|
74
|
+
await git.raw(args);
|
|
75
|
+
}
|
|
76
|
+
/** Clear git's bookkeeping for worktrees whose directories were deleted manually. */
|
|
77
|
+
export async function pruneWorktrees(git) {
|
|
78
|
+
await git.raw(['worktree', 'prune']);
|
|
79
|
+
}
|
|
80
|
+
export async function deleteBranch(git, branch, force) {
|
|
81
|
+
await git.raw(['branch', force ? '-D' : '-d', branch]);
|
|
82
|
+
}
|
|
83
|
+
/** Commits on `branch` not on `base` (ahead) and vice versa (behind). */
|
|
84
|
+
export async function aheadBehind(git, base, branch) {
|
|
85
|
+
const out = await git.raw(['rev-list', '--left-right', '--count', `${base}...${branch}`]);
|
|
86
|
+
const parts = out.trim().split(/\s+/);
|
|
87
|
+
return { behind: Number(parts[0] ?? 0), ahead: Number(parts[1] ?? 0) };
|
|
88
|
+
}
|
|
89
|
+
/** Files changed by commits on `branch` since it diverged from `base` (merge-base diff). */
|
|
90
|
+
export async function changedFilesVsBase(git, base, branch) {
|
|
91
|
+
const out = await git.raw(['diff', '--name-only', `${base}...${branch}`]);
|
|
92
|
+
return out
|
|
93
|
+
.split('\n')
|
|
94
|
+
.map((line) => line.trim())
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
/** Modified, staged, and untracked files in a worktree. */
|
|
98
|
+
export async function uncommittedFiles(worktreeDir) {
|
|
99
|
+
const status = await gitAt(worktreeDir).status();
|
|
100
|
+
return status.files.map((f) => ({
|
|
101
|
+
path: f.path,
|
|
102
|
+
status: `${f.index}${f.working_dir}`.trim(),
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
/** ISO timestamp of the last commit on `ref`, or null if the ref has no commits. */
|
|
106
|
+
export async function lastCommitISO(git, ref) {
|
|
107
|
+
try {
|
|
108
|
+
const out = await git.raw(['log', '-1', '--format=%cI', ref, '--']);
|
|
109
|
+
return out.trim() || null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** True when every commit on `branch` is already reachable from `base`. */
|
|
116
|
+
export async function isMergedInto(git, branch, base) {
|
|
117
|
+
// Not `merge-base --is-ancestor`: it answers via exit code with empty
|
|
118
|
+
// stderr, which simple-git does not surface as an error. Count commits on
|
|
119
|
+
// `branch` that `base` can't reach instead — zero means fully merged.
|
|
120
|
+
const out = await git.raw(['rev-list', '--count', `${base}..${branch}`]);
|
|
121
|
+
return Number(out.trim()) === 0;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Ensure `.fleet/` is ignored via `.git/info/exclude` so Switchyard never dirties
|
|
125
|
+
* the repos it manages — even ones whose .gitignore doesn't mention it.
|
|
126
|
+
*/
|
|
127
|
+
export async function ensureFleetExcluded(repoRoot) {
|
|
128
|
+
const commonDirRaw = await gitAt(repoRoot).raw([
|
|
129
|
+
'rev-parse',
|
|
130
|
+
'--path-format=absolute',
|
|
131
|
+
'--git-common-dir',
|
|
132
|
+
]);
|
|
133
|
+
const infoDir = path.join(commonDirRaw.trim(), 'info');
|
|
134
|
+
const excludeFile = path.join(infoDir, 'exclude');
|
|
135
|
+
const entry = '.fleet/';
|
|
136
|
+
const current = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf8') : '';
|
|
137
|
+
if (current.split(/\r?\n/).some((line) => line.trim() === entry))
|
|
138
|
+
return;
|
|
139
|
+
mkdirSync(infoDir, { recursive: true });
|
|
140
|
+
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
141
|
+
appendFileSync(excludeFile, `${prefix}# added by fleet\n${entry}\n`, 'utf8');
|
|
142
|
+
}
|