@brainervirus/workit-core 0.8.12 → 0.9.1
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/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// AR-16: path-gated releases. Replaces message-only commit analysis: a
|
|
3
|
+
// releasable commit counts only when it touches a PRODUCT PATH (any of the
|
|
4
|
+
// four package dirs). Tooling-only merges produce no release at all.
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const RELEASE_PACKAGES = [
|
|
9
|
+
"workit-core",
|
|
10
|
+
"workit-opencode",
|
|
11
|
+
"workit-cursor",
|
|
12
|
+
"workit-cli",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
const g = (root: string, args: string[]): string =>
|
|
16
|
+
execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
|
17
|
+
|
|
18
|
+
const SEMVER_TAG = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
19
|
+
|
|
20
|
+
export function latestTag(root = process.cwd()): string | null {
|
|
21
|
+
const out = g(root, ["tag", "--list", "v*", "--sort=-v:refname"])
|
|
22
|
+
.split("\n")
|
|
23
|
+
.map((l) => l.trim())
|
|
24
|
+
.filter((l) => SEMVER_TAG.test(l));
|
|
25
|
+
return out[0] ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Level = "major" | "minor" | "patch";
|
|
29
|
+
const LEVEL_RANK: Record<Level, number> = { patch: 1, minor: 2, major: 3 };
|
|
30
|
+
const TYPE_LEVEL: Record<string, Level> = { fix: "patch", perf: "patch", feat: "minor" };
|
|
31
|
+
|
|
32
|
+
const subjectLevel = (commit: string): Level | null => {
|
|
33
|
+
const firstLine = commit.split("\n")[0] ?? "";
|
|
34
|
+
const m = /^(?:fix|perf|feat)(?:\([^)]*\))?!?:/.exec(firstLine);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
if (m[0].includes("!")) return "major";
|
|
37
|
+
const body = commit.split("\n").slice(1).join("\n");
|
|
38
|
+
return /BREAKING[- ]CHANGE:/.test(body)
|
|
39
|
+
? "major"
|
|
40
|
+
: TYPE_LEVEL[m[0].split("(")[0].replace("!", "")];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Two-pass collection (sanctioned by the task brief): the single-pass
|
|
44
|
+
// `%H<NUL>%s%n%b` + `--name-only` interleave is brittle because execFileSync
|
|
45
|
+
// rejects NUL bytes inside arguments. Bounded by commit count; acceptable for
|
|
46
|
+
// this repo's cadence.
|
|
47
|
+
//
|
|
48
|
+
// diff-tree with -m unions files across a merge's parents (a plain `show`
|
|
49
|
+
// combined diff drops files identical to either parent — e.g. hotfix-branch
|
|
50
|
+
// back-merges), and -z returns raw NUL-delimited paths so spaces/non-ASCII
|
|
51
|
+
// are never C-quoted. NUL is fine in captured output, never in argv.
|
|
52
|
+
const commitsSince = (root: string, from: string): { message: string; files: string[] }[] => {
|
|
53
|
+
const hashes = g(root, ["log", "--reverse", "--format=%H", `${from}..HEAD`])
|
|
54
|
+
.split("\n")
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
return hashes.map((h) => ({
|
|
57
|
+
message: g(root, ["show", "-s", "--format=%B", h]),
|
|
58
|
+
files: g(root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-m", "--root", "-z", h])
|
|
59
|
+
.split("\0")
|
|
60
|
+
.filter(Boolean),
|
|
61
|
+
}));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function analyzeReleaseScope(
|
|
65
|
+
root = process.cwd(),
|
|
66
|
+
): { level: Level | null; productPkgs: string[] } {
|
|
67
|
+
const from = latestTag(root);
|
|
68
|
+
if (from === null) {
|
|
69
|
+
return { level: "minor", productPkgs: [...RELEASE_PACKAGES] };
|
|
70
|
+
}
|
|
71
|
+
const commits = commitsSince(root, from);
|
|
72
|
+
const levels: Level[] = [];
|
|
73
|
+
const pkgs = new Set<string>();
|
|
74
|
+
for (const { message, files } of commits) {
|
|
75
|
+
const touched = files.filter((f) => RELEASE_PACKAGES.some((p) => f.startsWith(`packages/${p}/`)));
|
|
76
|
+
if (touched.length === 0) continue;
|
|
77
|
+
const lvl = subjectLevel(message);
|
|
78
|
+
if (lvl) levels.push(lvl);
|
|
79
|
+
for (const f of touched) {
|
|
80
|
+
const pkg = RELEASE_PACKAGES.find((p) => f.startsWith(`packages/${p}/`));
|
|
81
|
+
if (pkg) pkgs.add(pkg);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (levels.length === 0) return { level: null, productPkgs: [...pkgs] };
|
|
85
|
+
const level = levels.reduce<Level>((best, l) => (LEVEL_RANK[l] > LEVEL_RANK[best] ? l : best), "patch");
|
|
86
|
+
return { level, productPkgs: [...pkgs] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (import.meta.main) {
|
|
90
|
+
const root = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
|
|
91
|
+
const { level } = analyzeReleaseScope(root);
|
|
92
|
+
if (level) process.stdout.write(`${level}\n`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// AR-16: selective publishing. Publishes only packages whose directory
|
|
3
|
+
// changed since the previous v* tag; logs an exact skip line per unchanged
|
|
4
|
+
// package so release logs answer "what shipped?" without leaving the terminal.
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { latestTag, RELEASE_PACKAGES } from "./analyze-release-scope";
|
|
8
|
+
|
|
9
|
+
const git = (root: string, args: string[]): string =>
|
|
10
|
+
execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
|
11
|
+
|
|
12
|
+
export function changedPackages(root: string, fromTag: string): string[] {
|
|
13
|
+
// Committed state only: <tag>..HEAD, never the working tree — unreviewed
|
|
14
|
+
// local edits must not decide what ships.
|
|
15
|
+
return RELEASE_PACKAGES.filter((pkg) => {
|
|
16
|
+
const out = git(root, ["diff", "--name-only", `${fromTag}..HEAD`, "--", `packages/${pkg}`]);
|
|
17
|
+
return out !== "";
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function publishChanged(opts: {
|
|
22
|
+
root: string;
|
|
23
|
+
dryRun?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Base tag to diff against. Empty string means first-ever release (ship
|
|
26
|
+
* all). Default: latestTag(root). semantic-release creates the NEW release
|
|
27
|
+
* tag before publish plugins run, so production passes the PREVIOUS tag via
|
|
28
|
+
* `${lastRelease.gitTag}` — diffing against latestTag() there is always
|
|
29
|
+
* empty and would skip every package.
|
|
30
|
+
*/
|
|
31
|
+
fromTag?: string;
|
|
32
|
+
run?: (cmd: string, args: string[], o: { cwd: string }) => unknown;
|
|
33
|
+
}): { published: string[]; skipped: string[]; tag: string | null } {
|
|
34
|
+
const { root, dryRun = false } = opts;
|
|
35
|
+
const run =
|
|
36
|
+
opts.run ??
|
|
37
|
+
((cmd: string, args: string[], o: { cwd: string }) =>
|
|
38
|
+
execFileSync(cmd, args, { cwd: o.cwd, encoding: "utf8", stdio: "inherit" }));
|
|
39
|
+
const tag =
|
|
40
|
+
opts.fromTag !== undefined ? (opts.fromTag === "" ? null : opts.fromTag) : latestTag(root);
|
|
41
|
+
if (tag === null) {
|
|
42
|
+
// First-ever release: everything ships.
|
|
43
|
+
const published: string[] = [];
|
|
44
|
+
for (const pkg of RELEASE_PACKAGES) {
|
|
45
|
+
const cwd = resolve(root, "packages", pkg);
|
|
46
|
+
if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd });
|
|
47
|
+
published.push(pkg);
|
|
48
|
+
console.log(`published ${pkg} @ ${cwd}`);
|
|
49
|
+
}
|
|
50
|
+
return { published, skipped: [], tag: null };
|
|
51
|
+
}
|
|
52
|
+
const changed = new Set(changedPackages(root, tag));
|
|
53
|
+
const published: string[] = [];
|
|
54
|
+
const skipped: string[] = [];
|
|
55
|
+
for (const pkg of RELEASE_PACKAGES) {
|
|
56
|
+
if (!changed.has(pkg)) {
|
|
57
|
+
skipped.push(pkg);
|
|
58
|
+
console.log(`skip ${pkg} (no payload change since ${tag})`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const cwd = resolve(root, "packages", pkg);
|
|
62
|
+
try {
|
|
63
|
+
if (!dryRun) run("npm", ["publish", "--access", "public"], { cwd });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.log(`publish failed ${pkg}: ${e instanceof Error ? e.message : String(e)}`);
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
published.push(pkg);
|
|
69
|
+
console.log(`published ${pkg} @ ${cwd}`);
|
|
70
|
+
}
|
|
71
|
+
return { published, skipped, tag };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// @semantic-release/exec spawns this Cmd as a shell string whose ONLY optional
|
|
75
|
+
// positional arg is rendered from ${lastRelease.gitTag}: present when a
|
|
76
|
+
// previous release exists, absent on a first-ever release. The repo root is
|
|
77
|
+
// always the spawn cwd (release.config.cjs paths are repo-root-relative), so
|
|
78
|
+
// the CLI takes no root argument.
|
|
79
|
+
if (import.meta.main) {
|
|
80
|
+
publishChanged({
|
|
81
|
+
root: process.cwd(),
|
|
82
|
+
dryRun: process.env.PUBLISH_DRY_RUN === "1",
|
|
83
|
+
...(process.argv[2] !== undefined ? { fromTag: process.argv[2] } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
package/src/core/branch.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
2
13
|
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
3
16
|
import path from "node:path";
|
|
4
17
|
import { gitContext } from "./git";
|
|
5
18
|
import { readConfig, resolveBranchPolicy } from "./config";
|
|
@@ -186,11 +199,10 @@ export const docsBranch = ({
|
|
|
186
199
|
return { error: `cannot resolve docs branch from HEAD ${JSON.stringify(current)}` };
|
|
187
200
|
};
|
|
188
201
|
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return { ok: false, error: "not in a git repository" };
|
|
202
|
+
// Read-only half of ensureBaseBranch (fetch --prune + show-ref origin/base):
|
|
203
|
+
// safe to run before any mutation so a missing origin/<base> fails before a
|
|
204
|
+
// stash push empties the tree.
|
|
205
|
+
const originBaseReady = (cwd: string, base: string): { ok: boolean; error?: string } => {
|
|
194
206
|
const run = (args: string[]) =>
|
|
195
207
|
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
196
208
|
try {
|
|
@@ -199,20 +211,32 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
|
|
|
199
211
|
} catch {
|
|
200
212
|
run(["fetch", "origin", "--prune"]);
|
|
201
213
|
}
|
|
202
|
-
let hasOriginBase = true;
|
|
203
214
|
try {
|
|
204
215
|
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${base}`], {
|
|
205
216
|
cwd,
|
|
206
217
|
stdio: "pipe",
|
|
207
218
|
});
|
|
208
219
|
} catch {
|
|
209
|
-
hasOriginBase = false;
|
|
210
|
-
}
|
|
211
|
-
if (!hasOriginBase)
|
|
212
220
|
return {
|
|
213
221
|
ok: false,
|
|
214
222
|
error: `origin/${base} missing — push ${base} before creating feature/* or bugfix/* branches`,
|
|
215
223
|
};
|
|
224
|
+
}
|
|
225
|
+
return { ok: true };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
error: error instanceof Error ? error.message : "ensure-base-branch failed",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Mutating half of ensureBaseBranch: fast-forwards the local base (creating
|
|
235
|
+
// it from origin/<base> if needed). Only safe on a clean tree.
|
|
236
|
+
const fastForwardBase = (cwd: string, base: string): { ok: boolean; error?: string } => {
|
|
237
|
+
const run = (args: string[]) =>
|
|
238
|
+
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
239
|
+
try {
|
|
216
240
|
let hasLocalBase = true;
|
|
217
241
|
try {
|
|
218
242
|
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${base}`], {
|
|
@@ -241,6 +265,82 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
|
|
|
241
265
|
}
|
|
242
266
|
};
|
|
243
267
|
|
|
268
|
+
export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; error?: string } => {
|
|
269
|
+
const git = gitContext(cwd);
|
|
270
|
+
if (!git.branch || git.branch === "unknown")
|
|
271
|
+
return { ok: false, error: "not in a git repository" };
|
|
272
|
+
const ready = originBaseReady(cwd, base);
|
|
273
|
+
if (!ready.ok) return ready;
|
|
274
|
+
return fastForwardBase(cwd, base);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// CA-05: flow-state snapshots live under the OS tempdir scoped by a hash of
|
|
278
|
+
// the workspace path — never inside the repository or docs/.
|
|
279
|
+
export const snapshotFlowState = (cwd: string): string => {
|
|
280
|
+
const root = path.join(
|
|
281
|
+
tmpdir(),
|
|
282
|
+
`workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}`,
|
|
283
|
+
);
|
|
284
|
+
rmSync(root, { recursive: true, force: true }); // drop a stale guard from a crashed run
|
|
285
|
+
mkdirSync(root, { recursive: true });
|
|
286
|
+
const docsDir = path.join(path.resolve(cwd), "docs");
|
|
287
|
+
let slugs: string[] = [];
|
|
288
|
+
try {
|
|
289
|
+
slugs = readdirSync(docsDir);
|
|
290
|
+
} catch {
|
|
291
|
+
return root; // no docs/ yet — zero-file snapshot
|
|
292
|
+
}
|
|
293
|
+
for (const slug of slugs) {
|
|
294
|
+
const src = path.join(docsDir, slug, "sdd", "flow.json");
|
|
295
|
+
try {
|
|
296
|
+
if (!statSync(src).isFile()) continue;
|
|
297
|
+
} catch {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const dest = path.join(root, "docs", slug, "sdd", "flow.json");
|
|
301
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
302
|
+
cpSync(src, dest);
|
|
303
|
+
}
|
|
304
|
+
return root;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
|
|
308
|
+
// root is removed only after every file is handled and retained on failure.
|
|
309
|
+
// A caught failure must not vanish: the message is returned so callers can
|
|
310
|
+
// surface it to operators as a warning.
|
|
311
|
+
export const restoreFlowSnapshot = (snapDir: string, cwd: string): string | undefined => {
|
|
312
|
+
const walk = (dir: string, rel: string): string[] =>
|
|
313
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
|
|
314
|
+
entry.isDirectory()
|
|
315
|
+
? walk(path.join(dir, entry.name), path.join(rel, entry.name))
|
|
316
|
+
: [path.join(rel, entry.name)],
|
|
317
|
+
);
|
|
318
|
+
try {
|
|
319
|
+
const workspace = path.resolve(cwd);
|
|
320
|
+
for (const rel of walk(snapDir, "")) {
|
|
321
|
+
const dest = path.join(workspace, rel);
|
|
322
|
+
if (existsSync(dest)) continue;
|
|
323
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
324
|
+
// Atomic publish: a crash mid-copy must never leave a truncated flow.json
|
|
325
|
+
// at the destination.
|
|
326
|
+
const tmpDest = `${dest}.tmp-${process.pid}`;
|
|
327
|
+
try {
|
|
328
|
+
copyFileSync(path.join(snapDir, rel), tmpDest);
|
|
329
|
+
renameSync(tmpDest, dest);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
rmSync(tmpDest, { force: true });
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
rmSync(snapDir, { recursive: true, force: true });
|
|
336
|
+
return undefined;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return `flow state snapshot restore failed: ${
|
|
339
|
+
error instanceof Error ? error.message : String(error)
|
|
340
|
+
}`;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
244
344
|
// Port of scripts/branch/setup-branch.sh
|
|
245
345
|
export const branchSetup = ({
|
|
246
346
|
action,
|
|
@@ -275,19 +375,35 @@ export const branchSetup = ({
|
|
|
275
375
|
const writeManifest = (data: Record<string, unknown>) =>
|
|
276
376
|
writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
277
377
|
|
|
378
|
+
let snapDir: string | null = null;
|
|
379
|
+
const restoreWithWarning = (dir: string | null): string[] => {
|
|
380
|
+
const warning = dir ? restoreFlowSnapshot(dir, cwd) : undefined;
|
|
381
|
+
return warning ? [warning] : [];
|
|
382
|
+
};
|
|
383
|
+
|
|
278
384
|
if (action === "reapply_stash") {
|
|
279
385
|
const manifest = readManifest();
|
|
280
386
|
const ref = manifest.stash_ref;
|
|
281
387
|
if (!ref) return { error: "no stash_ref in manifest" };
|
|
388
|
+
// D-03: guard flow.json across the stash pop window.
|
|
389
|
+
snapDir = snapshotFlowState(cwd);
|
|
282
390
|
try {
|
|
283
391
|
exec(["stash", "pop", String(ref)]);
|
|
284
392
|
} catch (error) {
|
|
285
|
-
|
|
393
|
+
// CA-03: the snapshot ran before the pop — a failing pop must still
|
|
394
|
+
// restore a mid-window-wiped flow.json before returning.
|
|
395
|
+
const [warning] = restoreWithWarning(snapDir);
|
|
396
|
+
return {
|
|
397
|
+
error: `${error instanceof Error ? error.message : "stash pop failed"}${
|
|
398
|
+
warning ? `; ${warning}` : ""
|
|
399
|
+
}`,
|
|
400
|
+
};
|
|
286
401
|
}
|
|
287
402
|
delete manifest.stash_ref;
|
|
288
403
|
delete manifest.stash_created_at;
|
|
289
404
|
writeManifest(manifest);
|
|
290
|
-
|
|
405
|
+
const warnings = restoreWithWarning(snapDir);
|
|
406
|
+
return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
291
407
|
}
|
|
292
408
|
|
|
293
409
|
const target = target_branch ?? "";
|
|
@@ -296,17 +412,65 @@ export const branchSetup = ({
|
|
|
296
412
|
if (!allowedBranch(cwd, target))
|
|
297
413
|
return { error: `target branch ${target} is not allowed by the branch policy` };
|
|
298
414
|
|
|
415
|
+
// CA-02: resolve the base up front so an unresolvable base fails before
|
|
416
|
+
// any mutation. The origin/<base> validation runs after the stash gate
|
|
417
|
+
// below but still BEFORE any mutation (no snapshot, no stash push).
|
|
418
|
+
let base: string | undefined;
|
|
419
|
+
let targetExists = true;
|
|
420
|
+
try {
|
|
421
|
+
exec(["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]);
|
|
422
|
+
} catch {
|
|
423
|
+
targetExists = false;
|
|
424
|
+
}
|
|
425
|
+
if (!targetExists) {
|
|
426
|
+
const baseResolved = baseBranch(cwd);
|
|
427
|
+
if ("error" in baseResolved) return { error: baseResolved.error };
|
|
428
|
+
base = baseResolved.base;
|
|
429
|
+
}
|
|
430
|
+
|
|
299
431
|
let stash_ref: string | undefined;
|
|
432
|
+
// Best-effort restore; if the pop itself fails, the caller's error gains a
|
|
433
|
+
// suffix pointing at the stash so stranded work stays discoverable.
|
|
434
|
+
const failAfterStash = (message: string): { error: string } => {
|
|
435
|
+
let suffix = "";
|
|
436
|
+
if (stash_ref) {
|
|
437
|
+
try {
|
|
438
|
+
exec(["stash", "pop", stash_ref]);
|
|
439
|
+
stash_ref = undefined;
|
|
440
|
+
} catch {
|
|
441
|
+
suffix = " (changes preserved in stash)";
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// CA-03: the snapshot ran before the stash push, so every error return
|
|
445
|
+
// here must still restore a mid-window-wiped flow.json and drop the
|
|
446
|
+
// guard root (a retained root is destroyed by the next run's
|
|
447
|
+
// stale-root rmSync). Never masks the original error.
|
|
448
|
+
const [warning] = restoreWithWarning(snapDir);
|
|
449
|
+
return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
|
|
450
|
+
};
|
|
300
451
|
if (current !== target) {
|
|
301
452
|
const dirty = Boolean(gitContext(cwd).status_short.trim());
|
|
453
|
+
if (dirty && stash !== "yes") {
|
|
454
|
+
return {
|
|
455
|
+
error:
|
|
456
|
+
"dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
// CA-02: validate origin/<base> before ANY mutation (no snapshot, no
|
|
460
|
+
// stash push, no checkout) so a missing origin/<base> fails with the
|
|
461
|
+
// tree untouched. The mutating fast-forward stays below: it checks out
|
|
462
|
+
// the base branch — unsafe on a dirty tree.
|
|
463
|
+
let validatedBase: string | undefined;
|
|
464
|
+
if (!targetExists && base !== undefined) {
|
|
465
|
+
const ready = originBaseReady(cwd, base);
|
|
466
|
+
if (!ready.ok) return { error: ready.error ?? "ensure-base-branch failed" };
|
|
467
|
+
validatedBase = base;
|
|
468
|
+
}
|
|
302
469
|
if (dirty) {
|
|
303
|
-
if (stash !== "yes") {
|
|
304
|
-
return {
|
|
305
|
-
error:
|
|
306
|
-
"dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
470
|
try {
|
|
471
|
+
// CA-03: snapshot before the stash push so flow.json survives the
|
|
472
|
+
// stash/checkout window even if the pathspec exclusion misses.
|
|
473
|
+
snapDir = snapshotFlowState(cwd);
|
|
310
474
|
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
|
|
311
475
|
} catch (error) {
|
|
312
476
|
return { error: error instanceof Error ? error.message : "stash push failed" };
|
|
@@ -318,32 +482,59 @@ export const branchSetup = ({
|
|
|
318
482
|
} catch (error) {
|
|
319
483
|
const message = error instanceof Error ? error.message : "checkout failed";
|
|
320
484
|
if (/worktree/i.test(message)) {
|
|
321
|
-
return
|
|
322
|
-
|
|
323
|
-
|
|
485
|
+
return failAfterStash(
|
|
486
|
+
`branch ${target} is locked by an existing git worktree — remove it first (we do not use worktrees)`,
|
|
487
|
+
);
|
|
324
488
|
}
|
|
325
489
|
try {
|
|
326
|
-
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
490
|
+
let effectiveBase = base;
|
|
491
|
+
if (effectiveBase === undefined) {
|
|
492
|
+
const lateResolved = baseBranch(cwd);
|
|
493
|
+
if ("error" in lateResolved) return failAfterStash(lateResolved.error);
|
|
494
|
+
effectiveBase = lateResolved.base;
|
|
495
|
+
}
|
|
496
|
+
// Pre-validated above when the target was missing: only the
|
|
497
|
+
// fast-forward mutation remains post-stash.
|
|
498
|
+
const baseResult =
|
|
499
|
+
validatedBase !== undefined
|
|
500
|
+
? fastForwardBase(cwd, effectiveBase)
|
|
501
|
+
: ensureBaseBranch(cwd, effectiveBase);
|
|
502
|
+
if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
|
|
330
503
|
exec(["checkout", "-b", target]);
|
|
331
504
|
} catch (createError) {
|
|
332
|
-
return
|
|
333
|
-
|
|
334
|
-
|
|
505
|
+
return failAfterStash(
|
|
506
|
+
createError instanceof Error ? createError.message : "branch create failed",
|
|
507
|
+
);
|
|
335
508
|
}
|
|
336
509
|
}
|
|
337
510
|
}
|
|
338
511
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
512
|
+
try {
|
|
513
|
+
const manifest = readManifest();
|
|
514
|
+
manifest.branch = target;
|
|
515
|
+
manifest.previous_branch = current;
|
|
516
|
+
if (stash_ref) {
|
|
517
|
+
manifest.stash_ref = stash_ref;
|
|
518
|
+
manifest.stash_created_at = new Date().toISOString();
|
|
519
|
+
}
|
|
520
|
+
writeManifest(manifest);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const result = failAfterStash(
|
|
523
|
+
error instanceof Error
|
|
524
|
+
? `manifest update failed: ${error.message}`
|
|
525
|
+
: "manifest update failed",
|
|
526
|
+
);
|
|
527
|
+
// After a successful pop, don't strand HEAD on the half-created target:
|
|
528
|
+
// return to the originating branch (best-effort; a conflicting tree can
|
|
529
|
+
// still refuse the checkout and keeps the popped state).
|
|
530
|
+
if (stash_ref === undefined && gitContext(cwd).branch !== current) {
|
|
531
|
+
try {
|
|
532
|
+
exec(["checkout", current]);
|
|
533
|
+
} catch {}
|
|
534
|
+
}
|
|
535
|
+
return result;
|
|
345
536
|
}
|
|
346
|
-
|
|
537
|
+
const warnings = restoreWithWarning(snapDir);
|
|
347
538
|
return {
|
|
348
539
|
action: "setup",
|
|
349
540
|
ok: true,
|
|
@@ -351,5 +542,6 @@ export const branchSetup = ({
|
|
|
351
542
|
previous_branch: current,
|
|
352
543
|
stash_ref: stash_ref ?? null,
|
|
353
544
|
manifest: manifestPath,
|
|
545
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
354
546
|
};
|
|
355
547
|
};
|