claude-code-merge-queue 0.1.14
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 +260 -0
- package/dist/bin/claude-code-local-merge.d.ts +2 -0
- package/dist/bin/claude-code-local-merge.js +316 -0
- package/dist/bin/lanekeeper.d.ts +2 -0
- package/dist/bin/lanekeeper.js +307 -0
- package/dist/bin/localmerge.d.ts +2 -0
- package/dist/bin/localmerge.js +316 -0
- package/dist/bin/mergequeue.d.ts +2 -0
- package/dist/bin/mergequeue.js +307 -0
- package/dist/build-lock.d.ts +1 -0
- package/dist/build-lock.js +70 -0
- package/dist/hooks/worktree-create.d.ts +15 -0
- package/dist/hooks/worktree-create.js +192 -0
- package/dist/land.d.ts +1 -0
- package/dist/land.js +144 -0
- package/dist/lib/check-command.d.ts +29 -0
- package/dist/lib/check-command.js +83 -0
- package/dist/lib/check-push.d.ts +35 -0
- package/dist/lib/check-push.js +46 -0
- package/dist/lib/claude-md-snippet.d.ts +16 -0
- package/dist/lib/claude-md-snippet.js +18 -0
- package/dist/lib/config.d.ts +92 -0
- package/dist/lib/config.js +137 -0
- package/dist/lib/ephemeral.d.ts +40 -0
- package/dist/lib/ephemeral.js +100 -0
- package/dist/lib/lane-port.d.ts +3 -0
- package/dist/lib/lane-port.js +25 -0
- package/dist/lib/main-checkout.d.ts +1 -0
- package/dist/lib/main-checkout.js +19 -0
- package/dist/lib/prune-lanes.d.ts +36 -0
- package/dist/lib/prune-lanes.js +196 -0
- package/dist/lib/queue-lock.d.ts +26 -0
- package/dist/lib/queue-lock.js +212 -0
- package/dist/lib/tty-confirm.d.ts +1 -0
- package/dist/lib/tty-confirm.js +44 -0
- package/dist/lib/wire-hooks.d.ts +62 -0
- package/dist/lib/wire-hooks.js +230 -0
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +119 -0
- package/dist/promote.d.ts +1 -0
- package/dist/promote.js +77 -0
- package/dist/sync.d.ts +16 -0
- package/dist/sync.js +161 -0
- package/examples/claude-code-local-merge.config.mjs +67 -0
- package/examples/ephemeral-tmp-dir.example.ts +66 -0
- package/hooks/claude-settings.example.json +14 -0
- package/hooks/pre-push +23 -0
- package/package.json +46 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The lanekeeper CLI. Every subcommand reads lanekeeper.config from the
|
|
4
|
+
* current repo — see src/lib/config.ts — so none of this is hardcoded to
|
|
5
|
+
* any one project's branch names.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { land } from "../land.js";
|
|
9
|
+
import { sync } from "../sync.js";
|
|
10
|
+
import { promote } from "../promote.js";
|
|
11
|
+
import { runPreview } from "../preview.js";
|
|
12
|
+
import { buildLock } from "../build-lock.js";
|
|
13
|
+
import { findRepoRoot, hasConfig, loadConfig, detectCurrentBranch, DEFAULTS } from "../lib/config.js";
|
|
14
|
+
import { checkPush, parseRefUpdates } from "../lib/check-push.js";
|
|
15
|
+
import { runWorktreeCreateHook } from "../hooks/worktree-create.js";
|
|
16
|
+
import { lanePort } from "../lib/lane-port.js";
|
|
17
|
+
import { claudeMdSnippet, MARKER } from "../lib/claude-md-snippet.js";
|
|
18
|
+
import { detectCheckCommand, runCheckCommand } from "../lib/check-command.js";
|
|
19
|
+
import { wireClaudeSettings, wireHuskyPrePush, ensureHooksPath, wirePackageJsonScripts } from "../lib/wire-hooks.js";
|
|
20
|
+
import { resolveMainCheckout } from "../lib/main-checkout.js";
|
|
21
|
+
import { pruneLandedLanes } from "../lib/prune-lanes.js";
|
|
22
|
+
const [, , command, ...rest] = process.argv;
|
|
23
|
+
async function readStdin() {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
for await (const chunk of process.stdin)
|
|
26
|
+
chunks.push(chunk);
|
|
27
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
28
|
+
}
|
|
29
|
+
async function init() {
|
|
30
|
+
const root = findRepoRoot();
|
|
31
|
+
if (!root) {
|
|
32
|
+
console.error("lanekeeper init: not inside a git repo.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const { writeFileSync, readFileSync: read, existsSync, appendFileSync } = await import("node:fs");
|
|
36
|
+
const { join } = await import("node:path");
|
|
37
|
+
// What actually got newly written or modified this run — only these need
|
|
38
|
+
// committing. If everything below was already wired from a previous run,
|
|
39
|
+
// there's nothing new to tell you to commit.
|
|
40
|
+
const writtenFiles = [];
|
|
41
|
+
if (hasConfig(root)) {
|
|
42
|
+
console.log("lanekeeper init: lanekeeper.config.mjs already exists — leaving it alone.");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
writtenFiles.push("lanekeeper.config.mjs");
|
|
46
|
+
const detectedBranch = detectCurrentBranch(root);
|
|
47
|
+
const detectedCheck = detectCheckCommand(root);
|
|
48
|
+
const generated = {
|
|
49
|
+
...DEFAULTS,
|
|
50
|
+
...(detectedBranch ? { integrationBranch: detectedBranch } : {}),
|
|
51
|
+
...(detectedCheck ? { checkCommand: detectedCheck } : {}),
|
|
52
|
+
};
|
|
53
|
+
const template = `// lanekeeper.config.mjs — generated by \`lanekeeper init\`. Edit freely.
|
|
54
|
+
// Its presence here is what turns LaneKeeper ON for this repo.
|
|
55
|
+
|
|
56
|
+
/** @type {import("lanekeeper").LaneKeeperConfig} */
|
|
57
|
+
export default ${JSON.stringify(generated, null, 2)};
|
|
58
|
+
`;
|
|
59
|
+
writeFileSync(join(root, "lanekeeper.config.mjs"), template);
|
|
60
|
+
console.log(`lanekeeper init: wrote ${join(root, "lanekeeper.config.mjs")}`);
|
|
61
|
+
if (detectedBranch && detectedBranch !== DEFAULTS.integrationBranch) {
|
|
62
|
+
console.log(` (detected current branch "${detectedBranch}" — set as integrationBranch instead of the "${DEFAULTS.integrationBranch}" default)`);
|
|
63
|
+
}
|
|
64
|
+
else if (!detectedBranch) {
|
|
65
|
+
console.log("");
|
|
66
|
+
console.log(` ⚠️ Couldn't detect the current branch (detached HEAD?) — integrationBranch`);
|
|
67
|
+
console.log(` defaulted to "${DEFAULTS.integrationBranch}". Verify that's actually right`);
|
|
68
|
+
console.log(` in lanekeeper.config.mjs before committing it.`);
|
|
69
|
+
}
|
|
70
|
+
if (detectedCheck) {
|
|
71
|
+
console.log(` (detected "${detectedCheck}" from package.json — set as checkCommand)`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log(" ⚠️ No checkCommand detected (no check:push/check/ci/test script found in");
|
|
76
|
+
console.log(" package.json). Every push is BLOCKED until you set one — or set");
|
|
77
|
+
console.log(" checksRequired: false to deliberately run with no checks.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// This is the part that actually makes agents use the workflow without a
|
|
81
|
+
// human reminding them every session — see claude-md-snippet.ts.
|
|
82
|
+
const claudeMdPath = join(root, "CLAUDE.md");
|
|
83
|
+
const cfg = hasConfig(root) ? await loadConfig(root) : DEFAULTS;
|
|
84
|
+
const snippet = claudeMdSnippet(cfg);
|
|
85
|
+
if (!existsSync(claudeMdPath)) {
|
|
86
|
+
writeFileSync(claudeMdPath, `# Project instructions for Claude Code\n\n${snippet}`);
|
|
87
|
+
console.log(`lanekeeper init: wrote ${claudeMdPath}`);
|
|
88
|
+
writtenFiles.push("CLAUDE.md");
|
|
89
|
+
}
|
|
90
|
+
else if (!read(claudeMdPath, "utf8").includes(MARKER)) {
|
|
91
|
+
appendFileSync(claudeMdPath, `\n${snippet}`);
|
|
92
|
+
console.log(`lanekeeper init: appended the LaneKeeper workflow section to ${claudeMdPath}`);
|
|
93
|
+
writtenFiles.push("CLAUDE.md");
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log(`lanekeeper init: ${claudeMdPath} already has the LaneKeeper workflow section — leaving it alone.`);
|
|
97
|
+
}
|
|
98
|
+
const claudeSettingsResult = wireClaudeSettings(root);
|
|
99
|
+
switch (claudeSettingsResult) {
|
|
100
|
+
case "created":
|
|
101
|
+
console.log(`lanekeeper init: wrote ${join(root, ".claude", "settings.json")} with the WorktreeCreate hook.`);
|
|
102
|
+
writtenFiles.push(".claude/settings.json");
|
|
103
|
+
break;
|
|
104
|
+
case "merged":
|
|
105
|
+
console.log(`lanekeeper init: added the WorktreeCreate hook to your existing ${join(root, ".claude", "settings.json")}.`);
|
|
106
|
+
writtenFiles.push(".claude/settings.json");
|
|
107
|
+
break;
|
|
108
|
+
case "already-wired":
|
|
109
|
+
console.log("lanekeeper init: .claude/settings.json already has the WorktreeCreate hook — leaving it alone.");
|
|
110
|
+
break;
|
|
111
|
+
case "unparseable":
|
|
112
|
+
console.log("lanekeeper init: .claude/settings.json exists but isn't valid JSON — left untouched. Wire it manually,");
|
|
113
|
+
console.log(" see node_modules/lanekeeper/hooks/claude-settings.example.json.");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
const prePushResult = wireHuskyPrePush(root);
|
|
117
|
+
switch (prePushResult) {
|
|
118
|
+
case "created":
|
|
119
|
+
console.log("lanekeeper init: wrote .husky/pre-push.");
|
|
120
|
+
writtenFiles.push(".husky/pre-push");
|
|
121
|
+
break;
|
|
122
|
+
case "merged":
|
|
123
|
+
console.log("lanekeeper init: appended LaneKeeper's checks to your existing .husky/pre-push.");
|
|
124
|
+
writtenFiles.push(".husky/pre-push");
|
|
125
|
+
break;
|
|
126
|
+
case "already-wired":
|
|
127
|
+
console.log("lanekeeper init: .husky/pre-push already wired — leaving it alone.");
|
|
128
|
+
break;
|
|
129
|
+
case "no-husky":
|
|
130
|
+
console.log("lanekeeper init: no .husky/ directory found — pre-push hook NOT wired automatically.");
|
|
131
|
+
console.log(" Install Husky, or copy node_modules/lanekeeper/hooks/pre-push to .git/hooks/pre-push");
|
|
132
|
+
console.log(" yourself (note: .git/hooks isn't version-controlled — only Husky's is shared with your team).");
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (prePushResult === "created" || prePushResult === "merged" || prePushResult === "already-wired") {
|
|
136
|
+
// A .husky/pre-push file enforces nothing until core.hooksPath actually
|
|
137
|
+
// points at .husky — normally a side effect of the package manager's
|
|
138
|
+
// install step, which may not have run yet (e.g. a fresh clone, right
|
|
139
|
+
// where Quickstart leaves you). Without this, a direct push sails
|
|
140
|
+
// through uncontested with no indication anything's wrong.
|
|
141
|
+
switch (ensureHooksPath(root)) {
|
|
142
|
+
case "set":
|
|
143
|
+
console.log("lanekeeper init: set core.hooksPath=.husky so the pre-push hook actually runs (normally set by your package manager's install step, which may not have run yet).");
|
|
144
|
+
break;
|
|
145
|
+
case "already-set":
|
|
146
|
+
break; // the common case once installed — nothing to say
|
|
147
|
+
case "custom-path":
|
|
148
|
+
console.log("lanekeeper init: core.hooksPath is set to something other than .husky — leaving it alone.");
|
|
149
|
+
console.log(" Wrote .husky/pre-push, but it won't run until your hooks path points there. Reconcile this yourself.");
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const scriptsResult = wirePackageJsonScripts(root);
|
|
154
|
+
switch (scriptsResult.result) {
|
|
155
|
+
case "added":
|
|
156
|
+
console.log(`lanekeeper init: added "${scriptsResult.added.join('", "')}" to package.json scripts.`);
|
|
157
|
+
writtenFiles.push("package.json");
|
|
158
|
+
break;
|
|
159
|
+
case "already-wired":
|
|
160
|
+
console.log("lanekeeper init: package.json already has all five scripts — leaving them alone.");
|
|
161
|
+
break;
|
|
162
|
+
case "no-package-json":
|
|
163
|
+
console.log("lanekeeper init: no package.json found — scripts NOT wired automatically.");
|
|
164
|
+
console.log(' Add "land"/"sync"/"promote"/"preview"/"preview:restore" -> "lanekeeper <name>" yourself.');
|
|
165
|
+
break;
|
|
166
|
+
case "unparseable":
|
|
167
|
+
console.log("lanekeeper init: package.json exists but isn't valid JSON — left untouched. Wire the scripts manually.");
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
console.log("");
|
|
171
|
+
console.log("Next steps:");
|
|
172
|
+
if (writtenFiles.length > 0) {
|
|
173
|
+
console.log(` 1. Commit what it wrote — ${writtenFiles.join(", ")}.`);
|
|
174
|
+
console.log(" 2. claude --worktree <name> — the agent takes it from there.");
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
console.log(" 1. claude --worktree <name> — the agent takes it from there.");
|
|
178
|
+
console.log(" (everything was already wired — nothing new to commit)");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function main() {
|
|
182
|
+
switch (command) {
|
|
183
|
+
case "init":
|
|
184
|
+
return init();
|
|
185
|
+
case "hook": {
|
|
186
|
+
const sub = rest[0];
|
|
187
|
+
if (sub === "worktree-create")
|
|
188
|
+
return runWorktreeCreateHook();
|
|
189
|
+
console.error(`lanekeeper hook: unknown hook "${sub ?? ""}". Only "worktree-create" is supported.`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
case "port": {
|
|
194
|
+
const root = findRepoRoot();
|
|
195
|
+
if (!root || !hasConfig(root)) {
|
|
196
|
+
console.error("lanekeeper port: no lanekeeper.config found — not a lane.");
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const cfg = await loadConfig(root);
|
|
200
|
+
const port = lanePort(process.cwd(), cfg);
|
|
201
|
+
if (port === null) {
|
|
202
|
+
console.error("lanekeeper port: current directory doesn't look like a lane worktree.");
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
console.log(port);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
case "land":
|
|
209
|
+
return land();
|
|
210
|
+
case "sync":
|
|
211
|
+
process.exit(await sync());
|
|
212
|
+
return;
|
|
213
|
+
case "promote":
|
|
214
|
+
process.exit(await promote());
|
|
215
|
+
return;
|
|
216
|
+
case "prune": {
|
|
217
|
+
// `land` already does this automatically as a side effect of every
|
|
218
|
+
// successful landing — this is the on-demand escape hatch for
|
|
219
|
+
// "clean these up right now" instead of waiting for the next lane to
|
|
220
|
+
// land something. Same safety rules apply: only already-merged
|
|
221
|
+
// branches, never anything with a live process inside, never the
|
|
222
|
+
// worktree this command itself is running from.
|
|
223
|
+
const root = findRepoRoot();
|
|
224
|
+
if (!root || !hasConfig(root)) {
|
|
225
|
+
console.error("lanekeeper prune: no lanekeeper.config found — nothing to do.");
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
const cfg = await loadConfig(root);
|
|
229
|
+
const mainTop = resolveMainCheckout(process.cwd());
|
|
230
|
+
const pruned = pruneLandedLanes(mainTop, cfg, process.cwd());
|
|
231
|
+
if (pruned.length === 0) {
|
|
232
|
+
console.log("lanekeeper prune: nothing to clean up — no already-landed sibling lanes found.");
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const names = pruned.map((p) => p.split("/").pop()).join(", ");
|
|
236
|
+
console.log(`lanekeeper prune: removed ${pruned.length} already-landed lane${pruned.length === 1 ? "" : "s"}: ${names}`);
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
case "preview":
|
|
241
|
+
return runPreview(rest);
|
|
242
|
+
case "build-lock": {
|
|
243
|
+
const parts = rest[0] === "--" ? rest.slice(1) : rest;
|
|
244
|
+
return buildLock(parts);
|
|
245
|
+
}
|
|
246
|
+
case "check-push": {
|
|
247
|
+
const root = findRepoRoot();
|
|
248
|
+
if (!root || !hasConfig(root)) {
|
|
249
|
+
// No config means LaneKeeper isn't enabled for this repo — nothing to enforce.
|
|
250
|
+
process.exit(0);
|
|
251
|
+
}
|
|
252
|
+
const cfg = await loadConfig(root);
|
|
253
|
+
const stdin = await readStdin();
|
|
254
|
+
const refUpdates = parseRefUpdates(stdin);
|
|
255
|
+
// git still invokes pre-push (with empty stdin) for a push that
|
|
256
|
+
// updates zero refs — e.g. re-pushing a branch that's already exactly
|
|
257
|
+
// up to date on the remote. There's nothing to block (nothing is
|
|
258
|
+
// actually changing on the remote either way) and nothing meaningful
|
|
259
|
+
// for checkCommand to verify — running it anyway just produces a
|
|
260
|
+
// confusing, unrelated failure (a missing devDependency, say) that
|
|
261
|
+
// looks exactly like a real block but isn't one. Recognize "nothing
|
|
262
|
+
// to push" explicitly instead of silently falling through both checks.
|
|
263
|
+
if (refUpdates.length === 0) {
|
|
264
|
+
console.log("lanekeeper check-push: nothing being pushed (already up to date) — skipping checks.");
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
if (process.env.LANEKEEPER_EMERGENCY_PUSH === "1") {
|
|
268
|
+
console.log("\n🚨 LANEKEEPER_EMERGENCY_PUSH is set — bypassing the landing queue.\n");
|
|
269
|
+
}
|
|
270
|
+
const result = checkPush(refUpdates, cfg, process.env);
|
|
271
|
+
if (!result.ok) {
|
|
272
|
+
console.error(result.message);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
process.exit(runCheckCommand(cfg, root));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
case "--version":
|
|
279
|
+
case "-v": {
|
|
280
|
+
const { join, dirname } = await import("node:path");
|
|
281
|
+
const { fileURLToPath } = await import("node:url");
|
|
282
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
283
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
284
|
+
console.log(pkg.version);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
default:
|
|
288
|
+
console.log(`lanekeeper — keep parallel coding agents in their lane.
|
|
289
|
+
|
|
290
|
+
Usage:
|
|
291
|
+
lanekeeper init write a starter lanekeeper.config.mjs
|
|
292
|
+
lanekeeper land rebase + push this lane onto the integration branch (queued)
|
|
293
|
+
lanekeeper sync fast-forward the MAIN checkout to its upstream
|
|
294
|
+
lanekeeper promote ship the integration branch to production (human-only — never script this)
|
|
295
|
+
lanekeeper preview [--restore] swap the MAIN checkout to this lane's working tree, or restore it
|
|
296
|
+
lanekeeper build-lock -- <cmd> run <cmd>, serialized across every lane
|
|
297
|
+
lanekeeper port print this lane's dev-server port
|
|
298
|
+
lanekeeper hook worktree-create (Claude Code WorktreeCreate hook — not for direct use)
|
|
299
|
+
lanekeeper check-push (used by the pre-push hook — not for direct use)
|
|
300
|
+
`);
|
|
301
|
+
process.exit(command ? 1 : 0);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
main().catch((err) => {
|
|
305
|
+
console.error(err instanceof Error ? err.message : err);
|
|
306
|
+
process.exit(1);
|
|
307
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The localmerge CLI. Every subcommand reads localmerge.config from the
|
|
4
|
+
* current repo — see src/lib/config.ts — so none of this is hardcoded to
|
|
5
|
+
* any one project's branch names.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { land } from "../land.js";
|
|
9
|
+
import { sync } from "../sync.js";
|
|
10
|
+
import { promote } from "../promote.js";
|
|
11
|
+
import { runPreview } from "../preview.js";
|
|
12
|
+
import { buildLock } from "../build-lock.js";
|
|
13
|
+
import { findRepoRoot, hasConfig, loadConfig, detectCurrentBranch, DEFAULTS } from "../lib/config.js";
|
|
14
|
+
import { checkPush, parseRefUpdates } from "../lib/check-push.js";
|
|
15
|
+
import { runWorktreeCreateHook } from "../hooks/worktree-create.js";
|
|
16
|
+
import { lanePort } from "../lib/lane-port.js";
|
|
17
|
+
import { claudeMdSnippet, MARKER } from "../lib/claude-md-snippet.js";
|
|
18
|
+
import { detectCheckCommand, runCheckCommand } from "../lib/check-command.js";
|
|
19
|
+
import { wireClaudeSettings, wireHuskyPrePush, ensureHooksPath, wirePackageJsonScripts, wirePreflightScript, PREFLIGHT_FILENAME } from "../lib/wire-hooks.js";
|
|
20
|
+
import { resolveMainCheckout } from "../lib/main-checkout.js";
|
|
21
|
+
import { pruneLandedLanes } from "../lib/prune-lanes.js";
|
|
22
|
+
const [, , command, ...rest] = process.argv;
|
|
23
|
+
async function readStdin() {
|
|
24
|
+
const chunks = [];
|
|
25
|
+
for await (const chunk of process.stdin)
|
|
26
|
+
chunks.push(chunk);
|
|
27
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
28
|
+
}
|
|
29
|
+
async function init() {
|
|
30
|
+
const root = findRepoRoot();
|
|
31
|
+
if (!root) {
|
|
32
|
+
console.error("localmerge init: not inside a git repo.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const { writeFileSync, readFileSync: read, existsSync, appendFileSync } = await import("node:fs");
|
|
36
|
+
const { join } = await import("node:path");
|
|
37
|
+
// What actually got newly written or modified this run — only these need
|
|
38
|
+
// committing. If everything below was already wired from a previous run,
|
|
39
|
+
// there's nothing new to tell you to commit.
|
|
40
|
+
const writtenFiles = [];
|
|
41
|
+
if (hasConfig(root)) {
|
|
42
|
+
console.log("localmerge init: localmerge.config.mjs already exists — leaving it alone.");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
writtenFiles.push("localmerge.config.mjs");
|
|
46
|
+
const detectedBranch = detectCurrentBranch(root);
|
|
47
|
+
const detectedCheck = detectCheckCommand(root);
|
|
48
|
+
const generated = {
|
|
49
|
+
...DEFAULTS,
|
|
50
|
+
...(detectedBranch ? { integrationBranch: detectedBranch } : {}),
|
|
51
|
+
...(detectedCheck ? { checkCommand: detectedCheck } : {}),
|
|
52
|
+
};
|
|
53
|
+
const template = `// localmerge.config.mjs — generated by \`localmerge init\`. Edit freely.
|
|
54
|
+
// Its presence here is what turns LocalMerge ON for this repo.
|
|
55
|
+
|
|
56
|
+
/** @type {import("localmerge").LocalMergeConfig} */
|
|
57
|
+
export default ${JSON.stringify(generated, null, 2)};
|
|
58
|
+
`;
|
|
59
|
+
writeFileSync(join(root, "localmerge.config.mjs"), template);
|
|
60
|
+
console.log(`localmerge init: wrote ${join(root, "localmerge.config.mjs")}`);
|
|
61
|
+
if (detectedBranch && detectedBranch !== DEFAULTS.integrationBranch) {
|
|
62
|
+
console.log(` (detected current branch "${detectedBranch}" — set as integrationBranch instead of the "${DEFAULTS.integrationBranch}" default)`);
|
|
63
|
+
}
|
|
64
|
+
else if (!detectedBranch) {
|
|
65
|
+
console.log("");
|
|
66
|
+
console.log(` ⚠️ Couldn't detect the current branch (detached HEAD?) — integrationBranch`);
|
|
67
|
+
console.log(` defaulted to "${DEFAULTS.integrationBranch}". Verify that's actually right`);
|
|
68
|
+
console.log(` in localmerge.config.mjs before committing it.`);
|
|
69
|
+
}
|
|
70
|
+
if (detectedCheck) {
|
|
71
|
+
console.log(` (detected "${detectedCheck}" from package.json — set as checkCommand)`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log(" ⚠️ No checkCommand detected (no check:push/check/ci/test script found in");
|
|
76
|
+
console.log(" package.json). Every push is BLOCKED until you set one — or set");
|
|
77
|
+
console.log(" checksRequired: false to deliberately run with no checks.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// This is the part that actually makes agents use the workflow without a
|
|
81
|
+
// human reminding them every session — see claude-md-snippet.ts.
|
|
82
|
+
const claudeMdPath = join(root, "CLAUDE.md");
|
|
83
|
+
const cfg = hasConfig(root) ? await loadConfig(root) : DEFAULTS;
|
|
84
|
+
const snippet = claudeMdSnippet(cfg);
|
|
85
|
+
if (!existsSync(claudeMdPath)) {
|
|
86
|
+
writeFileSync(claudeMdPath, `# Project instructions for Claude Code\n\n${snippet}`);
|
|
87
|
+
console.log(`localmerge init: wrote ${claudeMdPath}`);
|
|
88
|
+
writtenFiles.push("CLAUDE.md");
|
|
89
|
+
}
|
|
90
|
+
else if (!read(claudeMdPath, "utf8").includes(MARKER)) {
|
|
91
|
+
appendFileSync(claudeMdPath, `\n${snippet}`);
|
|
92
|
+
console.log(`localmerge init: appended the LocalMerge workflow section to ${claudeMdPath}`);
|
|
93
|
+
writtenFiles.push("CLAUDE.md");
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log(`localmerge init: ${claudeMdPath} already has the LocalMerge workflow section — leaving it alone.`);
|
|
97
|
+
}
|
|
98
|
+
const claudeSettingsResult = wireClaudeSettings(root);
|
|
99
|
+
switch (claudeSettingsResult) {
|
|
100
|
+
case "created":
|
|
101
|
+
console.log(`localmerge init: wrote ${join(root, ".claude", "settings.json")} with the WorktreeCreate hook.`);
|
|
102
|
+
writtenFiles.push(".claude/settings.json");
|
|
103
|
+
break;
|
|
104
|
+
case "merged":
|
|
105
|
+
console.log(`localmerge init: added the WorktreeCreate hook to your existing ${join(root, ".claude", "settings.json")}.`);
|
|
106
|
+
writtenFiles.push(".claude/settings.json");
|
|
107
|
+
break;
|
|
108
|
+
case "already-wired":
|
|
109
|
+
console.log("localmerge init: .claude/settings.json already has the WorktreeCreate hook — leaving it alone.");
|
|
110
|
+
break;
|
|
111
|
+
case "unparseable":
|
|
112
|
+
console.log("localmerge init: .claude/settings.json exists but isn't valid JSON — left untouched. Wire it manually,");
|
|
113
|
+
console.log(" see node_modules/localmerge/hooks/claude-settings.example.json.");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
const prePushResult = wireHuskyPrePush(root);
|
|
117
|
+
switch (prePushResult) {
|
|
118
|
+
case "created":
|
|
119
|
+
console.log("localmerge init: wrote .husky/pre-push.");
|
|
120
|
+
writtenFiles.push(".husky/pre-push");
|
|
121
|
+
break;
|
|
122
|
+
case "merged":
|
|
123
|
+
console.log("localmerge init: appended LocalMerge's checks to your existing .husky/pre-push.");
|
|
124
|
+
writtenFiles.push(".husky/pre-push");
|
|
125
|
+
break;
|
|
126
|
+
case "already-wired":
|
|
127
|
+
console.log("localmerge init: .husky/pre-push already wired — leaving it alone.");
|
|
128
|
+
break;
|
|
129
|
+
case "no-husky":
|
|
130
|
+
console.log("localmerge init: no .husky/ directory found — pre-push hook NOT wired automatically.");
|
|
131
|
+
console.log(" Install Husky, or copy node_modules/localmerge/hooks/pre-push to .git/hooks/pre-push");
|
|
132
|
+
console.log(" yourself (note: .git/hooks isn't version-controlled — only Husky's is shared with your team).");
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (prePushResult === "created" || prePushResult === "merged" || prePushResult === "already-wired") {
|
|
136
|
+
// A .husky/pre-push file enforces nothing until core.hooksPath actually
|
|
137
|
+
// points at .husky — normally a side effect of the package manager's
|
|
138
|
+
// install step, which may not have run yet (e.g. a fresh clone, right
|
|
139
|
+
// where Quickstart leaves you). Without this, a direct push sails
|
|
140
|
+
// through uncontested with no indication anything's wrong.
|
|
141
|
+
switch (ensureHooksPath(root)) {
|
|
142
|
+
case "set":
|
|
143
|
+
console.log("localmerge init: set core.hooksPath=.husky so the pre-push hook actually runs (normally set by your package manager's install step, which may not have run yet).");
|
|
144
|
+
break;
|
|
145
|
+
case "already-set":
|
|
146
|
+
break; // the common case once installed — nothing to say
|
|
147
|
+
case "custom-path":
|
|
148
|
+
console.log("localmerge init: core.hooksPath is set to something other than .husky — leaving it alone.");
|
|
149
|
+
console.log(" Wrote .husky/pre-push, but it won't run until your hooks path points there. Reconcile this yourself.");
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
switch (wirePreflightScript(root, cfg.integrationBranch)) {
|
|
154
|
+
case "created":
|
|
155
|
+
console.log(`localmerge init: wrote ${PREFLIGHT_FILENAME} (a self-contained pre-check "land"/"sync" run before landing — see comments in the file).`);
|
|
156
|
+
writtenFiles.push(PREFLIGHT_FILENAME);
|
|
157
|
+
break;
|
|
158
|
+
case "already-exists":
|
|
159
|
+
break; // don't overwrite — re-run init after changing integrationBranch if it needs updating
|
|
160
|
+
}
|
|
161
|
+
const scriptsResult = wirePackageJsonScripts(root);
|
|
162
|
+
switch (scriptsResult.result) {
|
|
163
|
+
case "added":
|
|
164
|
+
console.log(`localmerge init: added "${scriptsResult.added.join('", "')}" to package.json scripts.`);
|
|
165
|
+
writtenFiles.push("package.json");
|
|
166
|
+
break;
|
|
167
|
+
case "already-wired":
|
|
168
|
+
console.log("localmerge init: package.json already has all five scripts — leaving them alone.");
|
|
169
|
+
break;
|
|
170
|
+
case "no-package-json":
|
|
171
|
+
console.log("localmerge init: no package.json found — scripts NOT wired automatically.");
|
|
172
|
+
console.log(' Add "land"/"sync"/"promote"/"preview"/"preview:restore" -> "localmerge <name>", plus');
|
|
173
|
+
console.log(` "preland"/"presync" -> "node ${PREFLIGHT_FILENAME} land"/"sync" yourself.`);
|
|
174
|
+
break;
|
|
175
|
+
case "unparseable":
|
|
176
|
+
console.log("localmerge init: package.json exists but isn't valid JSON — left untouched. Wire the scripts manually.");
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
console.log("");
|
|
180
|
+
console.log("Next steps:");
|
|
181
|
+
if (writtenFiles.length > 0) {
|
|
182
|
+
console.log(` 1. Commit what it wrote — ${writtenFiles.join(", ")}.`);
|
|
183
|
+
console.log(" 2. claude --worktree <name> — the agent takes it from there.");
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.log(" 1. claude --worktree <name> — the agent takes it from there.");
|
|
187
|
+
console.log(" (everything was already wired — nothing new to commit)");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function main() {
|
|
191
|
+
switch (command) {
|
|
192
|
+
case "init":
|
|
193
|
+
return init();
|
|
194
|
+
case "hook": {
|
|
195
|
+
const sub = rest[0];
|
|
196
|
+
if (sub === "worktree-create")
|
|
197
|
+
return runWorktreeCreateHook();
|
|
198
|
+
console.error(`localmerge hook: unknown hook "${sub ?? ""}". Only "worktree-create" is supported.`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case "port": {
|
|
203
|
+
const root = findRepoRoot();
|
|
204
|
+
if (!root || !hasConfig(root)) {
|
|
205
|
+
console.error("localmerge port: no localmerge.config found — not a lane.");
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
const cfg = await loadConfig(root);
|
|
209
|
+
const port = lanePort(process.cwd(), cfg);
|
|
210
|
+
if (port === null) {
|
|
211
|
+
console.error("localmerge port: current directory doesn't look like a lane worktree.");
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
console.log(port);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
case "land":
|
|
218
|
+
return land();
|
|
219
|
+
case "sync":
|
|
220
|
+
process.exit(await sync());
|
|
221
|
+
return;
|
|
222
|
+
case "promote":
|
|
223
|
+
process.exit(await promote());
|
|
224
|
+
return;
|
|
225
|
+
case "prune": {
|
|
226
|
+
// `land` already does this automatically as a side effect of every
|
|
227
|
+
// successful landing — this is the on-demand escape hatch for
|
|
228
|
+
// "clean these up right now" instead of waiting for the next lane to
|
|
229
|
+
// land something. Same safety rules apply: only already-merged
|
|
230
|
+
// branches, never anything with a live process inside, never the
|
|
231
|
+
// worktree this command itself is running from.
|
|
232
|
+
const root = findRepoRoot();
|
|
233
|
+
if (!root || !hasConfig(root)) {
|
|
234
|
+
console.error("localmerge prune: no localmerge.config found — nothing to do.");
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
const cfg = await loadConfig(root);
|
|
238
|
+
const mainTop = resolveMainCheckout(process.cwd());
|
|
239
|
+
const pruned = pruneLandedLanes(mainTop, cfg, process.cwd());
|
|
240
|
+
if (pruned.length === 0) {
|
|
241
|
+
console.log("localmerge prune: nothing to clean up — no already-landed sibling lanes found.");
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
const names = pruned.map((p) => p.split("/").pop()).join(", ");
|
|
245
|
+
console.log(`localmerge prune: removed ${pruned.length} already-landed lane${pruned.length === 1 ? "" : "s"}: ${names}`);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
case "preview":
|
|
250
|
+
return runPreview(rest);
|
|
251
|
+
case "build-lock": {
|
|
252
|
+
const parts = rest[0] === "--" ? rest.slice(1) : rest;
|
|
253
|
+
return buildLock(parts);
|
|
254
|
+
}
|
|
255
|
+
case "check-push": {
|
|
256
|
+
const root = findRepoRoot();
|
|
257
|
+
if (!root || !hasConfig(root)) {
|
|
258
|
+
// No config means LocalMerge isn't enabled for this repo — nothing to enforce.
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
const cfg = await loadConfig(root);
|
|
262
|
+
const stdin = await readStdin();
|
|
263
|
+
const refUpdates = parseRefUpdates(stdin);
|
|
264
|
+
// git still invokes pre-push (with empty stdin) for a push that
|
|
265
|
+
// updates zero refs — e.g. re-pushing a branch that's already exactly
|
|
266
|
+
// up to date on the remote. There's nothing to block (nothing is
|
|
267
|
+
// actually changing on the remote either way) and nothing meaningful
|
|
268
|
+
// for checkCommand to verify — running it anyway just produces a
|
|
269
|
+
// confusing, unrelated failure (a missing devDependency, say) that
|
|
270
|
+
// looks exactly like a real block but isn't one. Recognize "nothing
|
|
271
|
+
// to push" explicitly instead of silently falling through both checks.
|
|
272
|
+
if (refUpdates.length === 0) {
|
|
273
|
+
console.log("localmerge check-push: nothing being pushed (already up to date) — skipping checks.");
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
if (process.env.LOCALMERGE_EMERGENCY_PUSH === "1") {
|
|
277
|
+
console.log("\n🚨 LOCALMERGE_EMERGENCY_PUSH is set — bypassing the landing queue.\n");
|
|
278
|
+
}
|
|
279
|
+
const result = checkPush(refUpdates, cfg, process.env);
|
|
280
|
+
if (!result.ok) {
|
|
281
|
+
console.error(result.message);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
process.exit(runCheckCommand(cfg, root));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
case "--version":
|
|
288
|
+
case "-v": {
|
|
289
|
+
const { join, dirname } = await import("node:path");
|
|
290
|
+
const { fileURLToPath } = await import("node:url");
|
|
291
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
292
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
293
|
+
console.log(pkg.version);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
default:
|
|
297
|
+
console.log(`localmerge — keep parallel coding agents in their lane.
|
|
298
|
+
|
|
299
|
+
Usage:
|
|
300
|
+
localmerge init write a starter localmerge.config.mjs
|
|
301
|
+
localmerge land rebase + push this lane onto the integration branch (queued)
|
|
302
|
+
localmerge sync fast-forward the MAIN checkout to its upstream
|
|
303
|
+
localmerge promote ship the integration branch to production (human-only — never script this)
|
|
304
|
+
localmerge preview [--restore] swap the MAIN checkout to this lane's working tree, or restore it
|
|
305
|
+
localmerge build-lock -- <cmd> run <cmd>, serialized across every lane
|
|
306
|
+
localmerge port print this lane's dev-server port
|
|
307
|
+
localmerge hook worktree-create (Claude Code WorktreeCreate hook — not for direct use)
|
|
308
|
+
localmerge check-push (used by the pre-push hook — not for direct use)
|
|
309
|
+
`);
|
|
310
|
+
process.exit(command ? 1 : 0);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
main().catch((err) => {
|
|
314
|
+
console.error(err instanceof Error ? err.message : err);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
});
|