mergestorm 0.3.4 → 0.3.6
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 +9 -10
- package/dist/api.d.ts +13 -4
- package/dist/api.js +8 -5
- package/dist/cli.js +5 -4
- package/dist/commands/review.d.ts +1 -1
- package/dist/commands/review.js +4 -6
- package/dist/commands/stack.d.ts +1 -0
- package/dist/commands/stack.js +85 -28
- package/dist/config.d.ts +1 -0
- package/dist/config.js +4 -1
- package/dist/git-stack.d.ts +9 -1
- package/dist/git-stack.js +36 -2
- package/dist/git.d.ts +0 -4
- package/dist/git.js +1 -32
- package/dist/pr-body.d.ts +8 -0
- package/dist/pr-body.js +63 -0
- package/dist/repo-identity.d.ts +12 -0
- package/dist/repo-identity.js +42 -0
- package/dist/stack-meta.d.ts +23 -5
- package/dist/stack-meta.js +259 -24
- package/dist/ui/banner.js +8 -0
- package/package.json +1 -1
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.6 ──────────────────────────────────────────────────────────╮
|
|
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 • stack
|
|
57
|
+
│ ▝▘ What's new in v0.3.6 │
|
|
58
|
+
│ ● msk… · maelstrom • stack state under ~/.mergestorm/stacks/ │
|
|
59
59
|
│ [████░░░░░░░░░░] 12% used │
|
|
60
60
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
61
61
|
mergestorm
|
|
@@ -99,13 +99,16 @@ mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
|
|
|
99
99
|
mergestorm stack restack <stack-id> Restack descendants
|
|
100
100
|
mergestorm stack land <stack-id> Land the bottom open PR
|
|
101
101
|
mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
|
|
102
|
+
mergestorm stack reset --force Clear local authoring state (not branches/PRs)
|
|
102
103
|
```
|
|
103
104
|
|
|
104
105
|
`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
|
|
|
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
|
|
107
|
+
`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.
|
|
107
108
|
|
|
108
|
-
`stack submit` walks
|
|
109
|
+
`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 Cyclone app token.
|
|
110
|
+
|
|
111
|
+
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
112
|
|
|
110
113
|
### `login`
|
|
111
114
|
|
|
@@ -119,7 +122,7 @@ Removes `~/.mergestorm/config.json` so the stored key is no longer used.
|
|
|
119
122
|
|
|
120
123
|
### `review [base] [head]`
|
|
121
124
|
|
|
122
|
-
Collects `git diff base...head` (default `main...HEAD`), attaches the changed file contents (up to 40 files, skipping files > 400 KB),
|
|
125
|
+
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
126
|
|
|
124
127
|
```bash
|
|
125
128
|
mergestorm review # main...HEAD
|
|
@@ -141,10 +144,6 @@ Require a logged-in key and API support for `GET /api/v1/me` and `GET /api/v1/re
|
|
|
141
144
|
|
|
142
145
|
`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
146
|
|
|
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
147
|
## Environment
|
|
149
148
|
|
|
150
149
|
| Variable | Purpose | Default |
|
package/dist/api.d.ts
CHANGED
|
@@ -91,6 +91,13 @@ export type StackLayerDto = {
|
|
|
91
91
|
htmlUrl: string | null;
|
|
92
92
|
ciStatus: string;
|
|
93
93
|
reviewStatus: string;
|
|
94
|
+
checks: {
|
|
95
|
+
total: number;
|
|
96
|
+
success: number;
|
|
97
|
+
pending: number;
|
|
98
|
+
failure: number;
|
|
99
|
+
failingName: string | null;
|
|
100
|
+
} | null;
|
|
94
101
|
conflictDetail: string | null;
|
|
95
102
|
lastRestackedSha: string | null;
|
|
96
103
|
};
|
|
@@ -99,7 +106,7 @@ export type StackDto = {
|
|
|
99
106
|
owner: string;
|
|
100
107
|
repo: string;
|
|
101
108
|
trunkBranch: string;
|
|
102
|
-
|
|
109
|
+
autoPromoteWhenGreen: boolean;
|
|
103
110
|
layers: StackLayerDto[];
|
|
104
111
|
};
|
|
105
112
|
/** List stacks (Bearer GET /api/v1/stacks). */
|
|
@@ -109,8 +116,10 @@ export declare function adoptStack(owner: string, repo: string, prNumber: number
|
|
|
109
116
|
/** Restack descendants (Bearer POST /api/v1/stacks/:id/restack). */
|
|
110
117
|
export declare function restackStack(stackId: string, cfg?: Config): Promise<unknown>;
|
|
111
118
|
/** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
|
|
112
|
-
export declare function landNextStack(stackId: string, cfg?: Config
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
export declare function landNextStack(stackId: string, cfg?: Config, opts?: {
|
|
120
|
+
prNumber?: number;
|
|
121
|
+
}): 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>;
|
|
115
124
|
/** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
|
|
116
125
|
export declare function listThreads(limit?: number, cfg?: Config): Promise<ThreadListItem[]>;
|
package/dist/api.js
CHANGED
|
@@ -119,12 +119,15 @@ export async function restackStack(stackId, cfg) {
|
|
|
119
119
|
return stackMutation(stackId, "/restack", "POST", {}, "Failed to restack", cfg);
|
|
120
120
|
}
|
|
121
121
|
/** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
|
|
122
|
-
export async function landNextStack(stackId, cfg) {
|
|
123
|
-
|
|
122
|
+
export async function landNextStack(stackId, cfg, opts) {
|
|
123
|
+
const json = opts?.prNumber != null && Number.isInteger(opts.prNumber) && opts.prNumber > 0
|
|
124
|
+
? { prNumber: opts.prNumber }
|
|
125
|
+
: {};
|
|
126
|
+
return stackMutation(stackId, "/land-next", "POST", json, "Failed to land next", cfg);
|
|
124
127
|
}
|
|
125
|
-
/** Toggle auto-
|
|
126
|
-
export async function
|
|
127
|
-
return stackMutation(stackId, "", "PATCH", {
|
|
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);
|
|
128
131
|
}
|
|
129
132
|
/** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
|
|
130
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 (
|
|
34
|
-
mergestorm stack submit Push
|
|
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
|
-
mergestorm stack land <stack-id> Land
|
|
39
|
-
mergestorm stack auto-
|
|
39
|
+
mergestorm stack land <stack-id> Land bottom PR (or promote into unit)
|
|
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
|
|
|
@@ -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;
|
package/dist/commands/review.js
CHANGED
|
@@ -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,
|
|
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)
|
|
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
|
});
|
package/dist/commands/stack.d.ts
CHANGED
package/dist/commands/stack.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
import { adoptStack, landNextStack, listStacks, restackStack,
|
|
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
|
-
import { branchExists, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, pushBranch, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
|
|
6
|
-
import {
|
|
5
|
+
import { branchExists, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
|
|
6
|
+
import { buildPrBodyFromCommit } from "../pr-body.js";
|
|
7
|
+
import { gitTopLevel } from "../repo-identity.js";
|
|
8
|
+
import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, } from "../stack-meta.js";
|
|
7
9
|
import { ansi } from "../ui/ansi.js";
|
|
8
10
|
import { parseGithubOriginRepo } from "./review.js";
|
|
9
11
|
const STACK_USAGE = `usage:
|
|
10
12
|
mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--json]
|
|
11
13
|
mergestorm stack submit [--json]
|
|
14
|
+
mergestorm stack reset --force
|
|
12
15
|
mergestorm stack list [--json]
|
|
13
16
|
mergestorm stack adopt <owner/repo>#<pr>
|
|
14
17
|
mergestorm stack restack <stack-id> [--json]
|
|
15
18
|
mergestorm stack land <stack-id> [--json]
|
|
16
|
-
mergestorm stack auto-
|
|
19
|
+
mergestorm stack auto-promote on|off <stack-id> [--json]
|
|
20
|
+
|
|
21
|
+
stack submit opens PRs with a body generated from each layer tip commit.
|
|
22
|
+
stack land promotes into the review unit when one exists; otherwise lands
|
|
23
|
+
the bottom open PR (unit-less stacks).`;
|
|
17
24
|
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
25
|
export function requireStackId(raw, usage) {
|
|
19
26
|
const id = raw?.trim() ?? "";
|
|
@@ -44,7 +51,7 @@ export function parseAdoptTarget(args) {
|
|
|
44
51
|
}
|
|
45
52
|
function formatStackHuman(s) {
|
|
46
53
|
const lines = [];
|
|
47
|
-
const auto = s.
|
|
54
|
+
const auto = s.autoPromoteWhenGreen ? " · auto-promote" : "";
|
|
48
55
|
lines.push(` ${ansi.bold(`${s.owner}/${s.repo}`)} trunk=${s.trunkBranch}${auto}`);
|
|
49
56
|
lines.push(` ${ansi.dim(s.id)}`);
|
|
50
57
|
if (s.layers.length === 0) {
|
|
@@ -102,9 +109,20 @@ export function parseStackCreateArgs(args) {
|
|
|
102
109
|
}
|
|
103
110
|
return { name, onto, trunk, asJson };
|
|
104
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
|
+
}
|
|
105
117
|
async function cmdStackCreate(args) {
|
|
106
118
|
const { name, onto, trunk: trunkFlag, asJson } = parseStackCreateArgs(args);
|
|
107
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
|
+
}
|
|
108
126
|
if (worktreeDirty(cwd)) {
|
|
109
127
|
throw new CommandError("Working tree is dirty. Commit or stash changes before `stack create`.");
|
|
110
128
|
}
|
|
@@ -122,16 +140,16 @@ async function cmdStackCreate(args) {
|
|
|
122
140
|
let meta = await loadStackMeta(cwd);
|
|
123
141
|
const trunk = trunkFlag?.trim() || meta?.trunk || discoverTrunk(cwd);
|
|
124
142
|
if (!meta) {
|
|
125
|
-
meta =
|
|
143
|
+
meta = emptyStackMeta(trunk);
|
|
126
144
|
}
|
|
127
145
|
else if (trunkFlag?.trim() && meta.trunk !== trunk) {
|
|
128
|
-
throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}.
|
|
146
|
+
throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}. Run \`mg stack reset --force\` to discard local pre-submit state.`);
|
|
129
147
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
throw new CommandError(
|
|
148
|
+
try {
|
|
149
|
+
meta = selectStackForCreate(meta, parentBranch);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw new CommandError(err instanceof Error ? err.message : String(err));
|
|
135
153
|
}
|
|
136
154
|
try {
|
|
137
155
|
createBranchFromHead(branch, cwd);
|
|
@@ -139,10 +157,9 @@ async function cmdStackCreate(args) {
|
|
|
139
157
|
catch (err) {
|
|
140
158
|
throw new CommandError(err instanceof Error ? err.message : String(err));
|
|
141
159
|
}
|
|
142
|
-
let next;
|
|
143
160
|
try {
|
|
144
|
-
|
|
145
|
-
await saveStackMeta(
|
|
161
|
+
meta = appendLayer(meta, { branch, parentBranch });
|
|
162
|
+
await saveStackMeta(meta, cwd);
|
|
146
163
|
}
|
|
147
164
|
catch (err) {
|
|
148
165
|
try {
|
|
@@ -151,13 +168,22 @@ async function cmdStackCreate(args) {
|
|
|
151
168
|
catch { /* rollback best-effort */ }
|
|
152
169
|
throw new CommandError(err instanceof Error ? err.message : String(err));
|
|
153
170
|
}
|
|
171
|
+
const layers = activeLayers(meta);
|
|
154
172
|
if (asJson) {
|
|
155
|
-
console.log(JSON.stringify({
|
|
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));
|
|
156
182
|
return;
|
|
157
183
|
}
|
|
158
184
|
console.log(ansi.green(` Created ${branch} (onto ${parentBranch})`));
|
|
159
|
-
console.log(ansi.dim(` trunk: ${
|
|
160
|
-
console.log(ansi.dim(
|
|
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"));
|
|
161
187
|
}
|
|
162
188
|
async function cmdStackSubmit(args) {
|
|
163
189
|
const asJson = args.includes("--json");
|
|
@@ -165,18 +191,25 @@ async function cmdStackSubmit(args) {
|
|
|
165
191
|
throw new CommandError("usage: mergestorm stack submit [--json]");
|
|
166
192
|
}
|
|
167
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
|
+
}
|
|
168
200
|
const meta = await loadStackMeta(cwd);
|
|
169
|
-
|
|
170
|
-
|
|
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`.");
|
|
171
204
|
}
|
|
172
|
-
const origin = parseGithubOriginRepo();
|
|
205
|
+
const origin = parseGithubOriginRepo(cwd);
|
|
173
206
|
if (!origin) {
|
|
174
207
|
throw new CommandError("Could not parse GitHub owner/repo from `origin`. Set a github.com remote and retry.");
|
|
175
208
|
}
|
|
176
209
|
const { owner, repo } = origin;
|
|
177
210
|
requireGh(cwd);
|
|
178
211
|
const opened = [];
|
|
179
|
-
for (const layer of
|
|
212
|
+
for (const layer of layers) {
|
|
180
213
|
const base = layer.parentBranch || meta.trunk;
|
|
181
214
|
if (!branchExists(layer.branch, cwd)) {
|
|
182
215
|
throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
|
|
@@ -192,6 +225,7 @@ async function cmdStackSubmit(args) {
|
|
|
192
225
|
let created = false;
|
|
193
226
|
if (prNumber == null) {
|
|
194
227
|
const title = tipCommitSubject(layer.branch, cwd);
|
|
228
|
+
const body = buildPrBodyFromCommit(tipCommitMessage(layer.branch, cwd));
|
|
195
229
|
console.log(ansi.dim(` Opening PR ${layer.branch} → ${base} …`));
|
|
196
230
|
prNumber = createPr({
|
|
197
231
|
owner,
|
|
@@ -199,7 +233,7 @@ async function cmdStackSubmit(args) {
|
|
|
199
233
|
base,
|
|
200
234
|
head: layer.branch,
|
|
201
235
|
title,
|
|
202
|
-
body
|
|
236
|
+
body,
|
|
203
237
|
cwd,
|
|
204
238
|
});
|
|
205
239
|
created = true;
|
|
@@ -224,6 +258,8 @@ async function cmdStackSubmit(args) {
|
|
|
224
258
|
throw new CommandError("Stack was not registered");
|
|
225
259
|
}
|
|
226
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);
|
|
227
263
|
if (asJson) {
|
|
228
264
|
console.log(JSON.stringify({
|
|
229
265
|
owner,
|
|
@@ -247,6 +283,11 @@ async function cmdStackSubmit(args) {
|
|
|
247
283
|
}
|
|
248
284
|
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
|
|
249
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
|
+
}
|
|
250
291
|
async function cmdStackList(args) {
|
|
251
292
|
const asJson = args.includes("--json");
|
|
252
293
|
const cfg = await loadConfig();
|
|
@@ -311,6 +352,7 @@ async function cmdStackLand(args) {
|
|
|
311
352
|
const asJson = args.includes("--json");
|
|
312
353
|
const id = requireStackId(args.find((a) => a !== "--json"), "usage: mergestorm stack land <stack-id>");
|
|
313
354
|
const cfg = await loadConfig();
|
|
355
|
+
// Server routes unit stacks to promote-next (#244 / AUD-07).
|
|
314
356
|
const body = await landNextStack(id, cfg);
|
|
315
357
|
if (asJson) {
|
|
316
358
|
console.log(JSON.stringify(body, null, 2));
|
|
@@ -318,6 +360,15 @@ async function cmdStackLand(args) {
|
|
|
318
360
|
}
|
|
319
361
|
const pr = body.mergedPrNumber;
|
|
320
362
|
const branch = body.mergedBranch;
|
|
363
|
+
const uNumber = body.uNumber;
|
|
364
|
+
if (typeof uNumber === "number" && uNumber > 0) {
|
|
365
|
+
console.log(ansi.green(` Promoted` +
|
|
366
|
+
(pr != null ? ` #${pr}` : "") +
|
|
367
|
+
` into U${uNumber}` +
|
|
368
|
+
(branch ? ` (${branch})` : "") +
|
|
369
|
+
` on ${id}`));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
321
372
|
console.log(ansi.green(` Landed` +
|
|
322
373
|
(pr != null ? ` #${pr}` : "") +
|
|
323
374
|
(branch ? ` (${branch})` : "") +
|
|
@@ -327,17 +378,17 @@ async function cmdStackAutoLand(args) {
|
|
|
327
378
|
const asJson = args.includes("--json");
|
|
328
379
|
const filtered = args.filter((a) => a !== "--json");
|
|
329
380
|
const mode = filtered[0]?.toLowerCase();
|
|
330
|
-
const id = requireStackId(filtered[1], "usage: mergestorm stack auto-
|
|
381
|
+
const id = requireStackId(filtered[1], "usage: mergestorm stack auto-promote on|off <stack-id>");
|
|
331
382
|
if (mode !== "on" && mode !== "off") {
|
|
332
|
-
throw new CommandError("usage: mergestorm stack auto-
|
|
383
|
+
throw new CommandError("usage: mergestorm stack auto-promote on|off <stack-id>");
|
|
333
384
|
}
|
|
334
385
|
const cfg = await loadConfig();
|
|
335
|
-
const body = await
|
|
386
|
+
const body = await setStackAutoPromote(id, mode === "on", cfg);
|
|
336
387
|
if (asJson) {
|
|
337
388
|
console.log(JSON.stringify(body, null, 2));
|
|
338
389
|
return;
|
|
339
390
|
}
|
|
340
|
-
console.log(ansi.green(` Auto-
|
|
391
|
+
console.log(ansi.green(` Auto-promote ${mode} for ${id}`));
|
|
341
392
|
}
|
|
342
393
|
export async function cmdStack(args) {
|
|
343
394
|
const sub = args[0]?.toLowerCase();
|
|
@@ -349,6 +400,8 @@ export async function cmdStack(args) {
|
|
|
349
400
|
return cmdStackCreate(rest);
|
|
350
401
|
if (sub === "submit")
|
|
351
402
|
return cmdStackSubmit(rest);
|
|
403
|
+
if (sub === "reset")
|
|
404
|
+
return cmdStackReset(rest);
|
|
352
405
|
if (sub === "list" || sub === "ls")
|
|
353
406
|
return cmdStackList(rest);
|
|
354
407
|
if (sub === "adopt")
|
|
@@ -357,7 +410,11 @@ export async function cmdStack(args) {
|
|
|
357
410
|
return cmdStackRestack(rest);
|
|
358
411
|
if (sub === "land" || sub === "land-next")
|
|
359
412
|
return cmdStackLand(rest);
|
|
360
|
-
if (sub === "auto-
|
|
413
|
+
if (sub === "auto-promote" ||
|
|
414
|
+
sub === "autopromote" ||
|
|
415
|
+
sub === "auto-land" ||
|
|
416
|
+
sub === "autoland") {
|
|
361
417
|
return cmdStackAutoLand(rest);
|
|
418
|
+
}
|
|
362
419
|
throw new CommandError(`${STACK_USAGE}\nunknown stack subcommand: ${sub}`);
|
|
363
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(
|
|
9
|
+
return path.join(mergestormHome(), "config.json");
|
|
7
10
|
}
|
|
8
11
|
export async function loadConfig() {
|
|
9
12
|
try {
|
package/dist/git-stack.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/** Current branch name, or null when detached. */
|
|
2
2
|
export declare function currentBranch(cwd?: string): string | null;
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* True when the worktree has staged/unstaged/untracked changes that should
|
|
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.
|
|
9
|
+
*/
|
|
4
10
|
export declare function worktreeDirty(cwd?: string): boolean;
|
|
5
11
|
/**
|
|
6
12
|
* Resolve trunk branch: --trunk flag, then origin/HEAD, then main/master if present.
|
|
@@ -16,3 +22,5 @@ export declare function defaultLayerBranchName(explicit?: string): string;
|
|
|
16
22
|
export declare function pushBranch(branch: string, cwd?: string): void;
|
|
17
23
|
/** First line of the tip commit on `branch` (for PR title). */
|
|
18
24
|
export declare function tipCommitSubject(branch: string, cwd?: string): string;
|
|
25
|
+
/** Full tip commit message on `branch` (subject + body; for PR description). */
|
|
26
|
+
export declare function tipCommitMessage(branch: string, cwd?: string): string;
|
package/dist/git-stack.js
CHANGED
|
@@ -11,10 +11,32 @@ export function currentBranch(cwd = process.cwd()) {
|
|
|
11
11
|
return null;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* True when the worktree has staged/unstaged/untracked changes that should
|
|
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.
|
|
20
|
+
*/
|
|
15
21
|
export function worktreeDirty(cwd = process.cwd()) {
|
|
16
22
|
const out = git(["status", "--porcelain"], cwd);
|
|
17
|
-
|
|
23
|
+
for (const line of out.split("\n")) {
|
|
24
|
+
const trimmed = line.trimEnd();
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
continue;
|
|
27
|
+
// porcelain: XY PATH or XY ORIG -> PATH (renames only)
|
|
28
|
+
const status = trimmed.slice(0, 2);
|
|
29
|
+
const pathPart = (status.includes("R") || status.includes("C"))
|
|
30
|
+
? trimmed.slice(3).split(" -> ").pop()?.trim() ?? ""
|
|
31
|
+
: trimmed.slice(3).trim();
|
|
32
|
+
if (pathPart.startsWith(".mergestorm/") ||
|
|
33
|
+
pathPart.endsWith("/.mergestorm/") ||
|
|
34
|
+
pathPart.endsWith("/.mergestorm/stack.json")) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
18
40
|
}
|
|
19
41
|
/**
|
|
20
42
|
* Resolve trunk branch: --trunk flag, then origin/HEAD, then main/master if present.
|
|
@@ -97,3 +119,15 @@ export function tipCommitSubject(branch, cwd = process.cwd()) {
|
|
|
97
119
|
return branch;
|
|
98
120
|
}
|
|
99
121
|
}
|
|
122
|
+
/** Full tip commit message on `branch` (subject + body; for PR description). */
|
|
123
|
+
export function tipCommitMessage(branch, cwd = process.cwd()) {
|
|
124
|
+
try {
|
|
125
|
+
const s = git(["log", "-1", "--format=%B", branch], cwd);
|
|
126
|
+
if (s.trim())
|
|
127
|
+
return s;
|
|
128
|
+
return tipCommitSubject(branch, cwd);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return tipCommitSubject(branch, cwd);
|
|
132
|
+
}
|
|
133
|
+
}
|
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 {
|
|
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")
|
package/dist/pr-body.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a GitHub PR body from a tip commit message (#217).
|
|
3
|
+
* Pure — no git / network.
|
|
4
|
+
*/
|
|
5
|
+
const TRAILER_RE = /^(fixes|closes|resolves)\s+#(\d+)\s*$/i;
|
|
6
|
+
/**
|
|
7
|
+
* Turn `git log -1 --format=%B` output into a non-empty PR description.
|
|
8
|
+
*/
|
|
9
|
+
export function buildPrBodyFromCommit(fullMessage) {
|
|
10
|
+
const lines = fullMessage.replace(/\r\n/g, "\n").split("\n");
|
|
11
|
+
const subject = (lines[0] ?? "").trim() || "Update";
|
|
12
|
+
const trailers = [];
|
|
13
|
+
const bodyLines = [];
|
|
14
|
+
for (const raw of lines.slice(1)) {
|
|
15
|
+
const line = raw.trim();
|
|
16
|
+
if (!line)
|
|
17
|
+
continue;
|
|
18
|
+
const m = TRAILER_RE.exec(line);
|
|
19
|
+
if (m) {
|
|
20
|
+
const verb = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase();
|
|
21
|
+
trailers.push(`${verb} #${m[2]}`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
bodyLines.push(line);
|
|
25
|
+
}
|
|
26
|
+
// Also pick trailers that appear as the whole subject (unusual but cheap).
|
|
27
|
+
const subjectTrailer = TRAILER_RE.exec(subject);
|
|
28
|
+
const summaryBullets = [];
|
|
29
|
+
if (subjectTrailer) {
|
|
30
|
+
const verb = subjectTrailer[1][0].toUpperCase() + subjectTrailer[1].slice(1).toLowerCase();
|
|
31
|
+
trailers.unshift(`${verb} #${subjectTrailer[2]}`);
|
|
32
|
+
summaryBullets.push(`- ${subject}`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
summaryBullets.push(`- ${subject}`);
|
|
36
|
+
}
|
|
37
|
+
for (const para of bodyLines) {
|
|
38
|
+
summaryBullets.push(`- ${para}`);
|
|
39
|
+
}
|
|
40
|
+
const parts = [
|
|
41
|
+
"## Summary",
|
|
42
|
+
...summaryBullets,
|
|
43
|
+
"",
|
|
44
|
+
"## Test plan",
|
|
45
|
+
"- [ ] CI green",
|
|
46
|
+
];
|
|
47
|
+
if (trailers.length > 0) {
|
|
48
|
+
parts.push("", ...uniqueTrailers(trailers));
|
|
49
|
+
}
|
|
50
|
+
return parts.join("\n") + "\n";
|
|
51
|
+
}
|
|
52
|
+
function uniqueTrailers(trailers) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const t of trailers) {
|
|
56
|
+
const key = t.toLowerCase();
|
|
57
|
+
if (seen.has(key))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(key);
|
|
60
|
+
out.push(t);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/stack-meta.d.ts
CHANGED
|
@@ -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:
|
|
10
|
+
version: 2;
|
|
8
11
|
trunk: string;
|
|
9
|
-
|
|
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
|
|
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;
|
package/dist/stack-meta.js
CHANGED
|
@@ -1,25 +1,78 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Stored
|
|
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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -42,6 +42,14 @@ const WHATS_NEW = {
|
|
|
42
42
|
"`stack create` / `submit` — author stacks with `mg` (not Graphite)",
|
|
43
43
|
"Adopt is import-only; happy path is create → submit → restack → land",
|
|
44
44
|
],
|
|
45
|
+
"0.3.5": [
|
|
46
|
+
"`stack submit` fills PR body from the tip commit (keeps Fixes/Closes)",
|
|
47
|
+
"Unit-stack `stack land` promotes (matches server gates)",
|
|
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
|
+
],
|
|
45
53
|
};
|
|
46
54
|
function formatStatusLines(me, key, barWidth) {
|
|
47
55
|
if (!key) {
|