mergestorm 0.3.3 → 0.3.4

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/README.md CHANGED
@@ -48,14 +48,14 @@ mergestorm # uses your local build
48
48
  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
49
 
50
50
  ```
51
- ╭─ mergestorm v0.3.3 ──────────────────────────────────────────────────────────╮
51
+ ╭─ mergestorm v0.3.4 ──────────────────────────────────────────────────────────╮
52
52
  │ ▟██████████▛ Tips for getting started │
53
53
  │ ▜████████▛ review review main...HEAD │
54
- │ ▝▜████▛▘ stack list / adopt / restack / land │
54
+ │ ▝▜████▛▘ stack create → submit → restack → land │
55
55
  │ ▜██▛ /help list all commands │
56
56
  │ ██ │
57
- │ ▝▘ What's new in v0.3.3 │
58
- │ ● msk… · maelstrom • stack list / adopt / restack / land │
57
+ │ ▝▘ What's new in v0.3.4 │
58
+ │ ● msk… · maelstrom • stack create / submit (author with mg) │
59
59
  │ [████░░░░░░░░░░] 12% used │
60
60
  ╰──────────────────────────────────────────────────────────────────────────────╯
61
61
  mergestorm
@@ -92,14 +92,20 @@ mergestorm branches [n] Recently reviewed branches (arrow-pick on TTY)
92
92
  mergestorm chain [slug] Branch review-chain timeline (default: current branch)
93
93
  mergestorm whoami [--json] Key prefix, plan, API base, config path
94
94
  mergestorm thread <slug> Jobs in a review thread
95
- mergestorm stack list [--json] List adopted stacks
96
- mergestorm stack adopt <owner/repo>#<pr> Adopt an open PR chain
95
+ mergestorm stack create [name] New local stack layer (optional `--onto` / `--trunk`)
96
+ mergestorm stack submit Push layers, open chained PRs (`gh`), register via adopt
97
+ mergestorm stack list [--json] List registered stacks
98
+ mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
97
99
  mergestorm stack restack <stack-id> Restack descendants
98
100
  mergestorm stack land <stack-id> Land the bottom open PR
99
101
  mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
100
102
  ```
101
103
 
102
- `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack list`. Stack ops use the same login key as review commands (`/api/v1/stacks`).
104
+ `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
+
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.
107
+
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), 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.
103
109
 
104
110
  ### `login`
105
111
 
package/dist/api.js CHANGED
@@ -93,7 +93,7 @@ export async function adoptStack(owner, repo, prNumber, cfg) {
93
93
  throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
94
94
  }
95
95
  if (status !== 200) {
96
- throw new CommandError(`Failed to adopt stack (HTTP ${status}): ${JSON.stringify(body)}`);
96
+ throw new CommandError(`Failed to import stack (HTTP ${status}): ${JSON.stringify(body)}`);
97
97
  }
98
98
  return body;
99
99
  }
package/dist/cli.js CHANGED
@@ -30,8 +30,10 @@ 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 list [--json] List adopted stacks
34
- mergestorm stack adopt <owner/repo>#<pr> Adopt an open PR chain
33
+ mergestorm stack create [name] New local stack layer (.mergestorm/stack.json)
34
+ mergestorm stack submit Push layers, open chained PRs, register stack
35
+ mergestorm stack list [--json] List registered stacks
36
+ mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
35
37
  mergestorm stack restack <stack-id> Restack stack descendants
36
38
  mergestorm stack land <stack-id> Land the bottom open PR
37
39
  mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
@@ -5,4 +5,11 @@ export declare function parseAdoptTarget(args: string[]): {
5
5
  repo: string;
6
6
  prNumber: number;
7
7
  };
8
+ /** Parse `stack create` argv into options. Exported for tests. */
9
+ export declare function parseStackCreateArgs(args: string[]): {
10
+ name?: string;
11
+ onto?: string;
12
+ trunk?: string;
13
+ asJson: boolean;
14
+ };
8
15
  export declare function cmdStack(args: string[]): Promise<void>;
@@ -1,8 +1,14 @@
1
1
  import { adoptStack, landNextStack, listStacks, restackStack, setStackAutoLand, } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
+ import { createPr, findOpenPrNumber, requireGh } from "../gh.js";
5
+ import { branchExists, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, pushBranch, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
6
+ import { appendLayer, loadStackMeta, saveStackMeta, } from "../stack-meta.js";
4
7
  import { ansi } from "../ui/ansi.js";
8
+ import { parseGithubOriginRepo } from "./review.js";
5
9
  const STACK_USAGE = `usage:
