mergestorm 0.3.5 → 0.3.7

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Margin Systems
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # mergestorm CLI
1
+ # Mergestorm CLI
2
2
 
3
- Review a local git diff with MergeStorm without opening a GitHub PR — for local loops and CI. Diffs are reviewed by the same engine as PR reviews and each review consumes one standard credit.
3
+ Review a local git diff with Mergestorm without opening a GitHub PR — for local loops and CI. Diffs are reviewed by the same engine as PR reviews and each review consumes one standard credit.
4
4
 
5
5
  Requires **Node.js 22+**.
6
6
 
@@ -24,6 +24,19 @@ mg login
24
24
 
25
25
  After install, both `mergestorm` and `mg` invoke the same CLI.
26
26
 
27
+ ## Source
28
+
29
+ Public source (MIT): [github.com/marginsystems/mergestorm-cli](https://github.com/marginsystems/mergestorm-cli).
30
+ Tags there match npm versions (`v0.3.7`, …).
31
+
32
+ ```bash
33
+ git clone https://github.com/marginsystems/mergestorm-cli.git
34
+ cd mergestorm-cli
35
+ npm install
36
+ npm run build
37
+ node dist/cli.js --help
38
+ ```
39
+
27
40
  ### From this monorepo (development / local UI testing)
28
41
 
29
42
  You do **not** need to bump/publish npm to try shell layout changes. From the repo root:
@@ -48,14 +61,14 @@ mergestorm # uses your local build
48
61
  On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a **full-width** welcome panel (logo + status on the left, tips / what's new on the right) and a **full-width** bordered input box with a live `/` slash-command dropdown. Non-TTY (CI/pipes) prints usage instead — no hanging prompt.
49
62
 
50
63
  ```
51
- ╭─ mergestorm v0.3.5 ──────────────────────────────────────────────────────────╮
64
+ ╭─ mergestorm v0.3.7 ──────────────────────────────────────────────────────────╮
52
65
  │ ▟██████████▛ Tips for getting started │
53
66
  │ ▜████████▛ review review main...HEAD │
54
67
  │ ▝▜████▛▘ stack create → submit → restack → land │
55
68
  │ ▜██▛ /help list all commands │
56
69
  │ ██ │
57
- │ ▝▘ What's new in v0.3.5 │
58
- │ ● msk… · maelstrom • stack submit fills PR body from tip commit │
70
+ │ ▝▘ What's new in v0.3.7 │
71
+ │ ● msk… · maelstrom • MIT license + public source (mergestorm-cli) │
59
72
  │ [████░░░░░░░░░░] 12% used │
60
73
  ╰──────────────────────────────────────────────────────────────────────────────╯
61
74
  mergestorm
@@ -99,13 +112,16 @@ mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
99
112
  mergestorm stack restack <stack-id> Restack descendants
100
113
  mergestorm stack land <stack-id> Land the bottom open PR
101
114
  mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
115
+ mergestorm stack reset --force Clear local authoring state (not branches/PRs)
102
116
  ```
103
117
 
104
118
  `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack create`. Happy path: **create → commit → submit → restack → land**. `stack adopt` is for importing a chain that already exists on GitHub (legacy / Graphite). Restack/land use the same login key (`/api/v1/stacks`).
105
119
 
106
- `stack create` is local-only: it checks out a new branch from the current tip (or `--onto <branch>`), discovers trunk (`main` / `master` / `origin/HEAD`, overridable with `--trunk`), and appends `{ branch, parentBranch }` under **`.mergestorm/stack.json`**. No GitHub or API calls.
120
+ `stack create` is local-only: it checks out a new branch from the current tip (or `--onto <branch>`), discovers trunk (`main` / `master` / `origin/HEAD`, overridable with `--trunk`), and records `{ branch, parentBranch }` in CLI-managed state under **`~/.mergestorm/stacks/<repo-id>/stack.json`**. The CLI updates this automatically; never edit it. Linked worktrees share state, while independent clones remain isolated. Creating onto trunk with a non-empty active stack starts a new one. No repository file, GitHub call, or API call is made.
121
+
122
+ `stack submit` walks the **active** local stack: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key), then drops that local stack entry so the next create onto trunk is clean. Push/PR auth is your local `git` + `gh auth` — not a GitHub App installation token.
107
123
 
108
- `stack submit` walks that meta: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), then registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key). Push/PR auth is your local `git` + `gh auth` — not a Cyclone app token.
124
+ Legacy repo-local `.mergestorm/stack.json` state migrates automatically on the next stack command. If local authoring state is stale or malformed, use `mg stack reset --force`; this clears only pre-submit CLI state and never deletes branches, PRs, or registered stacks.
109
125
 
110
126
  ### `login`
111
127
 
@@ -119,7 +135,7 @@ Removes `~/.mergestorm/config.json` so the stored key is no longer used.
119
135
 
120
136
  ### `review [base] [head]`
121
137
 
122
- Collects `git diff base...head` (default `main...HEAD`), attaches the changed file contents (up to 40 files, skipping files > 400 KB), auto-attaches context from `./.mergestorm/**/*.md|*.txt`, submits the job, and polls until it finishes (~3 min max). The thread slug is derived from the current branch (`local/<branch>`), so repeated runs on the same branch chain their findings.
138
+ Collects `git diff base...head` (default `main...HEAD`), attaches the changed file contents (up to 40 files, skipping files > 400 KB), submits the job, and polls until it finishes (~3 min max). The thread slug is derived from the current branch (`local/<branch>`), so repeated runs on the same branch chain their findings. Programmatic callers can provide explicit review context through the Review Jobs API.
123
139
 
124
140
  ```bash
125
141
  mergestorm review # main...HEAD
@@ -141,10 +157,6 @@ Require a logged-in key and API support for `GET /api/v1/me` and `GET /api/v1/re
141
157
 
142
158
  `branches` calls `GET /api/v1/threads` for recently active review threads (branches). On a TTY it opens an arrow-key selector; Enter loads that thread's job timeline. `chain [slug]` is the direct timeline view — omit the slug to use `local/<current-git-branch>` (same slug `review` stamps).
143
159
 
144
- ## Context pack (`.mergestorm/`)
145
-
146
- Any `*.md` / `*.txt` files under `./.mergestorm/` (one level of nesting) are attached as context so the reviewer understands intent, conventions, or migration notes without you resending history.
147
-
148
160
  ## Environment
