@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,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-range utilities for `fleet check --lines`: parse `git diff -U0` output
|
|
3
|
+
* into per-file ranges and intersect them across agents.
|
|
4
|
+
*
|
|
5
|
+
* All ranges are 1-indexed, inclusive, and expressed in *old-side* (merge-base)
|
|
6
|
+
* coordinates — the only coordinate system two agents' diffs share when both
|
|
7
|
+
* branched from the same base commit. When bases have diverged the ranges are
|
|
8
|
+
* a best-effort heuristic, which is why line-level checking is opt-in.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Parse unified diff output (`git diff -U0`) into a map of file path (new
|
|
12
|
+
* side; old side for deletions) to edited old-side line ranges. A pure
|
|
13
|
+
* insertion (`-N,0`) is widened to [N, N+1] so that two agents inserting at
|
|
14
|
+
* the same spot still count as overlapping.
|
|
15
|
+
*/
|
|
16
|
+
export function parseUnifiedDiff(text) {
|
|
17
|
+
const result = new Map();
|
|
18
|
+
let current = null;
|
|
19
|
+
let aPath = null;
|
|
20
|
+
let headerPath = null;
|
|
21
|
+
const pathFrom = (raw) => {
|
|
22
|
+
const trimmed = raw.trim();
|
|
23
|
+
if (trimmed === '/dev/null')
|
|
24
|
+
return null;
|
|
25
|
+
return trimmed.replace(/^[ab]\//, '');
|
|
26
|
+
};
|
|
27
|
+
for (const line of text.split('\n')) {
|
|
28
|
+
const header = /^diff --git a\/(.*) b\//.exec(line);
|
|
29
|
+
if (header) {
|
|
30
|
+
current = null;
|
|
31
|
+
aPath = null;
|
|
32
|
+
headerPath = header[1] ?? null;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (line.startsWith('--- ')) {
|
|
36
|
+
aPath = pathFrom(line.slice(4));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (line.startsWith('+++ ')) {
|
|
40
|
+
current = pathFrom(line.slice(4)) ?? aPath;
|
|
41
|
+
if (current && !result.has(current))
|
|
42
|
+
result.set(current, []);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (line.startsWith('Binary files ')) {
|
|
46
|
+
const file = current ?? headerPath;
|
|
47
|
+
if (file)
|
|
48
|
+
result.set(file, 'whole');
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const hunk = /^@@ -(\d+)(?:,(\d+))? \+/.exec(line);
|
|
52
|
+
if (hunk && current) {
|
|
53
|
+
const entry = result.get(current);
|
|
54
|
+
if (!entry || entry === 'whole')
|
|
55
|
+
continue;
|
|
56
|
+
const start = Number(hunk[1]);
|
|
57
|
+
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
58
|
+
entry.push(count === 0 ? { start, end: start + 1 } : { start, end: start + count - 1 });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
/** Sort and merge overlapping/adjacent ranges into a minimal list. */
|
|
64
|
+
export function mergeRanges(ranges) {
|
|
65
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const r of sorted) {
|
|
68
|
+
const last = out[out.length - 1];
|
|
69
|
+
if (last && r.start <= last.end + 1) {
|
|
70
|
+
last.end = Math.max(last.end, r.end);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
out.push({ ...r });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Intersect the edited ranges of several agents for one file.
|
|
80
|
+
* Returns 'whole' when any agent's line info is unknowable, otherwise the
|
|
81
|
+
* (possibly empty) union of pairwise intersections — empty means the agents
|
|
82
|
+
* edited disjoint lines.
|
|
83
|
+
*/
|
|
84
|
+
export function rangesOverlap(entries) {
|
|
85
|
+
if (entries.some((e) => e === 'whole'))
|
|
86
|
+
return 'whole';
|
|
87
|
+
const lists = entries;
|
|
88
|
+
const hits = [];
|
|
89
|
+
for (let i = 0; i < lists.length; i += 1) {
|
|
90
|
+
for (let j = i + 1; j < lists.length; j += 1) {
|
|
91
|
+
for (const a of lists[i] ?? []) {
|
|
92
|
+
for (const b of lists[j] ?? []) {
|
|
93
|
+
const start = Math.max(a.start, b.start);
|
|
94
|
+
const end = Math.min(a.end, b.end);
|
|
95
|
+
if (start <= end)
|
|
96
|
+
hits.push({ start, end });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return mergeRanges(hits);
|
|
102
|
+
}
|
|
103
|
+
/** "3-7, 12" style rendering for a range list. */
|
|
104
|
+
export function formatRanges(ranges) {
|
|
105
|
+
return ranges.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`)).join(', ');
|
|
106
|
+
}
|
package/dist/lib/proc.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Run a shell command string in `cwd`, streaming its output to the terminal.
|
|
4
|
+
* Resolves with the exit code — a failing command is an expected outcome for
|
|
5
|
+
* callers (hooks, `fleet exec`), not an exception.
|
|
6
|
+
*/
|
|
7
|
+
export function runShell(command, cwd) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawnChild(command, { cwd, shell: true, stdio: 'inherit' });
|
|
10
|
+
child.on('error', reject);
|
|
11
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Run an executable with an argv array (no shell). Resolves with the exit
|
|
16
|
+
* code, or -1 when the executable could not be started at all (e.g. not on
|
|
17
|
+
* PATH) — callers turn that into a "not installed" message.
|
|
18
|
+
*/
|
|
19
|
+
export function runFile(file, args, cwd, opts = {}) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const child = spawnChild(file, args, {
|
|
22
|
+
cwd,
|
|
23
|
+
shell: false,
|
|
24
|
+
stdio: opts.quiet ? 'ignore' : 'inherit',
|
|
25
|
+
});
|
|
26
|
+
child.on('error', () => resolve(-1));
|
|
27
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// Tokens that survive both sh and cmd.exe without quoting.
|
|
31
|
+
const SAFE_TOKEN = /^[A-Za-z0-9_\-./:=@,+]+$/;
|
|
32
|
+
/**
|
|
33
|
+
* Join argv tokens back into one shell command line, quoting anything that
|
|
34
|
+
* needs it. A pragmatic heuristic that covers everyday commands on both sh
|
|
35
|
+
* and cmd.exe — not a full shell-escaping engine; genuinely exotic quoting
|
|
36
|
+
* belongs in a script file the command can call instead.
|
|
37
|
+
*/
|
|
38
|
+
export function shellJoin(tokens) {
|
|
39
|
+
return tokens
|
|
40
|
+
.map((t) => (SAFE_TOKEN.test(t) ? t : `"${t.replace(/"/g, '\\"')}"`))
|
|
41
|
+
.join(' ');
|
|
42
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { FleetError } from './errors.js';
|
|
4
|
+
export function fleetDir(repoRoot) {
|
|
5
|
+
return path.join(repoRoot, '.fleet');
|
|
6
|
+
}
|
|
7
|
+
export function statePath(repoRoot) {
|
|
8
|
+
return path.join(fleetDir(repoRoot), 'state.json');
|
|
9
|
+
}
|
|
10
|
+
export function worktreesDir(repoRoot) {
|
|
11
|
+
return path.join(fleetDir(repoRoot), 'worktrees');
|
|
12
|
+
}
|
|
13
|
+
export function readState(repoRoot) {
|
|
14
|
+
const file = statePath(repoRoot);
|
|
15
|
+
if (!existsSync(file)) {
|
|
16
|
+
return { version: 1, agents: {} };
|
|
17
|
+
}
|
|
18
|
+
// Strip a UTF-8 BOM: Windows editors add one and JSON.parse rejects it.
|
|
19
|
+
const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
parsed = null;
|
|
26
|
+
}
|
|
27
|
+
if (parsed === null ||
|
|
28
|
+
typeof parsed !== 'object' ||
|
|
29
|
+
typeof parsed.agents !== 'object' ||
|
|
30
|
+
parsed.agents === null) {
|
|
31
|
+
throw new FleetError(`Switchyard state file is corrupted: ${file}\n` +
|
|
32
|
+
'Fix or delete it, then re-run. Your worktrees and branches are not affected; ' +
|
|
33
|
+
'you may need to `fleet spawn` agents again to re-register them.');
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
export function writeState(repoRoot, state) {
|
|
38
|
+
mkdirSync(fleetDir(repoRoot), { recursive: true });
|
|
39
|
+
const file = statePath(repoRoot);
|
|
40
|
+
// Write-then-rename so a crash mid-write never leaves a half-written state file.
|
|
41
|
+
const tmpFile = `${file}.tmp`;
|
|
42
|
+
writeFileSync(tmpFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
43
|
+
renameSync(tmpFile, file);
|
|
44
|
+
}
|
|
45
|
+
/** Look up an agent by name, with a helpful error listing known agents. */
|
|
46
|
+
export function getAgent(state, name) {
|
|
47
|
+
const record = state.agents[name];
|
|
48
|
+
if (!record) {
|
|
49
|
+
const known = Object.keys(state.agents).sort();
|
|
50
|
+
const hint = known.length > 0
|
|
51
|
+
? `Known agents: ${known.join(', ')}.`
|
|
52
|
+
: 'No agents are registered. Run `fleet spawn <name>` to create one.';
|
|
53
|
+
throw new FleetError(`No agent named "${name}". ${hint}`);
|
|
54
|
+
}
|
|
55
|
+
return record;
|
|
56
|
+
}
|
|
57
|
+
export function worktreeAbsPath(repoRoot, record) {
|
|
58
|
+
return path.resolve(repoRoot, record.worktreePath);
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@switchyardhq/git-fleet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Manage multiple AI coding agents working on the same git repository: isolated worktrees per agent, collision detection before merge, safe cleanup.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"git",
|
|
7
|
+
"worktree",
|
|
8
|
+
"ai-agents",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex",
|
|
11
|
+
"cursor",
|
|
12
|
+
"cli",
|
|
13
|
+
"developer-tools"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Mohammed Alkindi <alkndymhmd692@gmail.com>",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/MohammedAlkindi/Switchyard.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": "https://github.com/MohammedAlkindi/Switchyard/issues",
|
|
22
|
+
"homepage": "https://github.com/MohammedAlkindi/Switchyard#readme",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"fleet": "dist/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"main": "dist/cli.js",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"schema"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.17"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"lint": "eslint .",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"test:coverage": "vitest run --coverage",
|
|
45
|
+
"prepare": "npm run build",
|
|
46
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"chalk": "^5.3.0",
|
|
50
|
+
"cli-table3": "^0.6.5",
|
|
51
|
+
"commander": "^12.1.0",
|
|
52
|
+
"simple-git": "^3.27.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@eslint/js": "^9.9.0",
|
|
56
|
+
"@types/node": "^22.5.0",
|
|
57
|
+
"@types/tmp": "^0.2.6",
|
|
58
|
+
"@vitest/coverage-v8": "^2.1.9",
|
|
59
|
+
"eslint": "^9.9.0",
|
|
60
|
+
"tmp": "^0.2.3",
|
|
61
|
+
"typescript": "^5.5.4",
|
|
62
|
+
"typescript-eslint": "^8.2.0",
|
|
63
|
+
"vitest": "^2.1.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://unpkg.com/@switchyardhq/git-fleet/schema/fleetrc.schema.json",
|
|
4
|
+
"title": ".fleetrc.json",
|
|
5
|
+
"description": "Per-repo defaults for Switchyard (@switchyardhq/git-fleet). Precedence everywhere: CLI flag > .fleetrc.json > built-in default.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "Pointer to this schema, for editor autocomplete and validation."
|
|
12
|
+
},
|
|
13
|
+
"defaultBase": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"minLength": 1,
|
|
16
|
+
"description": "Default base branch for `fleet spawn` when --from is not passed (built-in default: the current branch)."
|
|
17
|
+
},
|
|
18
|
+
"watchInterval": {
|
|
19
|
+
"type": "number",
|
|
20
|
+
"exclusiveMinimum": 0,
|
|
21
|
+
"description": "Default refresh interval for `fleet watch`, in seconds (built-in default: 3)."
|
|
22
|
+
},
|
|
23
|
+
"autoClean": {
|
|
24
|
+
"type": "boolean",
|
|
25
|
+
"description": "Run a `fleet clean` sweep after every successful `fleet merge` (built-in default: false)."
|
|
26
|
+
},
|
|
27
|
+
"copyOnSpawn": {
|
|
28
|
+
"type": "array",
|
|
29
|
+
"items": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"minLength": 1
|
|
32
|
+
},
|
|
33
|
+
"description": "Repo-root-relative files/directories copied into every new worktree by `fleet spawn` — the gitignored essentials a fresh worktree lacks (.env, …). Entries must stay inside the repository."
|
|
34
|
+
},
|
|
35
|
+
"postSpawn": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"minLength": 1,
|
|
38
|
+
"description": "Shell command run inside a new worktree after `fleet spawn` (e.g. \"npm ci\"). A failing hook is reported but the worktree is kept."
|
|
39
|
+
},
|
|
40
|
+
"preMerge": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"minLength": 1,
|
|
43
|
+
"description": "Shell command run inside the agent's worktree before `fleet merge` starts (e.g. \"npm test\"). A non-zero exit aborts the merge."
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|