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,62 @@
|
|
|
1
|
+
export declare const PREFLIGHT_FILENAME = "claude-code-local-merge-preflight.mjs";
|
|
2
|
+
export type WireResult = "created" | "merged" | "already-wired" | "unparseable" | "no-husky";
|
|
3
|
+
export declare function wireClaudeSettings(root: string): WireResult;
|
|
4
|
+
export declare function wireHuskyPrePush(root: string): WireResult;
|
|
5
|
+
export type HooksPathResult = "set" | "already-set" | "custom-path";
|
|
6
|
+
/**
|
|
7
|
+
* A `.husky/pre-push` file on disk enforces nothing on its own — git only
|
|
8
|
+
* runs it if `core.hooksPath` points somewhere that resolves to it, which is
|
|
9
|
+
* normally set as a side effect of the package manager's install step
|
|
10
|
+
* (husky's own `prepare` script). On a freshly cloned repo where nobody's
|
|
11
|
+
* run that install yet — the exact state Quickstart leaves you in right
|
|
12
|
+
* after `init` — the file is silently inert and a direct push sails through
|
|
13
|
+
* uncontested. Since Claude Code Local Merge is the one promising "pushes are gated now,"
|
|
14
|
+
* it sets this itself instead of depending on a step that may not have
|
|
15
|
+
* happened yet, mirroring exactly what `husky install` itself does.
|
|
16
|
+
*
|
|
17
|
+
* Husky v9 changed its own convention mid-flight: v6–v8 point
|
|
18
|
+
* core.hooksPath directly at `.husky` (hook files run as-is); v9 points it
|
|
19
|
+
* at `.husky/_`, a generated wrapper directory that then execs the real
|
|
20
|
+
* `.husky/<hookname>` file. Both are legitimate, already-correct setups —
|
|
21
|
+
* only treat something OTHER than either as a deliberate custom path worth
|
|
22
|
+
* warning about. `.husky/_` doesn't exist yet on the fresh-clone/no-install
|
|
23
|
+
* case this function exists for, so `.husky` remains the right thing to set
|
|
24
|
+
* when nothing's configured at all; if the project turns out to be v9,
|
|
25
|
+
* husky's own next real install corrects it to `.husky/_`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function ensureHooksPath(root: string): HooksPathResult;
|
|
28
|
+
/**
|
|
29
|
+
* When a landing renames the tool `land`/`sync` invoke (lanekeeper ->
|
|
30
|
+
* claude-code-local-merge happened once already), any lane that hasn't rebased past that
|
|
31
|
+
* point yet still has package.json scripts calling the OLD name. The shared
|
|
32
|
+
* node_modules those lanes symlink from has already moved to the new name,
|
|
33
|
+
* so `npm run land` fails at the shell level — "lanekeeper: command not
|
|
34
|
+
* found" — before a single line of this tool's own code runs. Nothing
|
|
35
|
+
* inside `claude-code-local-merge land` can catch that; the binary it would need to run
|
|
36
|
+
* to catch it is the very thing that's missing.
|
|
37
|
+
*
|
|
38
|
+
* That's why this has to be a plain, standalone script committed into the
|
|
39
|
+
* CONSUMER repo rather than another `claude-code-local-merge` subcommand: it must still
|
|
40
|
+
* work the next time the tool itself gets renamed, so it can never invoke
|
|
41
|
+
* `claude-code-local-merge` (or import from the `claude-code-local-merge` package) itself. It only
|
|
42
|
+
* reads the target script's own command out of package.json, checks whether
|
|
43
|
+
* that command's binary actually resolves, and — if not — prints the real
|
|
44
|
+
* cause (a stale branch) instead of a bare, misleading shell error.
|
|
45
|
+
*/
|
|
46
|
+
export declare function preflightScriptContent(integrationBranch: string): string;
|
|
47
|
+
export type PreflightWireResult = "created" | "already-exists";
|
|
48
|
+
/** Additive/idempotent, same contract as the rest of this file — never overwrites a version you've customized. */
|
|
49
|
+
export declare function wirePreflightScript(root: string, integrationBranch: string): PreflightWireResult;
|
|
50
|
+
export type ScriptsWireResult = "added" | "already-wired" | "unparseable" | "no-package-json";
|
|
51
|
+
/**
|
|
52
|
+
* The last "copy this yourself" step `init` used to leave on the table:
|
|
53
|
+
* Quickstart told you to hand-add these scripts to package.json instead of
|
|
54
|
+
* just adding them. Same additive/idempotent contract as the rest of this
|
|
55
|
+
* file — only ever fills in scripts that don't exist yet, never overwrites
|
|
56
|
+
* one you've customized (e.g. if `land` already runs something of yours
|
|
57
|
+
* first), and does nothing if they're all already there.
|
|
58
|
+
*/
|
|
59
|
+
export declare function wirePackageJsonScripts(root: string): {
|
|
60
|
+
result: ScriptsWireResult;
|
|
61
|
+
added: string[];
|
|
62
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rest of `init`'s job: safely wire the WorktreeCreate hook into
|
|
3
|
+
* `.claude/settings.json` and the pre-push hook into `.husky/pre-push`,
|
|
4
|
+
* instead of leaving them as "copy this file yourself" — the exact kind of
|
|
5
|
+
* manual step that undercuts a tool whose whole point is fewer manual steps.
|
|
6
|
+
*
|
|
7
|
+
* Both merges are additive and idempotent: creating the file if it's
|
|
8
|
+
* missing, adding just our entry without touching anything else if the
|
|
9
|
+
* file already exists, and doing nothing (safely) if our entry's already
|
|
10
|
+
* there. Neither ever overwrites content that isn't ours.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, chmodSync } from "node:fs";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
const HOOK_COMMAND = "npx claude-code-local-merge hook worktree-create";
|
|
17
|
+
const PRE_PUSH_MARKER = "claude-code-local-merge check-push";
|
|
18
|
+
export const PREFLIGHT_FILENAME = "claude-code-local-merge-preflight.mjs";
|
|
19
|
+
const PACKAGE_SCRIPTS = {
|
|
20
|
+
land: "claude-code-local-merge land",
|
|
21
|
+
sync: "claude-code-local-merge sync",
|
|
22
|
+
promote: "claude-code-local-merge promote",
|
|
23
|
+
preview: "claude-code-local-merge preview",
|
|
24
|
+
"preview:restore": "claude-code-local-merge preview --restore",
|
|
25
|
+
// npm auto-runs "preland"/"presync" before "land"/"sync" — no wiring
|
|
26
|
+
// needed beyond the script name itself. See wirePreflightScript below for
|
|
27
|
+
// why the check they run has to live in a plain, tool-name-agnostic file
|
|
28
|
+
// instead of just being another `claude-code-local-merge` subcommand.
|
|
29
|
+
preland: `node ${PREFLIGHT_FILENAME} land`,
|
|
30
|
+
presync: `node ${PREFLIGHT_FILENAME} sync`,
|
|
31
|
+
};
|
|
32
|
+
export function wireClaudeSettings(root) {
|
|
33
|
+
const dir = join(root, ".claude");
|
|
34
|
+
const path = join(dir, "settings.json");
|
|
35
|
+
if (!existsSync(path)) {
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
const settings = {
|
|
38
|
+
hooks: { WorktreeCreate: [{ hooks: [{ type: "command", command: HOOK_COMMAND }] }] },
|
|
39
|
+
};
|
|
40
|
+
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
|
|
41
|
+
return "created";
|
|
42
|
+
}
|
|
43
|
+
let settings;
|
|
44
|
+
try {
|
|
45
|
+
settings = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return "unparseable"; // leave it alone — don't guess at broken JSON
|
|
49
|
+
}
|
|
50
|
+
settings.hooks ??= {};
|
|
51
|
+
settings.hooks.WorktreeCreate ??= [];
|
|
52
|
+
const alreadyWired = settings.hooks.WorktreeCreate.some((group) => group.hooks?.some((h) => h.command?.includes(HOOK_COMMAND)));
|
|
53
|
+
if (alreadyWired)
|
|
54
|
+
return "already-wired";
|
|
55
|
+
settings.hooks.WorktreeCreate.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] });
|
|
56
|
+
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
|
|
57
|
+
return "merged";
|
|
58
|
+
}
|
|
59
|
+
function shippedPrePushTemplate() {
|
|
60
|
+
// dist/lib/wire-hooks.js -> ../../hooks/pre-push at the package root.
|
|
61
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
return readFileSync(join(here, "..", "..", "hooks", "pre-push"), "utf8");
|
|
63
|
+
}
|
|
64
|
+
// The template file is written to stand alone (shebang + comments explaining
|
|
65
|
+
// itself to a human reading it fresh). Appending it whole into an *existing*
|
|
66
|
+
// hook file would duplicate the shebang mid-script and leave behind prose
|
|
67
|
+
// like "copy this file to .husky/pre-push" that's nonsensical once it's
|
|
68
|
+
// already there. So strip the shebang and the leading comment block, and
|
|
69
|
+
// append only the functional part — the same source of truth, no second
|
|
70
|
+
// copy to drift out of sync.
|
|
71
|
+
function functionalSnippet(template) {
|
|
72
|
+
const lines = template.split("\n");
|
|
73
|
+
let i = 0;
|
|
74
|
+
if (lines[0]?.startsWith("#!"))
|
|
75
|
+
i++;
|
|
76
|
+
for (; i < lines.length; i++) {
|
|
77
|
+
const trimmed = lines[i]?.trim() ?? "";
|
|
78
|
+
if (trimmed !== "" && !trimmed.startsWith("#"))
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return lines.slice(i).join("\n").trimEnd() + "\n";
|
|
82
|
+
}
|
|
83
|
+
export function wireHuskyPrePush(root) {
|
|
84
|
+
const huskyDir = join(root, ".husky");
|
|
85
|
+
if (!existsSync(huskyDir))
|
|
86
|
+
return "no-husky";
|
|
87
|
+
const path = join(huskyDir, "pre-push");
|
|
88
|
+
const template = shippedPrePushTemplate();
|
|
89
|
+
if (!existsSync(path)) {
|
|
90
|
+
writeFileSync(path, template);
|
|
91
|
+
chmodSync(path, 0o755);
|
|
92
|
+
return "created";
|
|
93
|
+
}
|
|
94
|
+
const existing = readFileSync(path, "utf8");
|
|
95
|
+
if (existing.includes(PRE_PUSH_MARKER))
|
|
96
|
+
return "already-wired";
|
|
97
|
+
const marker = "# --- Claude Code Local Merge (appended by `claude-code-local-merge init`) — see node_modules/claude-code-local-merge/hooks/pre-push for the full comments ---";
|
|
98
|
+
appendFileSync(path, `\n${marker}\n${functionalSnippet(template)}`);
|
|
99
|
+
chmodSync(path, 0o755);
|
|
100
|
+
return "merged";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A `.husky/pre-push` file on disk enforces nothing on its own — git only
|
|
104
|
+
* runs it if `core.hooksPath` points somewhere that resolves to it, which is
|
|
105
|
+
* normally set as a side effect of the package manager's install step
|
|
106
|
+
* (husky's own `prepare` script). On a freshly cloned repo where nobody's
|
|
107
|
+
* run that install yet — the exact state Quickstart leaves you in right
|
|
108
|
+
* after `init` — the file is silently inert and a direct push sails through
|
|
109
|
+
* uncontested. Since Claude Code Local Merge is the one promising "pushes are gated now,"
|
|
110
|
+
* it sets this itself instead of depending on a step that may not have
|
|
111
|
+
* happened yet, mirroring exactly what `husky install` itself does.
|
|
112
|
+
*
|
|
113
|
+
* Husky v9 changed its own convention mid-flight: v6–v8 point
|
|
114
|
+
* core.hooksPath directly at `.husky` (hook files run as-is); v9 points it
|
|
115
|
+
* at `.husky/_`, a generated wrapper directory that then execs the real
|
|
116
|
+
* `.husky/<hookname>` file. Both are legitimate, already-correct setups —
|
|
117
|
+
* only treat something OTHER than either as a deliberate custom path worth
|
|
118
|
+
* warning about. `.husky/_` doesn't exist yet on the fresh-clone/no-install
|
|
119
|
+
* case this function exists for, so `.husky` remains the right thing to set
|
|
120
|
+
* when nothing's configured at all; if the project turns out to be v9,
|
|
121
|
+
* husky's own next real install corrects it to `.husky/_`.
|
|
122
|
+
*/
|
|
123
|
+
export function ensureHooksPath(root) {
|
|
124
|
+
let current;
|
|
125
|
+
try {
|
|
126
|
+
current = execFileSync("git", ["config", "core.hooksPath"], { cwd: root, encoding: "utf8" }).trim();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
current = null; // unset
|
|
130
|
+
}
|
|
131
|
+
if (current === ".husky" || current === ".husky/_")
|
|
132
|
+
return "already-set";
|
|
133
|
+
if (current)
|
|
134
|
+
return "custom-path"; // respect an existing deliberate setup — don't override it
|
|
135
|
+
execFileSync("git", ["config", "core.hooksPath", ".husky"], { cwd: root });
|
|
136
|
+
return "set";
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* When a landing renames the tool `land`/`sync` invoke (lanekeeper ->
|
|
140
|
+
* claude-code-local-merge happened once already), any lane that hasn't rebased past that
|
|
141
|
+
* point yet still has package.json scripts calling the OLD name. The shared
|
|
142
|
+
* node_modules those lanes symlink from has already moved to the new name,
|
|
143
|
+
* so `npm run land` fails at the shell level — "lanekeeper: command not
|
|
144
|
+
* found" — before a single line of this tool's own code runs. Nothing
|
|
145
|
+
* inside `claude-code-local-merge land` can catch that; the binary it would need to run
|
|
146
|
+
* to catch it is the very thing that's missing.
|
|
147
|
+
*
|
|
148
|
+
* That's why this has to be a plain, standalone script committed into the
|
|
149
|
+
* CONSUMER repo rather than another `claude-code-local-merge` subcommand: it must still
|
|
150
|
+
* work the next time the tool itself gets renamed, so it can never invoke
|
|
151
|
+
* `claude-code-local-merge` (or import from the `claude-code-local-merge` package) itself. It only
|
|
152
|
+
* reads the target script's own command out of package.json, checks whether
|
|
153
|
+
* that command's binary actually resolves, and — if not — prints the real
|
|
154
|
+
* cause (a stale branch) instead of a bare, misleading shell error.
|
|
155
|
+
*/
|
|
156
|
+
export function preflightScriptContent(integrationBranch) {
|
|
157
|
+
return `#!/usr/bin/env node
|
|
158
|
+
// Generated by \`claude-code-local-merge init\` (wirePreflightScript) — do not hand-edit;
|
|
159
|
+
// re-run \`claude-code-local-merge init\` after changing integrationBranch instead.
|
|
160
|
+
//
|
|
161
|
+
// Runs as "preland"/"presync" (npm's automatic pre<script> hook) before
|
|
162
|
+
// "land"/"sync". Deliberately self-contained — no import of claude-code-local-merge
|
|
163
|
+
// itself — so it still catches a stale branch even across a future rename
|
|
164
|
+
// of this very tool. See wirePreflightScript in claude-code-local-merge's source for why.
|
|
165
|
+
import { execFileSync } from "node:child_process";
|
|
166
|
+
import { readFileSync } from "node:fs";
|
|
167
|
+
|
|
168
|
+
const target = process.argv[2]; // "land" or "sync"
|
|
169
|
+
const INTEGRATION_BRANCH = ${JSON.stringify(integrationBranch)};
|
|
170
|
+
|
|
171
|
+
let pkg;
|
|
172
|
+
try {
|
|
173
|
+
pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
174
|
+
} catch {
|
|
175
|
+
process.exit(0); // can't read it — not this script's problem to raise
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const command = pkg.scripts?.[target];
|
|
179
|
+
if (!command) process.exit(0);
|
|
180
|
+
|
|
181
|
+
const bin = command.trim().split(/\\s+/)[0];
|
|
182
|
+
try {
|
|
183
|
+
execFileSync("sh", ["-c", \`command -v -- "\${bin}"\`], { stdio: "ignore" });
|
|
184
|
+
} catch {
|
|
185
|
+
console.error(\`\\n✋ '\${bin}' isn't resolvable — this branch's package.json looks stale relative to origin/\${INTEGRATION_BRANCH} (the tool it invokes may have been renamed or removed there since this branch was last rebased).\`);
|
|
186
|
+
console.error(\` Fix: git fetch origin \${INTEGRATION_BRANCH} && git rebase origin/\${INTEGRATION_BRANCH}, then retry.\\n\`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
/** Additive/idempotent, same contract as the rest of this file — never overwrites a version you've customized. */
|
|
192
|
+
export function wirePreflightScript(root, integrationBranch) {
|
|
193
|
+
const path = join(root, PREFLIGHT_FILENAME);
|
|
194
|
+
if (existsSync(path))
|
|
195
|
+
return "already-exists";
|
|
196
|
+
writeFileSync(path, preflightScriptContent(integrationBranch));
|
|
197
|
+
return "created";
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The last "copy this yourself" step `init` used to leave on the table:
|
|
201
|
+
* Quickstart told you to hand-add these scripts to package.json instead of
|
|
202
|
+
* just adding them. Same additive/idempotent contract as the rest of this
|
|
203
|
+
* file — only ever fills in scripts that don't exist yet, never overwrites
|
|
204
|
+
* one you've customized (e.g. if `land` already runs something of yours
|
|
205
|
+
* first), and does nothing if they're all already there.
|
|
206
|
+
*/
|
|
207
|
+
export function wirePackageJsonScripts(root) {
|
|
208
|
+
const path = join(root, "package.json");
|
|
209
|
+
if (!existsSync(path))
|
|
210
|
+
return { result: "no-package-json", added: [] };
|
|
211
|
+
let pkg;
|
|
212
|
+
try {
|
|
213
|
+
pkg = JSON.parse(readFileSync(path, "utf8"));
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return { result: "unparseable", added: [] };
|
|
217
|
+
}
|
|
218
|
+
pkg.scripts ??= {};
|
|
219
|
+
const added = [];
|
|
220
|
+
for (const [name, command] of Object.entries(PACKAGE_SCRIPTS)) {
|
|
221
|
+
if (!(name in pkg.scripts)) {
|
|
222
|
+
pkg.scripts[name] = command;
|
|
223
|
+
added.push(name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (added.length === 0)
|
|
227
|
+
return { result: "already-wired", added: [] };
|
|
228
|
+
writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
|
|
229
|
+
return { result: "added", added };
|
|
230
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runPreview(args: string[]): Promise<void>;
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preview.ts — instantly preview a lane's working tree on the ONE shared dev
|
|
3
|
+
* server, no build, no deploy.
|
|
4
|
+
*
|
|
5
|
+
* A hosted preview deployment is too slow for "let me glance at this." A dev
|
|
6
|
+
* server is just files on disk being watched by your framework's bundler —
|
|
7
|
+
* so this copies a lane's working tree (including uncommitted changes,
|
|
8
|
+
* exactly what's being iterated on) straight onto the MAIN checkout. The
|
|
9
|
+
* bundler picks up the change and hot-reloads in seconds.
|
|
10
|
+
*
|
|
11
|
+
* This is framework-agnostic by construction: it's an rsync, not a build
|
|
12
|
+
* step, so it has no opinion about Next.js, Vite, or anything else — your
|
|
13
|
+
* own dev server does the actual watching and reloading. The one place a
|
|
14
|
+
* framework's fingerprints show up is `buildOutputDirs` in your config
|
|
15
|
+
* (never copy someone's stale ".next" or "dist" over a live checkout).
|
|
16
|
+
*
|
|
17
|
+
* claude-code-local-merge preview from a lane worktree — swap the dev server
|
|
18
|
+
* to show THIS lane's current working tree.
|
|
19
|
+
* claude-code-local-merge preview --restore from anywhere — put the dev server back on
|
|
20
|
+
* the integration branch's real HEAD.
|
|
21
|
+
*
|
|
22
|
+
* Safety:
|
|
23
|
+
* - Refuses to start a new preview if the MAIN checkout isn't clean (a
|
|
24
|
+
* previous preview wasn't restored, or it has real local changes) —
|
|
25
|
+
* never silently overwrites unknown state.
|
|
26
|
+
* - Additive only (no rsync --delete): a file the lane DELETED won't show
|
|
27
|
+
* up deleted in the preview. Deleting untracked files in a live checkout
|
|
28
|
+
* with no git record to recover them isn't a risk worth taking for a
|
|
29
|
+
* "quick look" tool — this only ever adds or modifies files.
|
|
30
|
+
* - Exact restore, not a guessed `git clean`: every newly-created
|
|
31
|
+
* untracked path introduced by the swap is recorded in a manifest up
|
|
32
|
+
* front, and restore removes precisely those paths, then
|
|
33
|
+
* `git checkout -- .` to revert every modified TRACKED file to HEAD.
|
|
34
|
+
* - Never touches .git, node_modules, build output, or env files in the
|
|
35
|
+
* target — only the source tree itself moves.
|
|
36
|
+
*/
|
|
37
|
+
import { execSync, execFileSync, spawnSync } from "node:child_process";
|
|
38
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
40
|
+
import { tmpdir } from "node:os";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
import { resolveMainCheckout } from "./lib/main-checkout.js";
|
|
43
|
+
import { loadConfig } from "./lib/config.js";
|
|
44
|
+
const DIM = "\x1b[2m", RESET = "\x1b[0m", RED = "\x1b[31m", GREEN = "\x1b[32m";
|
|
45
|
+
// Always excluded, regardless of framework — never rsync git internals,
|
|
46
|
+
// dependencies, or secrets over a live checkout.
|
|
47
|
+
const BASE_EXCLUDES = [".git", "node_modules", ".env", ".env.local"];
|
|
48
|
+
function gitStatus(dir) {
|
|
49
|
+
return execFileSync("git", ["status", "--porcelain"], { cwd: dir, encoding: "utf8" });
|
|
50
|
+
}
|
|
51
|
+
function restore(target, manifestPath) {
|
|
52
|
+
if (!existsSync(manifestPath)) {
|
|
53
|
+
console.log(`${DIM}preview: no active preview to restore.${RESET}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const { addedPaths } = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
57
|
+
console.log(`${DIM}reverting tracked-file changes on the dev checkout…${RESET}`);
|
|
58
|
+
execFileSync("git", ["checkout", "--", "."], { cwd: target, stdio: "inherit" });
|
|
59
|
+
for (const p of addedPaths) {
|
|
60
|
+
rmSync(join(target, p), { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
unlinkSync(manifestPath);
|
|
63
|
+
const head = execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: target, encoding: "utf8" }).trim();
|
|
64
|
+
console.log(`${GREEN}✓ dev server restored to HEAD @ ${head}.${RESET}`);
|
|
65
|
+
}
|
|
66
|
+
function preview(source, target, manifestPath, excludes) {
|
|
67
|
+
if (source === target) {
|
|
68
|
+
console.error("claude-code-local-merge preview: refusing to run from the dev-server checkout itself — run this from a lane worktree.");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
if (existsSync(manifestPath)) {
|
|
72
|
+
console.error(`${RED}preview: a preview is already active on the dev server.${RESET} Run 'claude-code-local-merge preview --restore' first.`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const before = gitStatus(target);
|
|
76
|
+
if (before.trim() !== "") {
|
|
77
|
+
console.error(`${RED}preview: the dev-server checkout isn't clean — refusing to swap over unknown local changes.${RESET}`);
|
|
78
|
+
console.error(before);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const branch = execFileSync("git", ["-C", source, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();
|
|
82
|
+
console.log(`${DIM}copying ${branch}'s working tree onto the dev server…${RESET}`);
|
|
83
|
+
const rsyncArgs = ["-a", ...excludes.flatMap((e) => ["--exclude", e]), `${source}/`, `${target}/`];
|
|
84
|
+
const rsync = spawnSync("rsync", rsyncArgs, { stdio: "inherit" });
|
|
85
|
+
if (rsync.status !== 0) {
|
|
86
|
+
console.error(`${RED}preview: rsync failed.${RESET}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
const after = gitStatus(target);
|
|
90
|
+
const addedPaths = after
|
|
91
|
+
.split("\n")
|
|
92
|
+
.filter((l) => l.startsWith("??"))
|
|
93
|
+
.map((l) => l.slice(3).trim());
|
|
94
|
+
writeFileSync(manifestPath, JSON.stringify({ branch, addedPaths }, null, 2));
|
|
95
|
+
console.log(`${GREEN}✓ dev server now showing ${branch}.${RESET} Refresh the browser.`);
|
|
96
|
+
console.log(`${DIM}Run 'claude-code-local-merge preview --restore' when done.${RESET}`);
|
|
97
|
+
}
|
|
98
|
+
export async function runPreview(args) {
|
|
99
|
+
const source = process.cwd();
|
|
100
|
+
const target = resolveMainCheckout(source);
|
|
101
|
+
const manifestPath = join(tmpdir(), `claude-code-local-merge-preview-manifest-${createHash("sha1").update(target).digest("hex").slice(0, 12)}.json`);
|
|
102
|
+
// Fail fast and legibly if rsync isn't available, rather than a cryptic
|
|
103
|
+
// spawn ENOENT partway through copying files.
|
|
104
|
+
try {
|
|
105
|
+
execSync("command -v rsync", { stdio: "ignore" });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
console.error("claude-code-local-merge preview: rsync is required and wasn't found on PATH.");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
if (args.includes("--restore")) {
|
|
112
|
+
restore(target, manifestPath);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const cfg = await loadConfig(source);
|
|
116
|
+
const excludes = [...BASE_EXCLUDES, ...cfg.buildOutputDirs];
|
|
117
|
+
preview(source, target, manifestPath, excludes);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function promote(): Promise<number>;
|
package/dist/promote.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promote.ts — ship the integration branch to production by fast-forwarding
|
|
3
|
+
* origin/<productionBranch> to origin/<integrationBranch>.
|
|
4
|
+
*
|
|
5
|
+
* This is the one command in Claude Code Local Merge that's deliberately NOT part of the
|
|
6
|
+
* automated workflow. Agents land on `integrationBranch` continuously and
|
|
7
|
+
* autonomously (see the CLAUDE.md workflow section `claude-code-local-merge init` writes) —
|
|
8
|
+
* production only moves when a human decides to run this. If your
|
|
9
|
+
* claude-code-local-merge.config has no `productionBranch` set, there's nothing to
|
|
10
|
+
* promote: `integrationBranch` already IS production, and this is a no-op.
|
|
11
|
+
*
|
|
12
|
+
* Usage: claude-code-local-merge promote (run from anywhere in the repo)
|
|
13
|
+
*
|
|
14
|
+
* Safe by construction:
|
|
15
|
+
* - Fetches first, then verifies origin/productionBranch is an ANCESTOR of
|
|
16
|
+
* origin/integrationBranch — a pure fast-forward, linear history, no
|
|
17
|
+
* merge commit. If production has commits not on the integration branch
|
|
18
|
+
* (someone pushed it directly), it ABORTS rather than force anything.
|
|
19
|
+
* - No local checkout needed: pushes the remote ref straight across.
|
|
20
|
+
* - --no-verify on the push: every commit on the integration branch
|
|
21
|
+
* already passed the full pre-push check when it landed, so re-running
|
|
22
|
+
* that suite here is pure waste. Your own CI still gates whatever runs
|
|
23
|
+
* on the production branch on its side.
|
|
24
|
+
* - Nothing to promote (already equal) → reports and exits 0.
|
|
25
|
+
*/
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
27
|
+
import { hasConfig, loadConfig } from "./lib/config.js";
|
|
28
|
+
function git(args, { allowFail = false } = {}) {
|
|
29
|
+
try {
|
|
30
|
+
return { ok: true, out: execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim() };
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
const err = e;
|
|
34
|
+
if (!allowFail)
|
|
35
|
+
throw e;
|
|
36
|
+
return { ok: false, out: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function promote() {
|
|
40
|
+
if (!hasConfig()) {
|
|
41
|
+
console.error("claude-code-local-merge promote: no claude-code-local-merge.config found at the repo root.");
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
const cfg = await loadConfig();
|
|
45
|
+
if (!cfg.productionBranch) {
|
|
46
|
+
console.log(`claude-code-local-merge promote: no productionBranch configured — '${cfg.integrationBranch}' already IS production. Nothing to do.`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const { integrationBranch, productionBranch } = cfg;
|
|
50
|
+
git(["fetch", "origin", "--quiet"], { allowFail: true });
|
|
51
|
+
const prod = git(["rev-parse", `origin/${productionBranch}`], { allowFail: true });
|
|
52
|
+
const integ = git(["rev-parse", `origin/${integrationBranch}`], { allowFail: true });
|
|
53
|
+
if (!prod.ok || !integ.ok) {
|
|
54
|
+
console.error(`claude-code-local-merge promote: could not resolve origin/${productionBranch} or origin/${integrationBranch} — are both branches created and fetched?`);
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
if (prod.out === integ.out) {
|
|
58
|
+
console.log(`claude-code-local-merge promote: ${productionBranch} already at ${integrationBranch} (${integ.out.slice(0, 7)}) — nothing to ship.`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
// Pure fast-forward only: origin/productionBranch must be an ancestor of
|
|
62
|
+
// origin/integrationBranch.
|
|
63
|
+
const ff = git(["merge-base", "--is-ancestor", `origin/${productionBranch}`, `origin/${integrationBranch}`], { allowFail: true });
|
|
64
|
+
if (!ff.ok) {
|
|
65
|
+
console.error(`claude-code-local-merge promote: origin/${productionBranch} has commits NOT on origin/${integrationBranch} — history has diverged.\n` +
|
|
66
|
+
`Someone pushed ${productionBranch} directly. Reconcile manually before promoting.\n` +
|
|
67
|
+
"Left untouched — refusing to force-push production.");
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
const push = git(["push", "--no-verify", "origin", `origin/${integrationBranch}:${productionBranch}`], { allowFail: true });
|
|
71
|
+
if (!push.ok) {
|
|
72
|
+
console.error(`claude-code-local-merge promote: push to ${productionBranch} FAILED — production NOT updated.\n${push.out}`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
console.log(`claude-code-local-merge promote: shipped ${integrationBranch} → ${productionBranch} ${prod.out.slice(0, 7)} → ${integ.out.slice(0, 7)}`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ClaudeCodeLocalMergeConfig } from "./lib/config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fast-forwards the MAIN checkout. Returns a process exit code; never throws.
|
|
4
|
+
*
|
|
5
|
+
* Accepts an already-loaded config, for `land` calling this immediately
|
|
6
|
+
* after a push that ITSELF introduced or changed claude-code-local-merge.config.mjs: the
|
|
7
|
+
* MAIN checkout hasn't been fast-forwarded yet at that exact moment (that's
|
|
8
|
+
* this function's whole job), so loading fresh from MAIN would silently
|
|
9
|
+
* fall back to DEFAULTS and could reject a perfectly good sync — the same
|
|
10
|
+
* bootstrap gap createLane had to be fixed for. The lane's own config,
|
|
11
|
+
* which just successfully rebased onto and pushed to the real
|
|
12
|
+
* integrationBranch, is the more trustworthy answer at that moment. A bare
|
|
13
|
+
* `claude-code-local-merge sync` (no caller-provided config) still loads fresh from
|
|
14
|
+
* MAIN, same as before.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sync(providedCfg?: ClaudeCodeLocalMergeConfig): Promise<number>;
|