149
161
 
150
162
  | Variable | Purpose | Default |
@@ -155,5 +167,6 @@ Any `*.md` / `*.txt` files under `./.mergestorm/` (one level of nesting) are att
155
167
 
156
168
  ## See also
157
169
 
158
- - [Review Jobs API reference](https://github.com/marginsystems/mergestorm/blob/main/docs/review-jobs-api.md) — raw HTTP endpoints, webhook signing, thread chains, quota.
170
+ - [Public source — mergestorm-cli](https://github.com/marginsystems/mergestorm-cli)
159
171
  - [Docs — Review Jobs API & CLI](https://mergestorm.ai/docs#review-jobs-api) — product docs on the site.
172
+ - [npm — mergestorm](https://www.npmjs.com/package/mergestorm)
package/dist/api.d.ts CHANGED
@@ -106,7 +106,7 @@ export type StackDto = {
106
106
  owner: string;
107
107
  repo: string;
108
108
  trunkBranch: string;
109
- autoLandWhenGreen: boolean;
109
+ autoPromoteWhenGreen: boolean;
110
110
  layers: StackLayerDto[];
111
111
  };
112
112
  /** List stacks (Bearer GET /api/v1/stacks). */
@@ -119,7 +119,7 @@ export declare function restackStack(stackId: string, cfg?: Config): Promise<unk
119
119
  export declare function landNextStack(stackId: string, cfg?: Config, opts?: {
120
120
  prNumber?: number;
121
121
  }): Promise<unknown>;
122
- /** Toggle auto-land when green (Bearer PATCH /api/v1/stacks/:id). */
123
- export declare function setStackAutoLand(stackId: string, autoLandWhenGreen: boolean, cfg?: Config): Promise<unknown>;
122
+ /** Toggle auto-promote when green (Bearer PATCH /api/v1/stacks/:id). */
123
+ export declare function setStackAutoPromote(stackId: string, autoPromoteWhenGreen: boolean, cfg?: Config): Promise<unknown>;
124
124
  /** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
125
125
  export declare function listThreads(limit?: number, cfg?: Config): Promise<ThreadListItem[]>;
package/dist/api.js CHANGED
@@ -125,9 +125,9 @@ export async function landNextStack(stackId, cfg, opts) {
125
125
  : {};
126
126
  return stackMutation(stackId, "/land-next", "POST", json, "Failed to land next", cfg);
127
127
  }
128
- /** Toggle auto-land when green (Bearer PATCH /api/v1/stacks/:id). */
129
- export async function setStackAutoLand(stackId, autoLandWhenGreen, cfg) {
130
- return stackMutation(stackId, "", "PATCH", { autoLandWhenGreen }, "Failed to update auto-land", cfg);
128
+ /** Toggle auto-promote when green (Bearer PATCH /api/v1/stacks/:id). */
129
+ export async function setStackAutoPromote(stackId, autoPromoteWhenGreen, cfg) {
130
+ return stackMutation(stackId, "", "PATCH", { autoPromoteWhenGreen }, "Failed to update auto-promote", cfg);
131
131
  }
132
132
  /** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
133
133
  export async function listThreads(limit = 20, cfg) {
package/dist/cli.js CHANGED
@@ -30,13 +30,14 @@ function usage() {
30
30
  mergestorm chain [slug] Branch review-chain timeline (default: current)
31
31
  mergestorm whoami [--json] Key + plan + API base
32
32
  mergestorm thread <slug> Jobs in a review thread
33
- mergestorm stack create [name] New local stack layer (.mergestorm/stack.json)
34
- mergestorm stack submit Push layers, open chained PRs, register stack
33
+ mergestorm stack create [name] New local stack layer (CLI-managed authoring state)
34
+ mergestorm stack submit Push active local stack, open PRs, register stack
35
+ mergestorm stack reset --force Clear this repo's pre-submit authoring state
35
36
  mergestorm stack list [--json] List registered stacks
36
37
  mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
37
38
  mergestorm stack restack <stack-id> Restack stack descendants
38
39
  mergestorm stack land <stack-id> Land bottom PR (or promote into unit)
39
- mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
40
+ mergestorm stack auto-promote on|off <stack-id> Toggle auto-promote when green
40
41
 
41
42
  mg is a short alias for mergestorm (same binary).
42
43
 
@@ -25,7 +25,7 @@ async function loginWithKey(existingRl) {
25
25
  const rl = existingRl ?? createInterface({ input, output });
26
26
  const ownRl = !existingRl;
27
27
  try {
28
- const key = (await rl.question("Paste your MergeStorm API key (msk_live_...): ")).trim();
28
+ const key = (await rl.question("Paste your Mergestorm API key (msk_live_...): ")).trim();
29
29
  if (!key.startsWith("msk_live_")) {
30
30
  throw new CommandError("Key should start with msk_live_");
31
31
  }
@@ -4,7 +4,7 @@ export type ReviewOptions = {
4
4
  interactive?: boolean;
5
5
  };
6
6
  /** Parse `origin` remote into GitHub owner/repo when possible. */
7
- export declare function parseGithubOriginRepo(): {
7
+ export declare function parseGithubOriginRepo(cwd?: string): {
8
8
  owner: string;
9
9
  repo: string;
10
10
  } | null;
@@ -1,12 +1,12 @@
1
1
  import { apiFetch, getMe } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { CommandError, DetachedError } from "../errors.js";
4
- import { collectChangedFiles, collectContextFiles, git } from "../git.js";
4
+ import { collectChangedFiles, git } from "../git.js";
5
5
  import { ansi } from "../ui/ansi.js";
6
6
  /** Parse `origin` remote into GitHub owner/repo when possible. */
7
- export function parseGithubOriginRepo() {
7
+ export function parseGithubOriginRepo(cwd = process.cwd()) {
8
8
  try {
9
- const url = git(["remote", "get-url", "origin"]).trim();
9
+ const url = git(["remote", "get-url", "origin"], cwd).trim();
10
10
  if (!url)
11
11
  return null;
12
12
  const m = url.match(/^(?:https?:\/\/|git@)?github\.com[:/]([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i);
@@ -44,11 +44,10 @@ export async function cmdReview(args, opts = {}) {
44
44
  return;
45
45
  }
46
46
  const files = await collectChangedFiles(base, head);
47
- const context_files = await collectContextFiles(process.cwd());
48
47
  const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim() || "local";
49
48
  const thread = `local/${branch}`.replace(/[^a-zA-Z0-9._/-]/g, "-").slice(0, 120);
50
49
  const originRepo = parseGithubOriginRepo();
51
- console.log(`Submitting review (${Buffer.byteLength(diff, "utf8")} byte diff, ${files.length} file(s), ${context_files.length} context file(s))…`);
50
+ console.log(`Submitting review (${Buffer.byteLength(diff, "utf8")} byte diff, ${files.length} file(s))…`);
52
51
  const { status, body } = await apiFetch(cfg, "/api/v1/reviews", {
53
52
  method: "POST",
54
53
  json: {
@@ -59,7 +58,6 @@ export async function cmdReview(args, opts = {}) {
59
58
  head_label: head,
60
59
  diff,
61
60
  files,
62
- context_files,
63
61
  },
64
62
  signal: opts.signal,
65
63
  });
@@ -12,4 +12,5 @@ export declare function parseStackCreateArgs(args: string[]): {
12
12
  trunk?: string;
13
13
  asJson: boolean;
14
14
  };
15
+ export declare function parseStackResetArgs(args: string[]): void;
15
16
  export declare function cmdStack(args: string[]): Promise<void>;
@@ -1,20 +1,22 @@
1
- import { adoptStack, landNextStack, listStacks, restackStack, setStackAutoLand, } from "../api.js";
1
+ import { adoptStack, landNextStack, listStacks, restackStack, setStackAutoPromote, } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
4
  import { createPr, findOpenPrNumber, requireGh } from "../gh.js";
5
5
  import { branchExists, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
6
6
  import { buildPrBodyFromCommit } from "../pr-body.js";
7
- import { appendLayer, loadStackMeta, saveStackMeta, } from "../stack-meta.js";
7
+ import { gitTopLevel } from "../repo-identity.js";
8
+ import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, } from "../stack-meta.js";
8
9
  import { ansi } from "../ui/ansi.js";
9
10
  import { parseGithubOriginRepo } from "./review.js";
10
11
  const STACK_USAGE = `usage:
11
12
  mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--json]
12
13
  mergestorm stack submit [--json]
14
+ mergestorm stack reset --force
13
15
  mergestorm stack list [--json]
14
16
  mergestorm stack adopt <owner/repo>#<pr>
15
17
  mergestorm stack restack <stack-id> [--json]
16
18
  mergestorm stack land <stack-id> [--json]
17
- mergestorm stack auto-land on|off <stack-id> [--json]
19
+ mergestorm stack auto-promote on|off <stack-id> [--json]
18
20
 
19
21
  stack submit opens PRs with a body generated from each layer tip commit.
20
22
  stack land promotes into the review unit when one exists; otherwise lands
@@ -49,7 +51,7 @@ export function parseAdoptTarget(args) {
49
51
  }
50
52
  function formatStackHuman(s) {
51
53
  const lines = [];
52
- const auto = s.autoLandWhenGreen ? " · auto-land" : "";
54
+ const auto = s.autoPromoteWhenGreen ? " · auto-promote" : "";
53
55
  lines.push(` ${ansi.bold(`${s.owner}/${s.repo}`)} trunk=${s.trunkBranch}${auto}`);
54
56
  lines.push(` ${ansi.dim(s.id)}`);
55
57
  if (s.layers.length === 0) {
@@ -107,9 +109,20 @@ export function parseStackCreateArgs(args) {
107
109
  }
108
110
  return { name, onto, trunk, asJson };
109
111
  }
112
+ export function parseStackResetArgs(args) {
113
+ if (args.length !== 1 || args[0] !== "--force") {
114
+ throw new CommandError("usage: mergestorm stack reset --force\nClears only CLI-managed pre-submit state; branches, PRs, and registered stacks are unchanged.");
115
+ }
116
+ }
110
117
  async function cmdStackCreate(args) {
111
118
  const { name, onto, trunk: trunkFlag, asJson } = parseStackCreateArgs(args);
112
119
  const cwd = process.cwd();
120
+ try {
121
+ gitTopLevel(cwd);
122
+ }
123
+ catch {
124
+ throw new CommandError("Not in a git repository. Run `mg stack create` from inside the repository whose stack you want to author.");
125
+ }
113
126
  if (worktreeDirty(cwd)) {
114
127
  throw new CommandError("Working tree is dirty. Commit or stash changes before `stack create`.");
115
128
  }
@@ -127,16 +140,16 @@ async function cmdStackCreate(args) {
127
140
  let meta = await loadStackMeta(cwd);
128
141
  const trunk = trunkFlag?.trim() || meta?.trunk || discoverTrunk(cwd);
129
142
  if (!meta) {
130
- meta = { version: 1, trunk, layers: [] };
143
+ meta = emptyStackMeta(trunk);
131
144
  }
132
145
  else if (trunkFlag?.trim() && meta.trunk !== trunk) {
133
- throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}. Edit .mergestorm/stack.json or start fresh.`);
146
+ throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}. Run \`mg stack reset --force\` to discard local pre-submit state.`);
147
+ }
148
+ try {
149
+ meta = selectStackForCreate(meta, parentBranch);
134
150
  }
135
- // Parent must be trunk or an existing layer (linear local stack).
136
- if (meta.layers.length > 0 &&
137
- parentBranch !== meta.trunk &&
138
- !meta.layers.some((l) => l.branch === parentBranch)) {
139
- throw new CommandError(`Parent ${parentBranch} is not trunk (${meta.trunk}) or a stack layer. Use --onto <branch>.`);
151
+ catch (err) {
152
+ throw new CommandError(err instanceof Error ? err.message : String(err));
140
153
  }
141
154
  try {
142
155
  createBranchFromHead(branch, cwd);
@@ -144,10 +157,9 @@ async function cmdStackCreate(args) {
144
157
  catch (err) {
145
158
  throw new CommandError(err instanceof Error ? err.message : String(err));
146
159
  }
147
- let next;
148
160
  try {
149
- next = appendLayer(meta, { branch, parentBranch });
150
- await saveStackMeta(next, cwd);
161
+ meta = appendLayer(meta, { branch, parentBranch });
162
+ await saveStackMeta(meta, cwd);
151
163
  }
152
164
  catch (err) {
153
165
  try {
@@ -156,13 +168,22 @@ async function cmdStackCreate(args) {
156
168
  catch { /* rollback best-effort */ }
157
169
  throw new CommandError(err instanceof Error ? err.message : String(err));
158
170
  }
171
+ const layers = activeLayers(meta);
159
172
  if (asJson) {
160
- console.log(JSON.stringify({ branch, parentBranch, trunk: next.trunk, meta: next }, null, 2));
173
+ console.log(JSON.stringify({
174
+ branch,
175
+ parentBranch,
176
+ trunk: meta.trunk,
177
+ active: meta.active,
178
+ stacks: meta.stacks.length,
179
+ layers,
180
+ meta,
181
+ }, null, 2));
161
182
  return;
162
183
  }
163
184
  console.log(ansi.green(` Created ${branch} (onto ${parentBranch})`));
164
- console.log(ansi.dim(` trunk: ${next.trunk} · layers: ${next.layers.length}`));
165
- console.log(ansi.dim(` meta: .mergestorm/stack.json`));
185
+ console.log(ansi.dim(` trunk: ${meta.trunk} · local stacks: ${meta.stacks.length} · active layers: ${layers.length}`));
186
+ console.log(ansi.dim(" authoring state: managed automatically outside the repository"));
166
187
  }
167
188
  async function cmdStackSubmit(args) {
168
189
  const asJson = args.includes("--json");
@@ -170,18 +191,25 @@ async function cmdStackSubmit(args) {
170
191
  throw new CommandError("usage: mergestorm stack submit [--json]");
171
192
  }
172
193
  const cwd = process.cwd();
194
+ try {
195
+ gitTopLevel(cwd);
196
+ }
197
+ catch {
198
+ throw new CommandError("Not in a git repository. Run `mg stack submit` from inside the repo whose stack you want to push.");
199
+ }
173
200
  const meta = await loadStackMeta(cwd);
174
- if (!meta || meta.layers.length === 0) {
175
- throw new CommandError("No local stack layers. Run `mg stack create` (and commit) before `stack submit`.");
201
+ const layers = meta ? activeLayers(meta) : [];
202
+ if (!meta || layers.length === 0) {
203
+ throw new CommandError("No local stack layers. Run `mg stack create` (then commit your code) before `stack submit`.");
176
204
  }
177
- const origin = parseGithubOriginRepo();
205
+ const origin = parseGithubOriginRepo(cwd);
178
206
  if (!origin) {
179
207
  throw new CommandError("Could not parse GitHub owner/repo from `origin`. Set a github.com remote and retry.");
180
208
  }
181
209
  const { owner, repo } = origin;
182
210
  requireGh(cwd);
183
211
  const opened = [];
184
- for (const layer of meta.layers) {
212
+ for (const layer of layers) {
185
213
  const base = layer.parentBranch || meta.trunk;
186
214
  if (!branchExists(layer.branch, cwd)) {
187
215
  throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
@@ -230,6 +258,8 @@ async function cmdStackSubmit(args) {
230
258
  throw new CommandError("Stack was not registered");
231
259
  }
232
260
  const stackId = data.stack.id;
261
+ // Drop the submitted local stack so the next create onto trunk starts fresh.
262
+ await saveStackMeta(removeActiveStack(meta), cwd);
233
263
  if (asJson) {
234
264
  console.log(JSON.stringify({
235
265
  owner,
@@ -253,6 +283,11 @@ async function cmdStackSubmit(args) {
253
283
  }
254
284
  console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
255
285
  }
286
+ async function cmdStackReset(args) {
287
+ parseStackResetArgs(args);
288
+ await resetStackMeta(process.cwd());
289
+ console.log(ansi.green(" Cleared CLI-managed pre-submit stack state. Branches, PRs, and registered stacks were not changed."));
290
+ }
256
291
  async function cmdStackList(args) {
257
292
  const asJson = args.includes("--json");
258
293
  const cfg = await loadConfig();
@@ -343,17 +378,17 @@ async function cmdStackAutoLand(args) {
343
378
  const asJson = args.includes("--json");
344
379
  const filtered = args.filter((a) => a !== "--json");
345
380
  const mode = filtered[0]?.toLowerCase();
346
- const id = requireStackId(filtered[1], "usage: mergestorm stack auto-land on|off <stack-id>");
381
+ const id = requireStackId(filtered[1], "usage: mergestorm stack auto-promote on|off <stack-id>");
347
382
  if (mode !== "on" && mode !== "off") {
348
- throw new CommandError("usage: mergestorm stack auto-land on|off <stack-id>");
383
+ throw new CommandError("usage: mergestorm stack auto-promote on|off <stack-id>");
349
384
  }
350
385
  const cfg = await loadConfig();
351
- const body = await setStackAutoLand(id, mode === "on", cfg);
386
+ const body = await setStackAutoPromote(id, mode === "on", cfg);
352
387
  if (asJson) {
353
388
  console.log(JSON.stringify(body, null, 2));
354
389
  return;
355
390
  }
356
- console.log(ansi.green(` Auto-land ${mode} for ${id}`));
391
+ console.log(ansi.green(` Auto-promote ${mode} for ${id}`));
357
392
  }
358
393
  export async function cmdStack(args) {
359
394
  const sub = args[0]?.toLowerCase();
@@ -365,6 +400,8 @@ export async function cmdStack(args) {
365
400
  return cmdStackCreate(rest);
366
401
  if (sub === "submit")
367
402
  return cmdStackSubmit(rest);
403
+ if (sub === "reset")
404
+ return cmdStackReset(rest);
368
405
  if (sub === "list" || sub === "ls")
369
406
  return cmdStackList(rest);
370
407
  if (sub === "adopt")
@@ -373,7 +410,11 @@ export async function cmdStack(args) {
373
410
  return cmdStackRestack(rest);
374
411
  if (sub === "land" || sub === "land-next")
375
412
  return cmdStackLand(rest);
376
- if (sub === "auto-land" || sub === "autoland")
413
+ if (sub === "auto-promote" ||
414
+ sub === "autopromote" ||
415
+ sub === "auto-land" ||
416
+ sub === "autoland") {
377
417
  return cmdStackAutoLand(rest);
418
+ }
378
419
  throw new CommandError(`${STACK_USAGE}\nunknown stack subcommand: ${sub}`);
379
420
  }
package/dist/config.d.ts CHANGED
@@ -3,6 +3,7 @@ export type Config = {
3
3
  apiKey?: string;
4
4
  apiBase?: string;
5
5
  };
6
+ export declare function mergestormHome(): string;
6
7
  export declare function configPath(): string;
7
8
  export declare function loadConfig(): Promise<Config>;
8
9
  export declare function saveConfig(cfg: Config): Promise<void>;
package/dist/config.js CHANGED
@@ -2,8 +2,11 @@ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
  export const DEFAULT_API = "https://api.mergestorm.ai";
5
+ export function mergestormHome() {
6
+ return path.join(homedir(), ".mergestorm");
7
+ }
5
8
  export function configPath() {
6
- return path.join(homedir(), ".mergestorm", "config.json");
9
+ return path.join(mergestormHome(), "config.json");
7
10
  }
8
11
  export async function loadConfig() {
9
12
  try {
@@ -2,8 +2,10 @@
2
2
  export declare function currentBranch(cwd?: string): string | null;
3
3
  /**
4
4
  * True when the worktree has staged/unstaged/untracked changes that should
5
- * block `stack create`. Ignores `.mergestorm/stack.json` so local stack meta
6
- * written by create does not prevent the next layer.
5
+ * block `stack create`. Stack authoring metadata lives outside the worktree.
6
+ * Legacy repo-local `.mergestorm/stack.json` is ignored because `loadStackMeta`
7
+ * migrates it out of the worktree during `stack create`; counting it as dirty
8
+ * would block that migration and every subsequent create.
7
9
  */
8
10
  export declare function worktreeDirty(cwd?: string): boolean;
9
11
  /**
package/dist/git-stack.js CHANGED
@@ -13,8 +13,10 @@ export function currentBranch(cwd = process.cwd()) {
13
13
  }
14
14
  /**
15
15
  * True when the worktree has staged/unstaged/untracked changes that should
16
- * block `stack create`. Ignores `.mergestorm/stack.json` so local stack meta
17
- * written by create does not prevent the next layer.
16
+ * block `stack create`. Stack authoring metadata lives outside the worktree.
17
+ * Legacy repo-local `.mergestorm/stack.json` is ignored because `loadStackMeta`
18
+ * migrates it out of the worktree during `stack create`; counting it as dirty
19
+ * would block that migration and every subsequent create.
18
20
  */
19
21
  export function worktreeDirty(cwd = process.cwd()) {
20
22
  const out = git(["status", "--porcelain"], cwd);
@@ -27,7 +29,9 @@ export function worktreeDirty(cwd = process.cwd()) {
27
29
  const pathPart = (status.includes("R") || status.includes("C"))
28
30
  ? trimmed.slice(3).split(" -> ").pop()?.trim() ?? ""
29
31
  : trimmed.slice(3).trim();
30
- if (pathPart === ".mergestorm/stack.json" || pathPart.endsWith("/.mergestorm/stack.json")) {
32
+ if (pathPart.startsWith(".mergestorm/") ||
33
+ pathPart.endsWith("/.mergestorm/") ||
34
+ pathPart.endsWith("/.mergestorm/stack.json")) {
31
35
  continue;
32
36
  }
33
37
  return true;
package/dist/git.d.ts CHANGED
@@ -1,8 +1,4 @@
1
1
  export declare function git(args: string[], cwd?: string): string;
2
- export declare function collectContextFiles(root: string): Promise<{
3
- path: string;
4
- content: string;
5
- }[]>;
6
2
  export declare function collectChangedFiles(base: string, head: string): Promise<{
7
3
  path: string;
8
4
  content: string;
package/dist/git.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
- const CONTEXT_DIR = ".mergestorm";
5
4
  export function git(args, cwd = process.cwd()) {
6
5
  try {
7
6
  const result = spawnSync("git", args, { cwd, encoding: "utf8" });
@@ -14,36 +13,6 @@ export function git(args, cwd = process.cwd()) {
14
13
  throw new Error(`git ${args.join(" ")} failed: ${err instanceof Error ? err.message : String(err)}`);
15
14
  }
16
15
  }
17
- export async function collectContextFiles(root) {
18
- const pack = path.join(root, CONTEXT_DIR);
19
- let entries;
20
- try {
21
- entries = await readdir(pack, { withFileTypes: true });
22
- }
23
- catch {
24
- return [];
25
- }
26
- const files = [];
27
- for (const entry of entries) {
28
- if (entry.isFile() &&
29
- (entry.name.toLowerCase().endsWith(".md") || entry.name.toLowerCase().endsWith(".txt"))) {
30
- const content = await readFile(path.join(pack, entry.name), "utf8");
31
- files.push({ path: entry.name, content });
32
- }
33
- else if (entry.isDirectory() && !entry.name.startsWith(".")) {
34
- const nested = await readdir(path.join(pack, entry.name), { withFileTypes: true }).catch(() => []);
35
- for (const child of nested) {
36
- if (child.isFile() &&
37
- (child.name.toLowerCase().endsWith(".md") || child.name.toLowerCase().endsWith(".txt"))) {
38
- const rel = `${entry.name}/${child.name}`;
39
- const content = await readFile(path.join(pack, entry.name, child.name), "utf8");
40
- files.push({ path: rel, content });
41
- }
42
- }
43
- }
44
- }
45
- return files;
46
- }
47
16
  export async function collectChangedFiles(base, head) {
48
17
  const names = git(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`])
49
18
  .split("\n")
@@ -0,0 +1,12 @@
1
+ export declare const LEGACY_STACK_META_REL: string;
2
+ export declare function gitTopLevel(cwd?: string): string;
3
+ export declare function gitCommonDir(cwd?: string): string;
4
+ /**
5
+ * A physical clone and all its linked worktrees share one authoring state.
6
+ * Independent clones remain isolated even when they point at the same remote.
7
+ */
8
+ export declare function stableRepoId(cwd?: string): string;
9
+ export declare function cliStacksRoot(): string;
10
+ export declare function repoStateDir(cwd?: string, stacksRoot?: string): string;
11
+ export declare function stackMetaPath(cwd?: string, stacksRoot?: string): string;
12
+ export declare function legacyStackMetaPath(cwd?: string): string;
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { mergestormHome } from "./config.js";
5
+ import { CommandError } from "./errors.js";
6
+ import { git } from "./git.js";
7
+ export const LEGACY_STACK_META_REL = path.join(".mergestorm", "stack.json");
8
+ export function gitTopLevel(cwd = process.cwd()) {
9
+ return realpathSync(git(["rev-parse", "--show-toplevel"], cwd).trim());
10
+ }
11
+ export function gitCommonDir(cwd = process.cwd()) {
12
+ const raw = git(["rev-parse", "--git-common-dir"], cwd).trim();
13
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
14
+ try {
15
+ return realpathSync(absolute);
16
+ }
17
+ catch (err) {
18
+ if (err?.code === "ENOENT") {
19
+ throw new CommandError(`git common directory is missing (${absolute}); this is often a linked worktree whose main repository was deleted. Run \`mg stack reset --force\` to clear local pre-submit state.`);
20
+ }
21
+ throw err;
22
+ }
23
+ }
24
+ /**
25
+ * A physical clone and all its linked worktrees share one authoring state.
26
+ * Independent clones remain isolated even when they point at the same remote.
27
+ */
28
+ export function stableRepoId(cwd = process.cwd()) {
29
+ return createHash("sha256").update(gitCommonDir(cwd)).digest("hex").slice(0, 20);
30
+ }
31
+ export function cliStacksRoot() {
32
+ return path.join(mergestormHome(), "stacks");
33
+ }
34
+ export function repoStateDir(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
35
+ return path.join(stacksRoot, stableRepoId(cwd));
36
+ }
37
+ export function stackMetaPath(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
38
+ return path.join(repoStateDir(cwd, stacksRoot), "stack.json");
39
+ }
40
+ export function legacyStackMetaPath(cwd = process.cwd()) {
41
+ return path.join(gitTopLevel(cwd), LEGACY_STACK_META_REL);
42
+ }
@@ -3,13 +3,31 @@ export type StackLayerMeta = {
3
3
  /** Parent branch name (trunk for the bottom layer). */
4
4
  parentBranch: string;
5
5
  };
6
+ export type LocalStack = {
7
+ layers: StackLayerMeta[];
8
+ };
6
9
  export type StackMeta = {
7
- version: 1;
10
+ version: 2;
8
11
  trunk: string;
9
- layers: StackLayerMeta[];
12
+ stacks: LocalStack[];
13
+ /** Index into `stacks` for create/submit. */
14
+ active: number;
10
15
  };
16
+ /** Exported only for legacy migration/tests. New state never uses this path. */
11
17
  export declare const STACK_META_REL: string;
12
- export declare function stackMetaPath(cwd?: string): string;
13
- export declare function loadStackMeta(cwd?: string): Promise<StackMeta | null>;
14
- export declare function saveStackMeta(meta: StackMeta, cwd?: string): Promise<void>;
18
+ export declare function stackMetaPath(cwd?: string, stacksRoot?: string): string;
19
+ export declare function loadStackMeta(cwd?: string, stacksRoot?: string): Promise<StackMeta | null>;
20
+ export declare function emptyStackMeta(trunk: string): StackMeta;
21
+ export declare function activeStack(meta: StackMeta): LocalStack;
22
+ export declare function activeLayers(meta: StackMeta): StackLayerMeta[];
23
+ export declare function saveStackMeta(meta: StackMeta, cwd?: string, stacksRoot?: string): Promise<void>;
24
+ /** Explicit recovery. Does not touch branches, PRs, or registered server stacks. */
25
+ export declare function resetStackMeta(cwd?: string, stacksRoot?: string): Promise<void>;
15
26
  export declare function appendLayer(meta: StackMeta, layer: StackLayerMeta): StackMeta;
27
+ /**
28
+ * Decide which local stack receives a new layer.
29
+ * Onto trunk with a non-empty active stack → start a fresh local stack.
30
+ */
31
+ export declare function selectStackForCreate(meta: StackMeta, parentBranch: string): StackMeta;
32
+ /** Drop the active stack after a successful submit. */
33
+ export declare function removeActiveStack(meta: StackMeta): StackMeta;
@@ -1,25 +1,78 @@
1
1
  /**
2
- * Local stack metadata for `mg stack create` / `submit`.
3
- * Stored at `.mergestorm/stack.json` in the repo root.
2
+ * CLI-owned pre-submit stack metadata for `mg stack create` / `submit`.
3
+ * Stored outside repositories under ~/.mergestorm/stacks/<repo-id>/.
4
4
  */
5
- import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { randomUUID } from "node:crypto";
6
+ import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile, } from "node:fs/promises";
6
7
  import path from "node:path";
7
- export const STACK_META_REL = path.join(".mergestorm", "stack.json");
8
- export function stackMetaPath(cwd = process.cwd()) {
9
- return path.join(cwd, STACK_META_REL);
10
- }
11
- export async function loadStackMeta(cwd = process.cwd()) {
12
- try {
13
- const raw = await readFile(stackMetaPath(cwd), "utf8");
14
- const parsed = JSON.parse(raw);
15
- if (!parsed ||
16
- parsed.version !== 1 ||
17
- typeof parsed.trunk !== "string" ||
18
- !Array.isArray(parsed.layers) ||
19
- !parsed.layers.every(l => typeof l.branch === 'string' && typeof l.parentBranch === 'string')) {
20
- return null;
8
+ import { CommandError } from "./errors.js";
9
+ import { git } from "./git.js";
10
+ import { cliStacksRoot, gitTopLevel, LEGACY_STACK_META_REL, legacyStackMetaPath, repoStateDir, stackMetaPath as resolveStackMetaPath, } from "./repo-identity.js";
11
+ /** Exported only for legacy migration/tests. New state never uses this path. */
12
+ export const STACK_META_REL = LEGACY_STACK_META_REL;
13
+ export function stackMetaPath(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
14
+ return resolveStackMetaPath(cwd, stacksRoot);
15
+ }
16
+ function isLayer(value) {
17
+ if (!value || typeof value !== "object")
18
+ return false;
19
+ const layer = value;
20
+ return typeof layer.branch === "string" && typeof layer.parentBranch === "string";
21
+ }
22
+ function normalizeMeta(raw) {
23
+ if (!raw || typeof raw !== "object")
24
+ return null;
25
+ const obj = raw;
26
+ if (typeof obj.trunk !== "string" || !obj.trunk.trim()) {
27
+ throw new Error("Stack authoring state has no trunk; run `mg stack reset --force` to clear it.");
28
+ }
29
+ if (obj.version === 2) {
30
+ if (!Array.isArray(obj.stacks) || obj.stacks.length === 0) {
31
+ throw new Error("Stack authoring state has no stacks; run `mg stack reset --force` to clear it.");
32
+ }
33
+ const stacks = [];
34
+ for (const stack of obj.stacks) {
35
+ if (!stack ||
36
+ typeof stack !== "object" ||
37
+ !Array.isArray(stack.layers) ||
38
+ !stack.layers.every(isLayer)) {
39
+ throw new Error("Stack authoring state has a malformed stack entry; run `mg stack reset --force` to clear it.");
40
+ }
41
+ stacks.push({ layers: stack.layers });
21
42
  }
22
- return parsed;
43
+ const active = obj.active;
44
+ if (typeof active !== "number" ||
45
+ !Number.isInteger(active) ||
46
+ active < 0 ||
47
+ active >= stacks.length) {
48
+ throw new Error("Stack authoring state has an invalid active stack; run `mg stack reset --force` to clear it.");
49
+ }
50
+ return { version: 2, trunk: obj.trunk, stacks, active };
51
+ }
52
+ if (obj.version === 1) {
53
+ if (!Array.isArray(obj.layers) || !obj.layers.every(isLayer)) {
54
+ throw new Error("Stack authoring state has malformed legacy layers; run `mg stack reset --force` to clear it.");
55
+ }
56
+ return {
57
+ version: 2,
58
+ trunk: obj.trunk,
59
+ stacks: [{ layers: obj.layers }],
60
+ active: 0,
61
+ };
62
+ }
63
+ throw new Error("Stack authoring state has an unknown version; run `mg stack reset --force` to clear it.");
64
+ }
65
+ function parseStateJson(raw) {
66
+ try {
67
+ return JSON.parse(raw);
68
+ }
69
+ catch {
70
+ throw new CommandError("Stack authoring state is not valid JSON; run `mg stack reset --force` to clear it.");
71
+ }
72
+ }
73
+ async function readIfExists(file) {
74
+ try {
75
+ return await readFile(file, "utf8");
23
76
  }
24
77
  catch (err) {
25
78
  const code = err?.code;
@@ -28,14 +81,196 @@ export async function loadStackMeta(cwd = process.cwd()) {
28
81
  throw err;
29
82
  }
30
83
  }
31
- export async function saveStackMeta(meta, cwd = process.cwd()) {
32
- const file = stackMetaPath(cwd);
33
- await mkdir(path.dirname(file), { recursive: true });
34
- await writeFile(file, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
84
+ async function removeLegacyFile(file, cwd = process.cwd(), force = false) {
85
+ const top = gitTopLevel(cwd);
86
+ const rel = path.relative(top, file);
87
+ try {
88
+ git(["rm", "-f", "--", rel], top);
89
+ }
90
+ catch (err) {
91
+ // Fall back to a plain unlink only for untracked files; otherwise the
92
+ // index entry would remain after the worktree copy is gone.
93
+ if (git(["ls-files", "--", rel], top).trim() !== "") {
94
+ if (!force) {
95
+ throw new Error(`git rm failed for tracked ${rel}: ${err instanceof Error ? err.message : String(err)}`);
96
+ }
97
+ git(["update-index", "--force-remove", "--", rel], top);
98
+ }
99
+ await unlink(file);
100
+ }
101
+ try {
102
+ await rmdir(path.dirname(file));
103
+ }
104
+ catch {
105
+ // Leave a non-empty .mergestorm directory untouched.
106
+ }
107
+ }
108
+ export async function loadStackMeta(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
109
+ const file = stackMetaPath(cwd, stacksRoot);
110
+ const legacyFile = legacyStackMetaPath(cwd);
111
+ const raw = await readIfExists(file);
112
+ const legacyRaw = await readIfExists(legacyFile);
113
+ if (raw !== null) {
114
+ const meta = normalizeMeta(parseStateJson(raw));
115
+ if (meta === null) {
116
+ throw new CommandError("Stack authoring state is not an object; run `mg stack reset --force` to clear it.");
117
+ }
118
+ if (legacyRaw !== null) {
119
+ const stateDir = repoStateDir(cwd, stacksRoot);
120
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
121
+ const backup = path.join(stateDir, `legacy-conflict-${stamp}.json`);
122
+ try {
123
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
124
+ await chmod(stateDir, 0o700);
125
+ await copyFile(legacyFile, backup);
126
+ await chmod(backup, 0o600);
127
+ await removeLegacyFile(legacyFile, cwd);
128
+ }
129
+ catch (err) {
130
+ throw new CommandError(`Failed to preserve legacy stack state${err instanceof Error ? `: ${err.message}` : ""}; run \`mg stack reset --force\` to clear it.`);
131
+ }
132
+ console.warn(`Warning: global stack state already existed; preserved legacy state at ${backup}.`);
133
+ }
134
+ return meta;
135
+ }
136
+ if (legacyRaw === null)
137
+ return null;
138
+ const migrated = normalizeMeta(parseStateJson(legacyRaw));
139
+ if (!migrated)
140
+ return null;
141
+ try {
142
+ await saveStackMeta(migrated, cwd, stacksRoot);
143
+ await removeLegacyFile(legacyFile, cwd);
144
+ }
145
+ catch (err) {
146
+ throw new CommandError(`Failed to migrate legacy stack state${err instanceof Error ? `: ${err.message}` : ""}; run \`mg stack reset --force\` to clear it.`);
147
+ }
148
+ return migrated;
149
+ }
150
+ export function emptyStackMeta(trunk) {
151
+ return {
152
+ version: 2,
153
+ trunk,
154
+ stacks: [{ layers: [] }],
155
+ active: 0,
156
+ };
157
+ }
158
+ export function activeStack(meta) {
159
+ const stack = meta.stacks[meta.active];
160
+ if (!stack) {
161
+ throw new Error("Stack authoring state has an out-of-range active stack; run `mg stack reset --force` to clear it.");
162
+ }
163
+ return stack;
164
+ }
165
+ export function activeLayers(meta) {
166
+ return activeStack(meta).layers;
167
+ }
168
+ export async function saveStackMeta(meta, cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
169
+ const file = stackMetaPath(cwd, stacksRoot);
170
+ const dir = path.dirname(file);
171
+ await mkdir(dir, { recursive: true, mode: 0o700 });
172
+ await chmod(dir, 0o700);
173
+ const base = path.basename(file);
174
+ for (const name of await readdir(dir)) {
175
+ if (name.startsWith(`${base}.`) && name.endsWith(".tmp")) {
176
+ await rm(path.join(dir, name), { force: true }).catch(() => { });
177
+ }
178
+ }
179
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
180
+ try {
181
+ await writeFile(temp, `${JSON.stringify(meta, null, 2)}\n`, {
182
+ encoding: "utf8",
183
+ mode: 0o600,
184
+ flag: "wx",
185
+ });
186
+ await rename(temp, file);
187
+ await chmod(file, 0o600);
188
+ }
189
+ catch (err) {
190
+ await rm(temp, { force: true }).catch(() => { });
191
+ throw err;
192
+ }
193
+ }
194
+ /** Explicit recovery. Does not touch branches, PRs, or registered server stacks. */
195
+ export async function resetStackMeta(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
196
+ let stateFile;
197
+ let legacyFile;
198
+ try {
199
+ stateFile = stackMetaPath(cwd, stacksRoot);
200
+ legacyFile = legacyStackMetaPath(cwd);
201
+ }
202
+ catch (err) {
203
+ const code = err?.code;
204
+ if (code !== undefined)
205
+ throw err;
206
+ if (err instanceof Error &&
207
+ (err.message.includes("not a git repository") ||
208
+ err.message.includes("git common directory is missing"))) {
209
+ return;
210
+ }
211
+ throw err;
212
+ }
213
+ await rm(stateFile, { force: true });
214
+ try {
215
+ await removeLegacyFile(legacyFile, cwd, true);
216
+ }
217
+ catch (err) {
218
+ const code = err?.code;
219
+ if (code !== "ENOENT")
220
+ throw err;
221
+ }
35
222
  }
36
223
  export function appendLayer(meta, layer) {
37
- if (meta.layers.some((l) => l.branch === layer.branch)) {
224
+ const current = activeLayers(meta);
225
+ if (current.some((l) => l.branch === layer.branch)) {
38
226
  throw new Error(`branch already in stack: ${layer.branch}`);
39
227
  }
40
- return { ...meta, layers: [...meta.layers, layer] };
228
+ for (let i = 0; i < meta.stacks.length; i++) {
229
+ if (i === meta.active)
230
+ continue;
231
+ if (meta.stacks[i].layers.some((l) => l.branch === layer.branch)) {
232
+ throw new Error(`branch already in another local stack: ${layer.branch}`);
233
+ }
234
+ }
235
+ const stacks = meta.stacks.map((stack, index) => index === meta.active
236
+ ? { layers: [...stack.layers, layer] }
237
+ : stack);
238
+ return { ...meta, stacks };
239
+ }
240
+ /**
241
+ * Decide which local stack receives a new layer.
242
+ * Onto trunk with a non-empty active stack → start a fresh local stack.
243
+ */
244
+ export function selectStackForCreate(meta, parentBranch) {
245
+ const layers = activeLayers(meta);
246
+ if (parentBranch === meta.trunk) {
247
+ if (layers.length === 0)
248
+ return meta;
249
+ return {
250
+ ...meta,
251
+ stacks: [...meta.stacks, { layers: [] }],
252
+ active: meta.stacks.length,
253
+ };
254
+ }
255
+ if (layers.some((l) => l.branch === parentBranch)) {
256
+ return meta;
257
+ }
258
+ if (layers.length === 0) {
259
+ return meta;
260
+ }
261
+ for (let i = 0; i < meta.stacks.length; i++) {
262
+ if (meta.stacks[i].layers.some((l) => l.branch === parentBranch)) {
263
+ throw new Error(`Parent ${parentBranch} is a layer of a different local stack than the active one.`);
264
+ }
265
+ }
266
+ throw new Error(`Parent ${parentBranch} is not trunk (${meta.trunk}) or a stack layer.`);
267
+ }
268
+ /** Drop the active stack after a successful submit. */
269
+ export function removeActiveStack(meta) {
270
+ if (meta.stacks.length <= 1) {
271
+ return { ...meta, stacks: [{ layers: [] }], active: 0 };
272
+ }
273
+ const stacks = meta.stacks.filter((_, index) => index !== meta.active);
274
+ const active = Math.min(meta.active, stacks.length - 1);
275
+ return { ...meta, stacks, active };
41
276
  }
package/dist/ui/banner.js CHANGED
@@ -46,6 +46,14 @@ const WHATS_NEW = {
46
46
  "`stack submit` fills PR body from the tip commit (keeps Fixes/Closes)",
47
47
  "Unit-stack `stack land` promotes (matches server gates)",
48
48
  ],
49
+ "0.3.6": [
50
+ "Stack state under ~/.mergestorm/stacks/ (multi-stack + legacy migrate)",
51
+ "`stack reset --force` clears stale local authoring state",
52
+ ],
53
+ "0.3.7": [
54
+ "MIT license + public source at github.com/marginsystems/mergestorm-cli",
55
+ "npm repository/bugs point at the public CLI repo",
56
+ ],
49
57
  };
50
58
  function formatStatusLines(me, key, barWidth) {
51
59
  if (!key) {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.5",
4
- "description": "MergeStorm CLI — interactive shell and one-shot review commands",
3
+ "version": "0.3.7",
4
+ "description": "Mergestorm CLI — interactive shell and one-shot review commands",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "mergestorm": "./dist/cli.js",
@@ -10,14 +11,15 @@
10
11
  "homepage": "https://mergestorm.ai/docs#review-jobs-api",
11
12
  "repository": {
12
13
  "type": "git",
13
- "url": "git+https://github.com/marginsystems/mergestorm.git",
14
- "directory": "packages/mergestorm-cli"
14
+ "url": "git+https://github.com/marginsystems/mergestorm-cli.git"
15
15
  },
16
16
  "bugs": {
17
- "url": "https://github.com/marginsystems/mergestorm/issues"
17
+ "url": "https://github.com/marginsystems/mergestorm-cli/issues"
18
18
  },
19
19
  "files": [
20
- "dist"
20
+ "dist",
21
+ "LICENSE",
22
+ "README.md"
21
23
  ],
22
24
  "scripts": {
23
25
  "build": "tsc -p tsconfig.build.json",