mergestorm 0.3.2 → 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 +17 -4
- package/dist/api.d.ts +31 -0
- package/dist/api.js +57 -0
- package/dist/cli.js +10 -0
- package/dist/commands/stack.d.ts +15 -0
- package/dist/commands/stack.js +363 -0
- package/dist/gh.d.ts +20 -0
- package/dist/gh.js +89 -0
- package/dist/git-stack.d.ts +18 -0
- package/dist/git-stack.js +99 -0
- package/dist/shell.js +5 -0
- package/dist/stack-meta.d.ts +15 -0
- package/dist/stack-meta.js +41 -0
- package/dist/ui/banner.js +8 -1
- package/package.json +2 -2
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.
|
|
51
|
+
╭─ mergestorm v0.3.4 ──────────────────────────────────────────────────────────╮
|
|
52
52
|
│ ▟██████████▛ Tips for getting started │
|
|
53
53
|
│ ▜████████▛ review review main...HEAD │
|
|
54
|
-
│ ▝▜████▛▘
|
|
54
|
+
│ ▝▜████▛▘ stack create → submit → restack → land │
|
|
55
55
|
│ ▜██▛ /help list all commands │
|
|
56
56
|
│ ██ │
|
|
57
|
-
│ ▝▘ What's new in v0.3.
|
|
58
|
-
│ ● msk… · maelstrom •
|
|
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,8 +92,21 @@ 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 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
|
|
99
|
+
mergestorm stack restack <stack-id> Restack descendants
|
|
100
|
+
mergestorm stack land <stack-id> Land the bottom open PR
|
|
101
|
+
mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
|
|
95
102
|
```
|
|
96
103
|
|
|
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.
|
|
109
|
+
|
|
97
110
|
### `login`
|
|
98
111
|
|
|
99
112
|
Runs a browser device-authorization flow: prints a short code, opens `https://mergestorm.ai/cli/auth`, and — once you approve — mints and stores an API key in `~/.mergestorm/config.json` (mode `600`). No key copy/paste needed.
|
package/dist/api.d.ts
CHANGED
|
@@ -81,5 +81,36 @@ export type ThreadDetail = {
|
|
|
81
81
|
/** Fetch /api/v1/me; returns null on 404/network so callers can degrade. */
|
|
82
82
|
export declare function getMe(cfg?: Config): Promise<MeResponse | null>;
|
|
83
83
|
export declare function listJobs(limit?: number, cfg?: Config): Promise<JobListItem[]>;
|
|
84
|
+
export type StackLayerDto = {
|
|
85
|
+
branch: string;
|
|
86
|
+
parentBranch: string | null;
|
|
87
|
+
prNumber: number;
|
|
88
|
+
position: number;
|
|
89
|
+
state: string;
|
|
90
|
+
title: string | null;
|
|
91
|
+
htmlUrl: string | null;
|
|
92
|
+
ciStatus: string;
|
|
93
|
+
reviewStatus: string;
|
|
94
|
+
conflictDetail: string | null;
|
|
95
|
+
lastRestackedSha: string | null;
|
|
96
|
+
};
|
|
97
|
+
export type StackDto = {
|
|
98
|
+
id: string;
|
|
99
|
+
owner: string;
|
|
100
|
+
repo: string;
|
|
101
|
+
trunkBranch: string;
|
|
102
|
+
autoLandWhenGreen: boolean;
|
|
103
|
+
layers: StackLayerDto[];
|
|
104
|
+
};
|
|
105
|
+
/** List stacks (Bearer GET /api/v1/stacks). */
|
|
106
|
+
export declare function listStacks(cfg?: Config): Promise<StackDto[]>;
|
|
107
|
+
/** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
|
|
108
|
+
export declare function adoptStack(owner: string, repo: string, prNumber: number, cfg?: Config): Promise<unknown>;
|
|
109
|
+
/** Restack descendants (Bearer POST /api/v1/stacks/:id/restack). */
|
|
110
|
+
export declare function restackStack(stackId: string, cfg?: Config): Promise<unknown>;
|
|
111
|
+
/** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
|
|
112
|
+
export declare function landNextStack(stackId: string, cfg?: Config): Promise<unknown>;
|
|
113
|
+
/** Toggle auto-land when green (Bearer PATCH /api/v1/stacks/:id). */
|
|
114
|
+
export declare function setStackAutoLand(stackId: string, autoLandWhenGreen: boolean, cfg?: Config): Promise<unknown>;
|
|
84
115
|
/** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
|
|
85
116
|
export declare function listThreads(limit?: number, cfg?: Config): Promise<ThreadListItem[]>;
|
package/dist/api.js
CHANGED
|
@@ -69,6 +69,63 @@ export async function listJobs(limit = 10, cfg) {
|
|
|
69
69
|
const items = body.items;
|
|
70
70
|
return Array.isArray(items) ? items : [];
|
|
71
71
|
}
|
|
72
|
+
/** List stacks (Bearer GET /api/v1/stacks). */
|
|
73
|
+
export async function listStacks(cfg) {
|
|
74
|
+
const resolved = cfg ?? (await loadConfig());
|
|
75
|
+
const { status, body } = await apiFetch(resolved, "/api/v1/stacks");
|
|
76
|
+
if (status === 404) {
|
|
77
|
+
throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
|
|
78
|
+
}
|
|
79
|
+
if (status !== 200) {
|
|
80
|
+
throw new CommandError(`Failed to list stacks (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
81
|
+
}
|
|
82
|
+
const stacks = body.stacks;
|
|
83
|
+
return Array.isArray(stacks) ? stacks : [];
|
|
84
|
+
}
|
|
85
|
+
/** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
|
|
86
|
+
export async function adoptStack(owner, repo, prNumber, cfg) {
|
|
87
|
+
const resolved = cfg ?? (await loadConfig());
|
|
88
|
+
const { status, body } = await apiFetch(resolved, "/api/v1/stacks/adopt", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
json: { owner, repo, prNumber },
|
|
91
|
+
});
|
|
92
|
+
if (status === 404) {
|
|
93
|
+
throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
|
|
94
|
+
}
|
|
95
|
+
if (status !== 200) {
|
|
96
|
+
throw new CommandError(`Failed to import stack (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
97
|
+
}
|
|
98
|
+
return body;
|
|
99
|
+
}
|
|
100
|
+
async function stackMutation(stackId, pathSuffix, method, json, failLabel, cfg) {
|
|
101
|
+
const resolved = cfg ?? (await loadConfig());
|
|
102
|
+
const { status, body } = await apiFetch(resolved, `/api/v1/stacks/${encodeURIComponent(stackId)}${pathSuffix}`, method === "PATCH"
|
|
103
|
+
? { method, json }
|
|
104
|
+
: { method, json: json ?? {} });
|
|
105
|
+
if (status === 404) {
|
|
106
|
+
const err = body?.error;
|
|
107
|
+
if (err === "stack_not_found") {
|
|
108
|
+
throw new CommandError(`Stack not found: ${stackId}`);
|
|
109
|
+
}
|
|
110
|
+
throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
|
|
111
|
+
}
|
|
112
|
+
if (status !== 200) {
|
|
113
|
+
throw new CommandError(`${failLabel} (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
114
|
+
}
|
|
115
|
+
return body;
|
|
116
|
+
}
|
|
117
|
+
/** Restack descendants (Bearer POST /api/v1/stacks/:id/restack). */
|
|
118
|
+
export async function restackStack(stackId, cfg) {
|
|
119
|
+
return stackMutation(stackId, "/restack", "POST", {}, "Failed to restack", cfg);
|
|
120
|
+
}
|
|
121
|
+
/** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
|
|
122
|
+
export async function landNextStack(stackId, cfg) {
|
|
123
|
+
return stackMutation(stackId, "/land-next", "POST", {}, "Failed to land next", cfg);
|
|
124
|
+
}
|
|
125
|
+
/** Toggle auto-land when green (Bearer PATCH /api/v1/stacks/:id). */
|
|
126
|
+
export async function setStackAutoLand(stackId, autoLandWhenGreen, cfg) {
|
|
127
|
+
return stackMutation(stackId, "", "PATCH", { autoLandWhenGreen }, "Failed to update auto-land", cfg);
|
|
128
|
+
}
|
|
72
129
|
/** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
|
|
73
130
|
export async function listThreads(limit = 20, cfg) {
|
|
74
131
|
const resolved = cfg ?? (await loadConfig());
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { cmdJobs, cmdThread } from "./commands/jobs.js";
|
|
|
8
8
|
import { cmdLogin } from "./commands/login.js";
|
|
9
9
|
import { cmdLogout } from "./commands/logout.js";
|
|
10
10
|
import { cmdReview } from "./commands/review.js";
|
|
11
|
+
import { cmdStack } from "./commands/stack.js";
|
|
11
12
|
import { cmdStatus } from "./commands/status.js";
|
|
12
13
|
import { cmdWhoami } from "./commands/whoami.js";
|
|
13
14
|
import { runShell } from "./shell.js";
|
|
@@ -29,6 +30,13 @@ function usage() {
|
|
|
29
30
|
mergestorm chain [slug] Branch review-chain timeline (default: current)
|
|
30
31
|
mergestorm whoami [--json] Key + plan + API base
|
|
31
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
|
|
35
|
+
mergestorm stack list [--json] List registered stacks
|
|
36
|
+
mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
|
|
37
|
+
mergestorm stack restack <stack-id> Restack stack descendants
|
|
38
|
+
mergestorm stack land <stack-id> Land the bottom open PR
|
|
39
|
+
mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
|
|
32
40
|
|
|
33
41
|
mg is a short alias for mergestorm (same binary).
|
|
34
42
|
|
|
@@ -64,6 +72,8 @@ async function runCommand(cmd, args) {
|
|
|
64
72
|
throw new CommandError("usage: mergestorm thread <slug>");
|
|
65
73
|
return cmdThread(args[0], args.slice(1));
|
|
66
74
|
}
|
|
75
|
+
if (cmd === "stack")
|
|
76
|
+
return cmdStack(args);
|
|
67
77
|
if (cmd === "shell")
|
|
68
78
|
return runShell();
|
|
69
79
|
usage();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function requireStackId(raw: string | undefined, usage: string): string;
|
|
2
|
+
/** Parse `owner/repo#12` or `owner/repo 12`. */
|
|
3
|
+
export declare function parseAdoptTarget(args: string[]): {
|
|
4
|
+
owner: string;
|
|
5
|
+
repo: string;
|
|
6
|
+
prNumber: number;
|
|
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
|
+
};
|
|
15
|
+
export declare function cmdStack(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { adoptStack, landNextStack, listStacks, restackStack, setStackAutoLand, } from "../api.js";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
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";
|
|
7
|
+
import { ansi } from "../ui/ansi.js";
|
|
8
|
+
import { parseGithubOriginRepo } from "./review.js";
|
|
9
|
+
const STACK_USAGE = `usage:
|
|
10
|
+
mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--json]
|
|
11
|
+
mergestorm stack submit [--json]
|
|
12
|
+
mergestorm stack list [--json]
|
|
13
|
+
mergestorm stack adopt <owner/repo>#<pr>
|
|
14
|
+
mergestorm stack restack <stack-id> [--json]
|
|
15
|
+
mergestorm stack land <stack-id> [--json]
|
|
16
|
+
mergestorm stack auto-land on|off <stack-id> [--json]`;
|
|
17
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
18
|
+
export function requireStackId(raw, usage) {
|
|
19
|
+
const id = raw?.trim() ?? "";
|
|
20
|
+
if (!id || !UUID_RE.test(id)) {
|
|
21
|
+
throw new CommandError(usage);
|
|
22
|
+
}
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
/** Parse `owner/repo#12` or `owner/repo 12`. */
|
|
26
|
+
export function parseAdoptTarget(args) {
|
|
27
|
+
const joined = args.join(" ").trim();
|
|
28
|
+
const hashMatch = /^([^/\s]+)\/([^#\s]+)#([1-9]\d*)$/.exec(joined);
|
|
29
|
+
if (hashMatch) {
|
|
30
|
+
return {
|
|
31
|
+
owner: hashMatch[1],
|
|
32
|
+
repo: hashMatch[2],
|
|
33
|
+
prNumber: Number(hashMatch[3]),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (args.length >= 2) {
|
|
37
|
+
const slash = /^([^/\s]+)\/([^/\s]+)$/.exec(args[0]);
|
|
38
|
+
const pr = Number(args[1]);
|
|
39
|
+
if (slash && Number.isInteger(pr) && pr > 0) {
|
|
40
|
+
return { owner: slash[1], repo: slash[2], prNumber: pr };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new CommandError(`usage: mergestorm stack adopt <owner/repo>#<pr>`);
|
|
44
|
+
}
|
|
45
|
+
function formatStackHuman(s) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
const auto = s.autoLandWhenGreen ? " · auto-land" : "";
|
|
48
|
+
lines.push(` ${ansi.bold(`${s.owner}/${s.repo}`)} trunk=${s.trunkBranch}${auto}`);
|
|
49
|
+
lines.push(` ${ansi.dim(s.id)}`);
|
|
50
|
+
if (s.layers.length === 0) {
|
|
51
|
+
lines.push(ansi.dim(" (no layers)"));
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
for (const layer of s.layers) {
|
|
55
|
+
const title = layer.title ? ` ${layer.title}` : "";
|
|
56
|
+
const pr = layer.prNumber > 0 ? `#${layer.prNumber}` : "—";
|
|
57
|
+
lines.push(` ${String(layer.position).padStart(2)} ${pr.padEnd(5)} ${layer.state.padEnd(14)} ${layer.branch}${title}`);
|
|
58
|
+
}
|
|
59
|
+
return lines;
|
|
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
|
+
}
|
|
250
|
+
async function cmdStackList(args) {
|
|
251
|
+
const asJson = args.includes("--json");
|
|
252
|
+
const cfg = await loadConfig();
|
|
253
|
+
const stacks = await listStacks(cfg);
|
|
254
|
+
if (asJson) {
|
|
255
|
+
console.log(JSON.stringify({ stacks }, null, 2));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (stacks.length === 0) {
|
|
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`"));
|
|
261
|
+
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
for (const s of stacks) {
|
|
265
|
+
for (const line of formatStackHuman(s))
|
|
266
|
+
console.log(line);
|
|
267
|
+
console.log("");
|
|
268
|
+
}
|
|
269
|
+
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
|
|
270
|
+
}
|
|
271
|
+
async function cmdStackAdopt(args) {
|
|
272
|
+
const asJson = args.includes("--json");
|
|
273
|
+
const targetArgs = args.filter((a) => a !== "--json");
|
|
274
|
+
if (targetArgs.length === 0) {
|
|
275
|
+
throw new CommandError(`usage: mergestorm stack adopt <owner/repo>#<pr>`);
|
|
276
|
+
}
|
|
277
|
+
const { owner, repo, prNumber } = parseAdoptTarget(targetArgs);
|
|
278
|
+
const cfg = await loadConfig();
|
|
279
|
+
const body = await adoptStack(owner, repo, prNumber, cfg);
|
|
280
|
+
if (asJson) {
|
|
281
|
+
console.log(JSON.stringify(body, null, 2));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (typeof body !== "object" || body === null) {
|
|
285
|
+
throw new CommandError("Unexpected response shape from adopt API");
|
|
286
|
+
}
|
|
287
|
+
const data = body;
|
|
288
|
+
const stack = data.stack;
|
|
289
|
+
const chain = data.chain;
|
|
290
|
+
console.log(ansi.green(` Imported ${owner}/${repo}#${prNumber}` +
|
|
291
|
+
(stack?.id ? ` → ${stack.id}` : "") +
|
|
292
|
+
(Array.isArray(chain) ? ` (${chain.length} layer(s))` : "")));
|
|
293
|
+
if (stack?.trunkBranch) {
|
|
294
|
+
console.log(ansi.dim(` trunk: ${stack.trunkBranch}`));
|
|
295
|
+
}
|
|
296
|
+
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
|
|
297
|
+
}
|
|
298
|
+
async function cmdStackRestack(args) {
|
|
299
|
+
const asJson = args.includes("--json");
|
|
300
|
+
const id = requireStackId(args.find((a) => a !== "--json"), "usage: mergestorm stack restack <stack-id>");
|
|
301
|
+
const cfg = await loadConfig();
|
|
302
|
+
const body = await restackStack(id, cfg);
|
|
303
|
+
if (asJson) {
|
|
304
|
+
console.log(JSON.stringify(body, null, 2));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const fromBranch = body.fromBranch;
|
|
308
|
+
console.log(ansi.green(` Restack started` + (fromBranch ? ` from ${fromBranch}` : "") + ` (${id})`));
|
|
309
|
+
}
|
|
310
|
+
async function cmdStackLand(args) {
|
|
311
|
+
const asJson = args.includes("--json");
|
|
312
|
+
const id = requireStackId(args.find((a) => a !== "--json"), "usage: mergestorm stack land <stack-id>");
|
|
313
|
+
const cfg = await loadConfig();
|
|
314
|
+
const body = await landNextStack(id, cfg);
|
|
315
|
+
if (asJson) {
|
|
316
|
+
console.log(JSON.stringify(body, null, 2));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const pr = body.mergedPrNumber;
|
|
320
|
+
const branch = body.mergedBranch;
|
|
321
|
+
console.log(ansi.green(` Landed` +
|
|
322
|
+
(pr != null ? ` #${pr}` : "") +
|
|
323
|
+
(branch ? ` (${branch})` : "") +
|
|
324
|
+
` on ${id}`));
|
|
325
|
+
}
|
|
326
|
+
async function cmdStackAutoLand(args) {
|
|
327
|
+
const asJson = args.includes("--json");
|
|
328
|
+
const filtered = args.filter((a) => a !== "--json");
|
|
329
|
+
const mode = filtered[0]?.toLowerCase();
|
|
330
|
+
const id = requireStackId(filtered[1], "usage: mergestorm stack auto-land on|off <stack-id>");
|
|
331
|
+
if (mode !== "on" && mode !== "off") {
|
|
332
|
+
throw new CommandError("usage: mergestorm stack auto-land on|off <stack-id>");
|
|
333
|
+
}
|
|
334
|
+
const cfg = await loadConfig();
|
|
335
|
+
const body = await setStackAutoLand(id, mode === "on", cfg);
|
|
336
|
+
if (asJson) {
|
|
337
|
+
console.log(JSON.stringify(body, null, 2));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
console.log(ansi.green(` Auto-land ${mode} for ${id}`));
|
|
341
|
+
}
|
|
342
|
+
export async function cmdStack(args) {
|
|
343
|
+
const sub = args[0]?.toLowerCase();
|
|
344
|
+
const rest = args.slice(1);
|
|
345
|
+
if (!sub || sub === "-h" || sub === "--help") {
|
|
346
|
+
throw new CommandError(STACK_USAGE);
|
|
347
|
+
}
|
|
348
|
+
if (sub === "create")
|
|
349
|
+
return cmdStackCreate(rest);
|
|
350
|
+
if (sub === "submit")
|
|
351
|
+
return cmdStackSubmit(rest);
|
|
352
|
+
if (sub === "list" || sub === "ls")
|
|
353
|
+
return cmdStackList(rest);
|
|
354
|
+
if (sub === "adopt")
|
|
355
|
+
return cmdStackAdopt(rest);
|
|
356
|
+
if (sub === "restack")
|
|
357
|
+
return cmdStackRestack(rest);
|
|
358
|
+
if (sub === "land" || sub === "land-next")
|
|
359
|
+
return cmdStackLand(rest);
|
|
360
|
+
if (sub === "auto-land" || sub === "autoland")
|
|
361
|
+
return cmdStackAutoLand(rest);
|
|
362
|
+
throw new CommandError(`${STACK_USAGE}\nunknown stack subcommand: ${sub}`);
|
|
363
|
+
}
|
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
|
@@ -7,6 +7,7 @@ import { cmdJobs, cmdThread } from "./commands/jobs.js";
|
|
|
7
7
|
import { cmdLogin } from "./commands/login.js";
|
|
8
8
|
import { cmdLogout } from "./commands/logout.js";
|
|
9
9
|
import { cmdReview } from "./commands/review.js";
|
|
10
|
+
import { cmdStack } from "./commands/stack.js";
|
|
10
11
|
import { cmdStatus } from "./commands/status.js";
|
|
11
12
|
import { cmdWhoami } from "./commands/whoami.js";
|
|
12
13
|
import { ansi } from "./ui/ansi.js";
|
|
@@ -27,6 +28,7 @@ export const COMMANDS = [
|
|
|
27
28
|
{ name: "chain", summary: "Show a branch review-chain timeline" },
|
|
28
29
|
{ name: "whoami", summary: "Key + plan + API base" },
|
|
29
30
|
{ name: "thread", summary: "Jobs in a review thread" },
|
|
31
|
+
{ name: "stack", summary: "Stacks: create, submit, list, adopt, restack, land" },
|
|
30
32
|
{ name: "clear", summary: "Clear screen and reprint banner" },
|
|
31
33
|
{ name: "exit", summary: "Leave the shell" },
|
|
32
34
|
{ name: "quit", summary: "Leave the shell" },
|
|
@@ -161,6 +163,9 @@ export async function runShell() {
|
|
|
161
163
|
}
|
|
162
164
|
await cmdThread(args[0], args.slice(1));
|
|
163
165
|
}
|
|
166
|
+
else if (cmd === "stack") {
|
|
167
|
+
await cmdStack(args);
|
|
168
|
+
}
|
|
164
169
|
else {
|
|
165
170
|
console.error(ansi.dim(`unknown command "${cmd}" -- type help`));
|
|
166
171
|
}
|
|
@@ -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
|
@@ -35,6 +35,13 @@ const WHATS_NEW = {
|
|
|
35
35
|
"0.3.2": [
|
|
36
36
|
"`mg` short alias — same binary as `mergestorm`",
|
|
37
37
|
],
|
|
38
|
+
"0.3.3": [
|
|
39
|
+
"`stack list` / `adopt` / `restack` / `land` / `auto-land`",
|
|
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
|
+
],
|
|
38
45
|
};
|
|
39
46
|
function formatStatusLines(me, key, barWidth) {
|
|
40
47
|
if (!key) {
|
|
@@ -58,7 +65,7 @@ function tipsLines() {
|
|
|
58
65
|
return [
|
|
59
66
|
ansi.dim("Tips for getting started"),
|
|
60
67
|
` ${ansi.green("review")} review main...HEAD`,
|
|
61
|
-
` ${ansi.green("
|
|
68
|
+
` ${ansi.green("stack")} create → submit → restack → land`,
|
|
62
69
|
` ${ansi.green("/help")} list all commands`,
|
|
63
70
|
];
|
|
64
71
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mergestorm",
|
|
3
|
-
"version": "0.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"
|