10
+ mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--json]
11
+ mergestorm stack submit [--json]
6
12
  mergestorm stack list [--json]
7
13
  mergestorm stack adopt <owner/repo>#<pr>
8
14
  mergestorm stack restack <stack-id> [--json]
@@ -52,6 +58,195 @@ function formatStackHuman(s) {
52
58
  }
53
59
  return lines;
54
60
  }
61
+ /** Parse `stack create` argv into options. Exported for tests. */
62
+ export function parseStackCreateArgs(args) {
63
+ let name;
64
+ let onto;
65
+ let trunk;
66
+ let asJson = false;
67
+ for (let i = 0; i < args.length; i++) {
68
+ const a = args[i];
69
+ if (a === "--json") {
70
+ asJson = true;
71
+ continue;
72
+ }
73
+ if (a === "--onto") {
74
+ const v = args[++i];
75
+ if (!v)
76
+ throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>]");
77
+ onto = v;
78
+ continue;
79
+ }
80
+ if (a.startsWith("--onto=")) {
81
+ onto = a.slice("--onto=".length);
82
+ continue;
83
+ }
84
+ if (a === "--trunk") {
85
+ const v = args[++i];
86
+ if (!v)
87
+ throw new CommandError("usage: mergestorm stack create [name] [--trunk <branch>]");
88
+ trunk = v;
89
+ continue;
90
+ }
91
+ if (a.startsWith("--trunk=")) {
92
+ trunk = a.slice("--trunk=".length);
93
+ continue;
94
+ }
95
+ if (a.startsWith("-")) {
96
+ throw new CommandError(`${STACK_USAGE}\nunknown flag: ${a}`);
97
+ }
98
+ if (name != null) {
99
+ throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>] [--trunk <branch>]");
100
+ }
101
+ name = a;
102
+ }
103
+ return { name, onto, trunk, asJson };
104
+ }
105
+ async function cmdStackCreate(args) {
106
+ const { name, onto, trunk: trunkFlag, asJson } = parseStackCreateArgs(args);
107
+ const cwd = process.cwd();
108
+ if (worktreeDirty(cwd)) {
109
+ throw new CommandError("Working tree is dirty. Commit or stash changes before `stack create`.");
110
+ }
111
+ const parentBranch = onto?.trim() || currentBranch(cwd);
112
+ if (!parentBranch) {
113
+ throw new CommandError("Detached HEAD. Check out a branch (or pass --onto <branch>) before `stack create`.");
114
+ }
115
+ if (!branchExists(parentBranch, cwd)) {
116
+ throw new CommandError(`Parent branch not found: ${parentBranch}`);
117
+ }
118
+ const branch = defaultLayerBranchName(name);
119
+ if (branchExists(branch, cwd)) {
120
+ throw new CommandError(`Branch already exists: ${branch}`);
121
+ }
122
+ let meta = await loadStackMeta(cwd);
123
+ const trunk = trunkFlag?.trim() || meta?.trunk || discoverTrunk(cwd);
124
+ if (!meta) {
125
+ meta = { version: 1, trunk, layers: [] };
126
+ }
127
+ else if (trunkFlag?.trim() && meta.trunk !== trunk) {
128
+ throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}. Edit .mergestorm/stack.json or start fresh.`);
129
+ }
130
+ // Parent must be trunk or an existing layer (linear local stack).
131
+ if (meta.layers.length > 0 &&
132
+ parentBranch !== meta.trunk &&
133
+ !meta.layers.some((l) => l.branch === parentBranch)) {
134
+ throw new CommandError(`Parent ${parentBranch} is not trunk (${meta.trunk}) or a stack layer. Use --onto <branch>.`);
135
+ }
136
+ try {
137
+ createBranchFromHead(branch, cwd);
138
+ }
139
+ catch (err) {
140
+ throw new CommandError(err instanceof Error ? err.message : String(err));
141
+ }
142
+ let next;
143
+ try {
144
+ next = appendLayer(meta, { branch, parentBranch });
145
+ await saveStackMeta(next, cwd);
146
+ }
147
+ catch (err) {
148
+ try {
149
+ deleteBranch(branch, cwd);
150
+ }
151
+ catch { /* rollback best-effort */ }
152
+ throw new CommandError(err instanceof Error ? err.message : String(err));
153
+ }
154
+ if (asJson) {
155
+ console.log(JSON.stringify({ branch, parentBranch, trunk: next.trunk, meta: next }, null, 2));
156
+ return;
157
+ }
158
+ console.log(ansi.green(` Created ${branch} (onto ${parentBranch})`));
159
+ console.log(ansi.dim(` trunk: ${next.trunk} · layers: ${next.layers.length}`));
160
+ console.log(ansi.dim(` meta: .mergestorm/stack.json`));
161
+ }
162
+ async function cmdStackSubmit(args) {
163
+ const asJson = args.includes("--json");
164
+ if (args.some((a) => a !== "--json")) {
165
+ throw new CommandError("usage: mergestorm stack submit [--json]");
166
+ }
167
+ const cwd = process.cwd();
168
+ const meta = await loadStackMeta(cwd);
169
+ if (!meta || meta.layers.length === 0) {
170
+ throw new CommandError("No local stack layers. Run `mg stack create` (and commit) before `stack submit`.");
171
+ }
172
+ const origin = parseGithubOriginRepo();
173
+ if (!origin) {
174
+ throw new CommandError("Could not parse GitHub owner/repo from `origin`. Set a github.com remote and retry.");
175
+ }
176
+ const { owner, repo } = origin;
177
+ requireGh(cwd);
178
+ const opened = [];
179
+ for (const layer of meta.layers) {
180
+ const base = layer.parentBranch || meta.trunk;
181
+ if (!branchExists(layer.branch, cwd)) {
182
+ throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
183
+ }
184
+ console.log(ansi.dim(` Pushing ${layer.branch} …`));
185
+ try {
186
+ pushBranch(layer.branch, cwd);
187
+ }
188
+ catch (err) {
189
+ throw new CommandError(err instanceof Error ? err.message : String(err));
190
+ }
191
+ let prNumber = findOpenPrNumber(owner, repo, layer.branch, cwd);
192
+ let created = false;
193
+ if (prNumber == null) {
194
+ const title = tipCommitSubject(layer.branch, cwd);
195
+ console.log(ansi.dim(` Opening PR ${layer.branch} → ${base} …`));
196
+ prNumber = createPr({
197
+ owner,
198
+ repo,
199
+ base,
200
+ head: layer.branch,
201
+ title,
202
+ body: "",
203
+ cwd,
204
+ });
205
+ created = true;
206
+ }
207
+ else {
208
+ console.log(ansi.dim(` PR #${prNumber} already open for ${layer.branch}`));
209
+ }
210
+ opened.push({ branch: layer.branch, base, prNumber, created });
211
+ }
212
+ const registerPr = opened[0].prNumber;
213
+ const cfg = await loadConfig();
214
+ console.log(ansi.dim(` Registering stack via import (${owner}/${repo}#${registerPr}) …`));
215
+ const body = await adoptStack(owner, repo, registerPr, cfg);
216
+ if (typeof body !== "object" || body === null) {
217
+ throw new CommandError("Unexpected response shape from adopt API");
218
+ }
219
+ const data = body;
220
+ if (data.error) {
221
+ throw new CommandError(data.error);
222
+ }
223
+ if (!data.stack) {
224
+ throw new CommandError("Stack was not registered");
225
+ }
226
+ const stackId = data.stack.id;
227
+ if (asJson) {
228
+ console.log(JSON.stringify({
229
+ owner,
230
+ repo,
231
+ layers: opened,
232
+ stackId: stackId ?? null,
233
+ trunk: data.stack?.trunkBranch ?? meta.trunk,
234
+ chain: data.chain ?? null,
235
+ }, null, 2));
236
+ return;
237
+ }
238
+ for (const row of opened) {
239
+ const verb = row.created ? "opened" : "exists";
240
+ console.log(ansi.green(` #${row.prNumber} ${row.branch} → ${row.base} (${verb})`));
241
+ }
242
+ if (stackId) {
243
+ console.log(ansi.green(` Registered stack ${stackId}`));
244
+ }
245
+ else {
246
+ console.log(ansi.dim(" Stack registration returned no stack ID"));
247
+ }
248
+ console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
249
+ }
55
250
  async function cmdStackList(args) {
56
251
  const asJson = args.includes("--json");
57
252
  const cfg = await loadConfig();
@@ -61,7 +256,8 @@ async function cmdStackList(args) {
61
256
  return;
62
257
  }
63
258
  if (stacks.length === 0) {
64
- console.log(ansi.dim(" No stacks yet. Adopt one: `stack adopt owner/repo#pr`"));
259
+ console.log(ansi.dim(" No stacks yet. Author with `stack create` → commit → `stack submit`."));
260
+ console.log(ansi.dim(" Import an existing chain only: `stack adopt owner/repo#pr`"));
65
261
  console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
66
262
  return;
67
263
  }
@@ -91,7 +287,7 @@ async function cmdStackAdopt(args) {
91
287
  const data = body;
92
288
  const stack = data.stack;
93
289
  const chain = data.chain;
94
- console.log(ansi.green(` Adopted ${owner}/${repo}#${prNumber}` +
290
+ console.log(ansi.green(` Imported ${owner}/${repo}#${prNumber}` +
95
291
  (stack?.id ? ` → ${stack.id}` : "") +
96
292
  (Array.isArray(chain) ? ` (${chain.length} layer(s))` : "")));
97
293
  if (stack?.trunkBranch) {
@@ -149,6 +345,10 @@ export async function cmdStack(args) {
149
345
  if (!sub || sub === "-h" || sub === "--help") {
150
346
  throw new CommandError(STACK_USAGE);
151
347
  }
348
+ if (sub === "create")
349
+ return cmdStackCreate(rest);
350
+ if (sub === "submit")
351
+ return cmdStackSubmit(rest);
152
352
  if (sub === "list" || sub === "ls")
153
353
  return cmdStackList(rest);
154
354
  if (sub === "adopt")
package/dist/gh.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export declare function runGh(args: string[], cwd?: string): {
2
+ ok: true;
3
+ stdout: string;
4
+ } | {
5
+ ok: false;
6
+ stderr: string;
7
+ status: number | null;
8
+ };
9
+ export declare function requireGh(cwd?: string): void;
10
+ /** Open PR number for `head` branch in owner/repo, or null. */
11
+ export declare function findOpenPrNumber(owner: string, repo: string, headBranch: string, cwd?: string): number | null;
12
+ export declare function createPr(input: {
13
+ owner: string;
14
+ repo: string;
15
+ base: string;
16
+ head: string;
17
+ title: string;
18
+ body?: string;
19
+ cwd?: string;
20
+ }): number;
package/dist/gh.js ADDED
@@ -0,0 +1,89 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { CommandError } from "./errors.js";
3
+ export function runGh(args, cwd = process.cwd()) {
4
+ const result = spawnSync("gh", args, { cwd, encoding: "utf8" });
5
+ if (result.error) {
6
+ const msg = result.error.message || String(result.error);
7
+ if (result.error.code === "ENOENT") {
8
+ return { ok: false, stderr: "gh not found", status: null };
9
+ }
10
+ return { ok: false, stderr: msg, status: result.status };
11
+ }
12
+ if (result.status !== 0) {
13
+ return {
14
+ ok: false,
15
+ stderr: (result.stderr || result.stdout || `gh ${args.join(" ")} failed`).trim(),
16
+ status: result.status,
17
+ };
18
+ }
19
+ return { ok: true, stdout: result.stdout ?? "" };
20
+ }
21
+ export function requireGh(cwd = process.cwd()) {
22
+ const check = runGh(["--version"], cwd);
23
+ if (!check.ok) {
24
+ throw new CommandError("GitHub CLI (`gh`) is required for `stack submit`. Install https://cli.github.com and run `gh auth login`.");
25
+ }
26
+ const auth = runGh(["auth", "status"], cwd);
27
+ if (!auth.ok) {
28
+ throw new CommandError("`gh` is installed but not authenticated. Run `gh auth login`, then retry `stack submit`.");
29
+ }
30
+ }
31
+ /** Open PR number for `head` branch in owner/repo, or null. */
32
+ export function findOpenPrNumber(owner, repo, headBranch, cwd = process.cwd()) {
33
+ const res = runGh([
34
+ "pr",
35
+ "list",
36
+ "--repo",
37
+ `${owner}/${repo}`,
38
+ "--head",
39
+ headBranch,
40
+ "--state",
41
+ "open",
42
+ "--json",
43
+ "number",
44
+ "--limit",
45
+ "1",
46
+ ], cwd);
47
+ if (!res.ok) {
48
+ throw new CommandError(`Failed to list PRs for ${headBranch}: ${res.stderr}`);
49
+ }
50
+ try {
51
+ const rows = JSON.parse(res.stdout);
52
+ const n = rows[0]?.number;
53
+ return typeof n === "number" && n > 0 ? n : null;
54
+ }
55
+ catch (e) {
56
+ throw new CommandError(`Failed to parse PR list response: ${e instanceof Error ? e.message : String(e)}`);
57
+ }
58
+ }
59
+ export function createPr(input) {
60
+ const cwd = input.cwd ?? process.cwd();
61
+ const args = [
62
+ "pr",
63
+ "create",
64
+ "--repo",
65
+ `${input.owner}/${input.repo}`,
66
+ "--base",
67
+ input.base,
68
+ "--head",
69
+ input.head,
70
+ "--title",
71
+ input.title,
72
+ "--body",
73
+ input.body ?? "",
74
+ ];
75
+ const res = runGh(args, cwd);
76
+ if (!res.ok) {
77
+ throw new CommandError(`Failed to open PR ${input.head} → ${input.base}: ${res.stderr}`);
78
+ }
79
+ // stdout is typically the PR URL
80
+ const url = res.stdout.trim();
81
+ const m = /\/pull\/(\d+)/.exec(url);
82
+ if (m?.[1])
83
+ return Number(m[1]);
84
+ // Fallback: re-list
85
+ const n = findOpenPrNumber(input.owner, input.repo, input.head, cwd);
86
+ if (n != null)
87
+ return n;
88
+ throw new CommandError(`Opened PR at ${url || "(unknown URL)"} but could not determine PR number. Check your repository's pull requests.`);
89
+ }
@@ -0,0 +1,18 @@
1
+ /** Current branch name, or null when detached. */
2
+ export declare function currentBranch(cwd?: string): string | null;
3
+ /** True when the worktree has staged/unstaged/untracked changes. */
4
+ export declare function worktreeDirty(cwd?: string): boolean;
5
+ /**
6
+ * Resolve trunk branch: --trunk flag, then origin/HEAD, then main/master if present.
7
+ */
8
+ export declare function discoverTrunk(cwd?: string): string;
9
+ export declare function branchExists(name: string, cwd?: string): boolean;
10
+ export declare function deleteBranch(name: string, cwd?: string): void;
11
+ export declare function createBranchFromHead(name: string, cwd?: string): void;
12
+ /** Slugify a free-form name into a safe branch segment. */
13
+ export declare function slugifyBranchName(raw: string): string;
14
+ export declare function defaultLayerBranchName(explicit?: string): string;
15
+ /** Push branch to origin and set upstream. */
16
+ export declare function pushBranch(branch: string, cwd?: string): void;
17
+ /** First line of the tip commit on `branch` (for PR title). */
18
+ export declare function tipCommitSubject(branch: string, cwd?: string): string;
@@ -0,0 +1,99 @@
1
+ import { git } from "./git.js";
2
+ /** Current branch name, or null when detached. */
3
+ export function currentBranch(cwd = process.cwd()) {
4
+ try {
5
+ const name = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd).trim();
6
+ if (!name || name === "HEAD")
7
+ return null;
8
+ return name;
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ /** True when the worktree has staged/unstaged/untracked changes. */
15
+ export function worktreeDirty(cwd = process.cwd()) {
16
+ const out = git(["status", "--porcelain"], cwd);
17
+ return out.trim().length > 0;
18
+ }
19
+ /**
20
+ * Resolve trunk branch: --trunk flag, then origin/HEAD, then main/master if present.
21
+ */
22
+ export function discoverTrunk(cwd = process.cwd()) {
23
+ try {
24
+ const sym = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd).trim();
25
+ // origin/main → main
26
+ const m = /^origin\/(.+)$/.exec(sym);
27
+ if (m?.[1])
28
+ return m[1];
29
+ }
30
+ catch {
31
+ // no origin/HEAD
32
+ }
33
+ for (const candidate of ["main", "master"]) {
34
+ try {
35
+ git(["rev-parse", "--verify", candidate], cwd);
36
+ return candidate;
37
+ }
38
+ catch {
39
+ // try next
40
+ }
41
+ }
42
+ throw new Error("Could not discover trunk branch. Pass --trunk <name> or set origin/HEAD.");
43
+ }
44
+ export function branchExists(name, cwd = process.cwd()) {
45
+ try {
46
+ git(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], cwd);
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export function deleteBranch(name, cwd = process.cwd()) {
54
+ git(["branch", "-D", name], cwd);
55
+ }
56
+ export function createBranchFromHead(name, cwd = process.cwd()) {
57
+ git(["checkout", "-b", name], cwd);
58
+ }
59
+ /** Slugify a free-form name into a safe branch segment. */
60
+ export function slugifyBranchName(raw) {
61
+ const s = raw
62
+ .trim()
63
+ .toLowerCase()
64
+ .replace(/[^a-z0-9/_-]+/g, "-")
65
+ .replace(/\/+/g, "/")
66
+ .replace(/^-+|-+$/g, "");
67
+ return s || `layer-${Date.now().toString(36)}`;
68
+ }
69
+ export function defaultLayerBranchName(explicit) {
70
+ if (explicit?.trim()) {
71
+ const s = slugifyBranchName(explicit);
72
+ return s.includes("/") ? s : `ms/${s}`;
73
+ }
74
+ return `ms/${new Date().toISOString().slice(0, 10)}-${Date.now().toString(36).slice(-4)}`;
75
+ }
76
+ /** Push branch to origin and set upstream. */
77
+ export function pushBranch(branch, cwd = process.cwd()) {
78
+ try {
79
+ git(["push", "-u", "origin", branch], cwd);
80
+ }
81
+ catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err);
83
+ throw new Error(`git push failed for ${branch}. Check remote auth (SSH key or credential helper).\n${msg}`);
84
+ }
85
+ }
86
+ /** First line of the tip commit on `branch` (for PR title). */
87
+ export function tipCommitSubject(branch, cwd = process.cwd()) {
88
+ try {
89
+ const s = git(["log", "-1", "--format=%s", branch], cwd).trim();
90
+ if (s)
91
+ return s;
92
+ console.warn(`Warning: no commit message on ${branch}; using branch name as PR title`);
93
+ return branch;
94
+ }
95
+ catch {
96
+ console.warn(`Warning: could not read commit message on ${branch}; using branch name as PR title`);
97
+ return branch;
98
+ }
99
+ }
package/dist/shell.js CHANGED
@@ -28,7 +28,7 @@ export const COMMANDS = [
28
28
  { name: "chain", summary: "Show a branch review-chain timeline" },
29
29
  { name: "whoami", summary: "Key + plan + API base" },
30
30
  { name: "thread", summary: "Jobs in a review thread" },
31
- { name: "stack", summary: "Stacks: list, adopt, restack, land, auto-land" },
31
+ { name: "stack", summary: "Stacks: create, submit, list, adopt, restack, land" },
32
32
  { name: "clear", summary: "Clear screen and reprint banner" },
33
33
  { name: "exit", summary: "Leave the shell" },
34
34
  { name: "quit", summary: "Leave the shell" },
@@ -0,0 +1,15 @@
1
+ export type StackLayerMeta = {
2
+ branch: string;
3
+ /** Parent branch name (trunk for the bottom layer). */
4
+ parentBranch: string;
5
+ };
6
+ export type StackMeta = {
7
+ version: 1;
8
+ trunk: string;
9
+ layers: StackLayerMeta[];
10
+ };
11
+ 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>;
15
+ export declare function appendLayer(meta: StackMeta, layer: StackLayerMeta): StackMeta;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Local stack metadata for `mg stack create` / `submit`.
3
+ * Stored at `.mergestorm/stack.json` in the repo root.
4
+ */
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ 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;
21
+ }
22
+ return parsed;
23
+ }
24
+ catch (err) {
25
+ const code = err?.code;
26
+ if (code === "ENOENT")
27
+ return null;
28
+ throw err;
29
+ }
30
+ }
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");
35
+ }
36
+ export function appendLayer(meta, layer) {
37
+ if (meta.layers.some((l) => l.branch === layer.branch)) {
38
+ throw new Error(`branch already in stack: ${layer.branch}`);
39
+ }
40
+ return { ...meta, layers: [...meta.layers, layer] };
41
+ }
package/dist/ui/banner.js CHANGED
@@ -38,6 +38,10 @@ const WHATS_NEW = {
38
38
  "0.3.3": [
39
39
  "`stack list` / `adopt` / `restack` / `land` / `auto-land`",
40
40
  ],
41
+ "0.3.4": [
42
+ "`stack create` / `submit` — author stacks with `mg` (not Graphite)",
43
+ "Adopt is import-only; happy path is create → submit → restack → land",
44
+ ],
41
45
  };
42
46
  function formatStatusLines(me, key, barWidth) {
43
47
  if (!key) {
@@ -61,7 +65,7 @@ function tipsLines() {
61
65
  return [
62
66
  ansi.dim("Tips for getting started"),
63
67
  ` ${ansi.green("review")} review main...HEAD`,
64
- ` ${ansi.green("branches")} pick a reviewed branch`,
68
+ ` ${ansi.green("stack")} create → submit → restack → land`,
65
69
  ` ${ansi.green("/help")} list all commands`,
66
70
  ];
67
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "MergeStorm CLI — interactive shell and one-shot review commands",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "dev": "tsx src/cli.ts",
25
25
  "start": "node dist/cli.js",
26
26
  "typecheck": "tsc -p tsconfig.json --noEmit",
27
- "test": "tsx --test src/**/*.test.ts"
27
+ "test": "tsx --test src/*.test.ts src/**/*.test.ts"
28
28
  },
29
29
  "engines": {
30
30
  "node": ">=22.0.0"