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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -0
  3. package/dist/bin/claude-code-local-merge.d.ts +2 -0
  4. package/dist/bin/claude-code-local-merge.js +316 -0
  5. package/dist/bin/lanekeeper.d.ts +2 -0
  6. package/dist/bin/lanekeeper.js +307 -0
  7. package/dist/bin/localmerge.d.ts +2 -0
  8. package/dist/bin/localmerge.js +316 -0
  9. package/dist/bin/mergequeue.d.ts +2 -0
  10. package/dist/bin/mergequeue.js +307 -0
  11. package/dist/build-lock.d.ts +1 -0
  12. package/dist/build-lock.js +70 -0
  13. package/dist/hooks/worktree-create.d.ts +15 -0
  14. package/dist/hooks/worktree-create.js +192 -0
  15. package/dist/land.d.ts +1 -0
  16. package/dist/land.js +144 -0
  17. package/dist/lib/check-command.d.ts +29 -0
  18. package/dist/lib/check-command.js +83 -0
  19. package/dist/lib/check-push.d.ts +35 -0
  20. package/dist/lib/check-push.js +46 -0
  21. package/dist/lib/claude-md-snippet.d.ts +16 -0
  22. package/dist/lib/claude-md-snippet.js +18 -0
  23. package/dist/lib/config.d.ts +92 -0
  24. package/dist/lib/config.js +137 -0
  25. package/dist/lib/ephemeral.d.ts +40 -0
  26. package/dist/lib/ephemeral.js +100 -0
  27. package/dist/lib/lane-port.d.ts +3 -0
  28. package/dist/lib/lane-port.js +25 -0
  29. package/dist/lib/main-checkout.d.ts +1 -0
  30. package/dist/lib/main-checkout.js +19 -0
  31. package/dist/lib/prune-lanes.d.ts +36 -0
  32. package/dist/lib/prune-lanes.js +196 -0
  33. package/dist/lib/queue-lock.d.ts +26 -0
  34. package/dist/lib/queue-lock.js +212 -0
  35. package/dist/lib/tty-confirm.d.ts +1 -0
  36. package/dist/lib/tty-confirm.js +44 -0
  37. package/dist/lib/wire-hooks.d.ts +62 -0
  38. package/dist/lib/wire-hooks.js +230 -0
  39. package/dist/preview.d.ts +1 -0
  40. package/dist/preview.js +119 -0
  41. package/dist/promote.d.ts +1 -0
  42. package/dist/promote.js +77 -0
  43. package/dist/sync.d.ts +16 -0
  44. package/dist/sync.js +161 -0
  45. package/examples/claude-code-local-merge.config.mjs +67 -0
  46. package/examples/ephemeral-tmp-dir.example.ts +66 -0
  47. package/hooks/claude-settings.example.json +14 -0
  48. package/hooks/pre-push +23 -0
  49. 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>;
@@ -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>;
@@ -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>;