@helmsmith/workbrokr 0.1.0 → 0.3.0
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 +1 -1
- package/SKILL.md +19 -8
- package/dist/cli.js +1568 -342
- package/dist/cli.js.map +1 -1
- package/dist/core/index.js +1576 -0
- package/dist/core/index.js.map +1 -0
- package/package.json +7 -3
package/dist/cli.js
CHANGED
|
@@ -25,23 +25,30 @@ function createCli(opts) {
|
|
|
25
25
|
// src/commands/audit.ts
|
|
26
26
|
import chalk3 from "chalk";
|
|
27
27
|
|
|
28
|
-
//
|
|
28
|
+
// src/lib/context.ts
|
|
29
|
+
import chalk from "chalk";
|
|
30
|
+
|
|
31
|
+
// src/core/config.ts
|
|
29
32
|
import {
|
|
30
33
|
appendFileSync,
|
|
31
|
-
existsSync,
|
|
34
|
+
existsSync as existsSync2,
|
|
32
35
|
mkdirSync as mkdirSync2,
|
|
33
36
|
readFileSync,
|
|
34
37
|
realpathSync as realpathSync2,
|
|
35
38
|
writeFileSync
|
|
36
39
|
} from "fs";
|
|
37
|
-
import { join } from "path";
|
|
40
|
+
import { join as join2 } from "path";
|
|
38
41
|
|
|
39
|
-
//
|
|
42
|
+
// src/core/errors.ts
|
|
40
43
|
var REFUSALS = /* @__PURE__ */ new Set([
|
|
41
44
|
"MANIFEST_INVALID",
|
|
42
45
|
"AREA_UNKNOWN",
|
|
46
|
+
"TASK_INVALID",
|
|
43
47
|
"TYPE_UNKNOWN",
|
|
44
48
|
"MODE_UNKNOWN",
|
|
49
|
+
"MODE_MISMATCH",
|
|
50
|
+
"SCOPE_REQUIRES_TASK",
|
|
51
|
+
"SCOPE_OUTSIDE_AREA",
|
|
45
52
|
"MAIN_CHECKOUT",
|
|
46
53
|
"WORKTREE_INVALID",
|
|
47
54
|
"PROTECTED_BRANCH",
|
|
@@ -54,7 +61,8 @@ var REFUSALS = /* @__PURE__ */ new Set([
|
|
|
54
61
|
"PORT_RANGE_TAKEN",
|
|
55
62
|
"SYNC_CONFLICT",
|
|
56
63
|
"DIRTY_WORKTREE",
|
|
57
|
-
"UNPUSHED"
|
|
64
|
+
"UNPUSHED",
|
|
65
|
+
"PR_CLOSED"
|
|
58
66
|
]);
|
|
59
67
|
function exitCodeFor(code) {
|
|
60
68
|
return REFUSALS.has(code) ? 2 : 1;
|
|
@@ -76,11 +84,19 @@ function toErrorBody(err) {
|
|
|
76
84
|
return { code: "INTERNAL", message: err instanceof Error ? err.message : String(err) };
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
//
|
|
87
|
+
// src/core/git.ts
|
|
80
88
|
import { spawnSync } from "child_process";
|
|
81
|
-
import { mkdirSync, realpathSync } from "fs";
|
|
82
|
-
import { dirname } from "path";
|
|
83
|
-
var
|
|
89
|
+
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
|
|
90
|
+
import { dirname, join, resolve, sep } from "path";
|
|
91
|
+
var realpathOr = (p) => {
|
|
92
|
+
try {
|
|
93
|
+
return realpathSync(p);
|
|
94
|
+
} catch {
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
var SAFE_SUBMODULE_NAME = /^[^\p{Cc}\s=]+$/u;
|
|
99
|
+
var Git = class _Git {
|
|
84
100
|
constructor(cwd) {
|
|
85
101
|
this.cwd = cwd;
|
|
86
102
|
}
|
|
@@ -149,12 +165,27 @@ var Git = class {
|
|
|
149
165
|
else if (this.remoteBranchExists(this.currentBranch())) ref = `origin/${this.currentBranch()}`;
|
|
150
166
|
return this.lines(["rev-list", `${ref}..HEAD`]).length;
|
|
151
167
|
}
|
|
168
|
+
/** Commits on origin/<base> that HEAD does not have (0 when the ref is unknown). */
|
|
169
|
+
behindCount(base) {
|
|
170
|
+
const r = this.tryRun(["rev-list", "--count", `HEAD..origin/${base}`]);
|
|
171
|
+
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
172
|
+
}
|
|
152
173
|
isDirty() {
|
|
153
174
|
return this.run(["status", "--porcelain"]).trim().length > 0;
|
|
154
175
|
}
|
|
155
176
|
changedPathsStaged() {
|
|
156
177
|
return this.lines(["diff", "--cached", "--name-only"]);
|
|
157
178
|
}
|
|
179
|
+
/** The other parent of a merge in progress, or undefined when none is. */
|
|
180
|
+
mergeHead() {
|
|
181
|
+
const r = this.tryRun(["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
|
182
|
+
return r.code === 0 ? r.out.trim() : void 0;
|
|
183
|
+
}
|
|
184
|
+
/** Staged paths whose index content differs from `ref` (so, authored here rather than taken from `ref`). */
|
|
185
|
+
stagedDifferingFrom(ref, paths) {
|
|
186
|
+
if (paths.length === 0) return [];
|
|
187
|
+
return this.lines(["diff", "--cached", "--name-only", "--no-renames", ref, "--", ...paths]);
|
|
188
|
+
}
|
|
158
189
|
changedPaths(from, to) {
|
|
159
190
|
return this.lines(["diff", "--name-only", "--no-renames", from, to]);
|
|
160
191
|
}
|
|
@@ -187,18 +218,45 @@ var Git = class {
|
|
|
187
218
|
const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
|
|
188
219
|
return { kind: "conflict", before, after: before, conflicted };
|
|
189
220
|
}
|
|
221
|
+
/** `origin/<branch>` after a fetch attempt, when the remote has it; the local copy may have been retired. */
|
|
222
|
+
remoteRef(branch) {
|
|
223
|
+
this.tryRun(["fetch", "-q", "origin", branch]);
|
|
224
|
+
const ref = `refs/remotes/origin/${branch}`;
|
|
225
|
+
return this.tryRun(["rev-parse", "--verify", "-q", ref]).code === 0 ? `origin/${branch}` : void 0;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Add a worktree on `branch`: the local branch if it exists, else a tracking
|
|
229
|
+
* branch from origin/<branch> if the remote has it (so retiring the local
|
|
230
|
+
* branch while its PR was open never recreates it from the base), else a
|
|
231
|
+
* new branch from `startPoint`.
|
|
232
|
+
*/
|
|
190
233
|
worktreeAdd(path, branch, startPoint) {
|
|
191
234
|
mkdirSync(dirname(path), { recursive: true });
|
|
192
|
-
if (this.branchExists(branch))
|
|
193
|
-
|
|
194
|
-
|
|
235
|
+
if (this.branchExists(branch)) {
|
|
236
|
+
this.run(["worktree", "add", "-q", path, branch]);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const remote = this.remoteRef(branch);
|
|
240
|
+
if (remote) {
|
|
241
|
+
this.run(["worktree", "add", "-q", "--track", "-b", branch, path, remote]);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
|
|
195
245
|
}
|
|
196
246
|
worktreeRemove(path) {
|
|
197
247
|
this.run(["worktree", "remove", "--force", path]);
|
|
198
248
|
}
|
|
199
249
|
checkout(branch, createFrom) {
|
|
200
|
-
if (this.branchExists(branch))
|
|
201
|
-
|
|
250
|
+
if (this.branchExists(branch)) {
|
|
251
|
+
this.run(["checkout", "-q", branch]);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const remote = this.remoteRef(branch);
|
|
255
|
+
if (remote) {
|
|
256
|
+
this.run(["checkout", "-q", "--track", "-b", branch, remote]);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
|
|
202
260
|
}
|
|
203
261
|
push(branch) {
|
|
204
262
|
this.run(["push", "-q", "-u", "origin", branch]);
|
|
@@ -206,9 +264,89 @@ var Git = class {
|
|
|
206
264
|
deleteLocalBranch(branch) {
|
|
207
265
|
this.run(["branch", "-q", "-D", branch]);
|
|
208
266
|
}
|
|
267
|
+
isAncestor(ancestor, descendant) {
|
|
268
|
+
return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).code === 0;
|
|
269
|
+
}
|
|
270
|
+
/** True when HEAD's tree matches origin/<base>, or every commit on HEAD is patch-equivalent to origin/<base> (squash-merged). */
|
|
271
|
+
isEquivalentToBase(base) {
|
|
272
|
+
const target = `origin/${base}`;
|
|
273
|
+
if (this.tryRun(["diff", "--quiet", target, "HEAD"]).code === 0) return true;
|
|
274
|
+
const cherry = this.tryRun(["cherry", target, "HEAD"]);
|
|
275
|
+
if (cherry.code !== 0) return false;
|
|
276
|
+
const lines = cherry.out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
277
|
+
return lines.length > 0 && lines.every((l) => l.startsWith("- "));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Give a linked worktree its own clone of every submodule the main checkout has initialized,
|
|
281
|
+
* cloned locally from the main checkout's module directory (no network) and then re-pointed at
|
|
282
|
+
* the real URL. `git worktree add` under submodule.recurse leaves a gitfile aimed at a module
|
|
283
|
+
* directory that was never cloned; a gitfile aimed at the MAIN checkout's module directory is
|
|
284
|
+
* worse, because that module's core.worktree is the main checkout's submodule, so any git
|
|
285
|
+
* command in the worktree's submodule (checkout -f, reset, merge) rewrites the human's files.
|
|
286
|
+
* Both shapes are replaced; a submodule that is already its own working tree is left alone.
|
|
287
|
+
*/
|
|
288
|
+
repairSubmodules(mainRoot, log = () => {
|
|
289
|
+
}) {
|
|
290
|
+
const gitmodules = join(this.cwd, ".gitmodules");
|
|
291
|
+
if (!existsSync(gitmodules)) return;
|
|
292
|
+
const cfg = this.tryRun([
|
|
293
|
+
"config",
|
|
294
|
+
"-f",
|
|
295
|
+
".gitmodules",
|
|
296
|
+
"--get-regexp",
|
|
297
|
+
"^submodule\\..*\\.path$"
|
|
298
|
+
]);
|
|
299
|
+
if (cfg.code !== 0) return;
|
|
300
|
+
const wtGitDir = this.run(["rev-parse", "--path-format=absolute", "--git-dir"]).trim();
|
|
301
|
+
for (const line of cfg.out.split("\n").filter(Boolean)) {
|
|
302
|
+
const m = /^submodule\.(.+)\.path\s+(.+)$/.exec(line.trim());
|
|
303
|
+
if (!m) continue;
|
|
304
|
+
const [, name, subPath] = m;
|
|
305
|
+
const modulesDir = resolve(wtGitDir, "modules");
|
|
306
|
+
const moduleDir = resolve(modulesDir, name);
|
|
307
|
+
if (!SAFE_SUBMODULE_NAME.test(name) || name.split(/[\\/]/).some((seg) => seg === "" || seg === "." || seg === "..") || !moduleDir.startsWith(modulesDir + sep)) {
|
|
308
|
+
log(`submodule ${subPath}: refusing name ${JSON.stringify(name)}; not repaired`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const wtSub = join(this.cwd, subPath);
|
|
312
|
+
const wtGit = join(wtSub, ".git");
|
|
313
|
+
if (this.isOwnSubmodule(wtSub, wtGit)) continue;
|
|
314
|
+
const mainSub = join(mainRoot, subPath);
|
|
315
|
+
if (!existsSync(join(mainSub, ".git"))) continue;
|
|
316
|
+
const src = new _Git(mainSub).tryRun(["rev-parse", "--path-format=absolute", "--git-dir"]);
|
|
317
|
+
if (src.code !== 0 || !src.out.trim()) continue;
|
|
318
|
+
if (existsSync(wtGit) && statSync(wtGit).isFile()) rmSync(wtGit);
|
|
319
|
+
rmSync(moduleDir, { recursive: true, force: true });
|
|
320
|
+
const r = this.tryRun([
|
|
321
|
+
"-c",
|
|
322
|
+
"protocol.file.allow=always",
|
|
323
|
+
"-c",
|
|
324
|
+
`submodule.${name}.url=${src.out.trim()}`,
|
|
325
|
+
"submodule",
|
|
326
|
+
"update",
|
|
327
|
+
"--init",
|
|
328
|
+
"--",
|
|
329
|
+
subPath
|
|
330
|
+
]);
|
|
331
|
+
if (r.code !== 0) {
|
|
332
|
+
log(
|
|
333
|
+
`submodule ${subPath}: not repaired (${(r.err || r.out).trim()}); the project's refresh steps may still set it up`
|
|
334
|
+
);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
this.tryRun(["submodule", "sync", "-q", "--", subPath]);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** A submodule is the worktree's own when git, run inside it, says its top level IS that directory. */
|
|
341
|
+
isOwnSubmodule(wtSub, wtGit) {
|
|
342
|
+
if (!existsSync(wtGit)) return false;
|
|
343
|
+
const top = new _Git(wtSub).tryRun(["rev-parse", "--show-toplevel"]);
|
|
344
|
+
if (top.code !== 0) return false;
|
|
345
|
+
return realpathOr(top.out.trim()) === realpathOr(wtSub);
|
|
346
|
+
}
|
|
209
347
|
};
|
|
210
348
|
|
|
211
|
-
//
|
|
349
|
+
// src/core/config.ts
|
|
212
350
|
function renderTemplate(s, vars) {
|
|
213
351
|
return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => {
|
|
214
352
|
if (!(name in vars)) {
|
|
@@ -220,6 +358,10 @@ function renderTemplate(s, vars) {
|
|
|
220
358
|
return vars[name];
|
|
221
359
|
});
|
|
222
360
|
}
|
|
361
|
+
function storybookHostFor(m, worktree) {
|
|
362
|
+
if (!worktree) return "";
|
|
363
|
+
return m.storybook?.hostRoot ? join2(worktree, m.storybook.hostRoot) : worktree;
|
|
364
|
+
}
|
|
223
365
|
function slotVars(m, o) {
|
|
224
366
|
const vars = {
|
|
225
367
|
SLOT: o.slot === void 0 ? "" : String(o.slot),
|
|
@@ -228,28 +370,30 @@ function slotVars(m, o) {
|
|
|
228
370
|
BRANCH: o.branch ?? "",
|
|
229
371
|
WORKTREE: o.worktree ?? "",
|
|
230
372
|
LOG_DIR: o.logDir ?? "",
|
|
231
|
-
|
|
373
|
+
STORYBROKR_HOST: storybookHostFor(m, o.worktree)
|
|
232
374
|
};
|
|
233
375
|
for (const service of Object.keys(m.ports.services)) {
|
|
234
376
|
const p = o.ports[service];
|
|
235
377
|
vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
|
|
236
378
|
}
|
|
379
|
+
if (o.storybookPort !== void 0) vars.PORT_STORYBOOK = String(o.storybookPort);
|
|
380
|
+
else if (vars.PORT_STORYBOOK === void 0) vars.PORT_STORYBOOK = "";
|
|
237
381
|
return vars;
|
|
238
382
|
}
|
|
239
383
|
function ensureExcluded(worktree, patterns) {
|
|
240
384
|
const r = new Git(worktree).tryRun(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
241
385
|
if (r.code !== 0) return;
|
|
242
386
|
const common = realpathSync2(r.out.trim());
|
|
243
|
-
const file =
|
|
244
|
-
mkdirSync2(
|
|
387
|
+
const file = join2(common, "info", "exclude");
|
|
388
|
+
mkdirSync2(join2(common, "info"), { recursive: true });
|
|
245
389
|
const present = new Set(
|
|
246
|
-
|
|
390
|
+
existsSync2(file) ? readFileSync(file, "utf8").split("\n").map((l) => l.trim()) : []
|
|
247
391
|
);
|
|
248
392
|
const missing = patterns.filter((p) => !present.has(p));
|
|
249
393
|
if (missing.length === 0) return;
|
|
250
394
|
appendFileSync(
|
|
251
395
|
file,
|
|
252
|
-
`${present.size ? "\n" : ""}# written by @helmsmith/
|
|
396
|
+
`${present.size ? "\n" : ""}# written by @helmsmith/workbrokr: per-worktree config and logs
|
|
253
397
|
${missing.join("\n")}
|
|
254
398
|
`
|
|
255
399
|
);
|
|
@@ -261,26 +405,26 @@ function emitWorktreeConfig(a) {
|
|
|
261
405
|
`${a.m.logs.dir.replace(/\/+$/, "")}/`,
|
|
262
406
|
`${a.m.worktreeRoot.replace(/\/+$/, "")}/`
|
|
263
407
|
]);
|
|
264
|
-
const logDir =
|
|
408
|
+
const logDir = join2(a.worktree, a.m.logs.dir);
|
|
265
409
|
mkdirSync2(logDir, { recursive: true });
|
|
266
410
|
const vars = slotVars(a.m, { ...a, logDir });
|
|
267
|
-
const envFile =
|
|
411
|
+
const envFile = join2(a.worktree, a.m.env.file);
|
|
268
412
|
const lines = Object.entries(a.m.env.template).map(([k, v]) => `${k}=${renderTemplate(v, vars)}`);
|
|
269
413
|
writeFileSync(envFile, `${lines.join("\n")}
|
|
270
414
|
`);
|
|
271
415
|
let slotFile;
|
|
272
416
|
if (a.slot !== void 0) {
|
|
273
|
-
slotFile =
|
|
417
|
+
slotFile = join2(a.worktree, ".slot");
|
|
274
418
|
writeFileSync(slotFile, `${a.slot}
|
|
275
419
|
`);
|
|
276
420
|
}
|
|
277
421
|
return slotFile ? { envFile, slotFile, logDir, vars } : { envFile, logDir, vars };
|
|
278
422
|
}
|
|
279
423
|
|
|
280
|
-
//
|
|
281
|
-
import { existsSync as
|
|
424
|
+
// src/core/guards.ts
|
|
425
|
+
import { existsSync as existsSync3, realpathSync as realpathSync3 } from "fs";
|
|
282
426
|
|
|
283
|
-
//
|
|
427
|
+
// src/core/scopes.ts
|
|
284
428
|
import picomatch from "picomatch";
|
|
285
429
|
function areaVars(area) {
|
|
286
430
|
const parts = area.split("/");
|
|
@@ -298,7 +442,42 @@ function fill(template, vars) {
|
|
|
298
442
|
});
|
|
299
443
|
return missing ? null : out;
|
|
300
444
|
}
|
|
301
|
-
function
|
|
445
|
+
function extraScopesForMode(spec, mode) {
|
|
446
|
+
if (!spec.extraScopes) return [];
|
|
447
|
+
if (Array.isArray(spec.extraScopes)) {
|
|
448
|
+
return mode === "frontend" || mode === "fullstack" ? spec.extraScopes : [];
|
|
449
|
+
}
|
|
450
|
+
const out = [];
|
|
451
|
+
if (mode === "frontend" || mode === "fullstack") out.push(...spec.extraScopes.frontend ?? []);
|
|
452
|
+
if (mode === "backend" || mode === "fullstack") out.push(...spec.extraScopes.backend ?? []);
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
var normalizeGlob = (g) => g.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
456
|
+
var hasWildcard = (s) => /[*?[{]/.test(s);
|
|
457
|
+
function isSubGlob(child, parent) {
|
|
458
|
+
const c = normalizeGlob(child);
|
|
459
|
+
const p = normalizeGlob(parent);
|
|
460
|
+
if (c === p) return true;
|
|
461
|
+
const cSegs = c.split("/");
|
|
462
|
+
const pSegs = p.split("/");
|
|
463
|
+
for (let i = 0; i < pSegs.length; i++) {
|
|
464
|
+
const ps = pSegs[i];
|
|
465
|
+
if (ps === "**") return true;
|
|
466
|
+
const cs = cSegs[i];
|
|
467
|
+
if (cs === void 0 || cs === "**") return false;
|
|
468
|
+
if (!hasWildcard(ps) && !hasWildcard(cs)) {
|
|
469
|
+
if (ps !== cs) return false;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (!hasWildcard(cs) && hasWildcard(ps)) {
|
|
473
|
+
if (!picomatch.isMatch(cs, ps, { dot: true })) return false;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (cs !== ps) return false;
|
|
477
|
+
}
|
|
478
|
+
return cSegs.length === pSegs.length;
|
|
479
|
+
}
|
|
480
|
+
function expandScopes(m, area, mode, narrowedScopes, hasTask = false) {
|
|
302
481
|
const spec = m.areas[area];
|
|
303
482
|
if (!spec)
|
|
304
483
|
throw new WorkkitError(
|
|
@@ -307,15 +486,17 @@ function expandScopes(m, area, mode) {
|
|
|
307
486
|
);
|
|
308
487
|
const vars = { ...areaVars(area), module: spec.module };
|
|
309
488
|
const out = [];
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
489
|
+
if (mode === "frontend" || mode === "fullstack") {
|
|
490
|
+
for (const t of m.scopes.frontend) {
|
|
491
|
+
const g = fill(t, vars);
|
|
492
|
+
if (g) out.push(g);
|
|
493
|
+
}
|
|
313
494
|
}
|
|
314
|
-
if (mode === "fullstack") {
|
|
495
|
+
if (mode === "backend" || mode === "fullstack") {
|
|
315
496
|
if (!spec.module) {
|
|
316
497
|
throw new WorkkitError(
|
|
317
498
|
"MANIFEST_INVALID",
|
|
318
|
-
`area "${area}" has no module, so a
|
|
499
|
+
`area "${area}" has no module, so a ${mode} effort cannot hold a backend scope; add areas["${area}"].module or use --mode frontend`
|
|
319
500
|
);
|
|
320
501
|
}
|
|
321
502
|
for (const t of m.scopes.backend) {
|
|
@@ -323,20 +504,65 @@ function expandScopes(m, area, mode) {
|
|
|
323
504
|
if (g) out.push(g);
|
|
324
505
|
}
|
|
325
506
|
}
|
|
326
|
-
out.push(...spec
|
|
327
|
-
return out;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
507
|
+
out.push(...extraScopesForMode(spec, mode));
|
|
508
|
+
if (!narrowedScopes || narrowedScopes.length === 0) return out;
|
|
509
|
+
if (!hasTask) {
|
|
510
|
+
throw new WorkkitError(
|
|
511
|
+
"SCOPE_REQUIRES_TASK",
|
|
512
|
+
"--scope narrowing requires --task <slug> so primary area efforts always protect the full area"
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
const allowed = [...out, ...spec.sharedScopes ?? []];
|
|
516
|
+
for (const g of narrowedScopes) {
|
|
517
|
+
if (!allowed.some((base) => isSubGlob(g, base))) {
|
|
518
|
+
throw new WorkkitError(
|
|
519
|
+
"SCOPE_OUTSIDE_AREA",
|
|
520
|
+
`narrowed scope "${g}" is outside ${area}'s ${mode} scopes (${allowed.join(", ")})`,
|
|
521
|
+
{ area, mode, scope: g, allowed }
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return narrowedScopes;
|
|
526
|
+
}
|
|
527
|
+
function allowedScopesForEffort(m, effort) {
|
|
528
|
+
const shared = m.areas[effort.area]?.sharedScopes ?? [];
|
|
529
|
+
const allCommons = Object.values(m.commons).flat();
|
|
530
|
+
return [...effort.scopes, ...shared, ...allCommons];
|
|
531
|
+
}
|
|
532
|
+
function segmentsOverlap(sx, sy) {
|
|
533
|
+
if (sx === sy) return true;
|
|
534
|
+
const wx = hasWildcard(sx);
|
|
535
|
+
const wy = hasWildcard(sy);
|
|
536
|
+
if (!wx && !wy) return false;
|
|
537
|
+
if (!wx && wy) return picomatch.isMatch(sx, sy, { dot: true });
|
|
538
|
+
if (wx && !wy) return picomatch.isMatch(sy, sx, { dot: true });
|
|
539
|
+
const starToken = /^\*([^*?[{]+)\*$/;
|
|
540
|
+
const mx = starToken.exec(sx);
|
|
541
|
+
const my = starToken.exec(sy);
|
|
542
|
+
if (mx && my) {
|
|
543
|
+
const tx = mx[1];
|
|
544
|
+
const ty = my[1];
|
|
545
|
+
return tx.includes(ty) || ty.includes(tx);
|
|
546
|
+
}
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
function twoGlobsOverlap(x, y) {
|
|
550
|
+
const xs = normalizeGlob(x).split("/");
|
|
551
|
+
const ys = normalizeGlob(y).split("/");
|
|
552
|
+
const len = Math.min(xs.length, ys.length);
|
|
553
|
+
for (let i = 0; i < len; i++) {
|
|
554
|
+
if (xs[i] === "**" || ys[i] === "**") return true;
|
|
555
|
+
if (!segmentsOverlap(xs[i], ys[i])) return false;
|
|
556
|
+
}
|
|
557
|
+
if (xs.length === ys.length) return true;
|
|
558
|
+
const longer = xs.length > ys.length ? xs : ys;
|
|
559
|
+
return longer[len] === "**";
|
|
332
560
|
}
|
|
333
561
|
function globsOverlap(a, b) {
|
|
334
562
|
const pairs = [];
|
|
335
563
|
for (const x of a) {
|
|
336
|
-
const px = literalPrefix(x);
|
|
337
564
|
for (const y of b) {
|
|
338
|
-
|
|
339
|
-
if (px.startsWith(py) || py.startsWith(px)) pairs.push(`${x} ~ ${y}`);
|
|
565
|
+
if (twoGlobsOverlap(x, y)) pairs.push(`${x} ~ ${y}`);
|
|
340
566
|
}
|
|
341
567
|
}
|
|
342
568
|
return pairs;
|
|
@@ -350,7 +576,7 @@ function commonsTouched(m, paths) {
|
|
|
350
576
|
return Object.entries(m.commons).filter(([, globs]) => pathsMatching(paths, globs).length > 0).map(([name]) => name);
|
|
351
577
|
}
|
|
352
578
|
|
|
353
|
-
//
|
|
579
|
+
// src/core/guards.ts
|
|
354
580
|
function assertNotProtected(m, branch) {
|
|
355
581
|
if (m.protectedBranches.includes(branch)) {
|
|
356
582
|
throw new WorkkitError(
|
|
@@ -367,28 +593,64 @@ var same = (a, b) => {
|
|
|
367
593
|
return a === b;
|
|
368
594
|
}
|
|
369
595
|
};
|
|
596
|
+
var effortLabel = (e) => e.task ? `${e.area}--${e.task}` : e.area;
|
|
370
597
|
function checkCommit(c) {
|
|
371
598
|
const refusals = [];
|
|
599
|
+
const warnings = [];
|
|
372
600
|
if (c.m.protectedBranches.includes(c.branch)) {
|
|
373
601
|
refusals.push(
|
|
374
602
|
`branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
|
|
375
603
|
);
|
|
376
604
|
}
|
|
377
|
-
const
|
|
605
|
+
const mergeHead = c.git.mergeHead();
|
|
606
|
+
const authored = mergeHead ? new Set(c.git.stagedDifferingFrom(mergeHead, c.stagedPaths)) : new Set(c.stagedPaths);
|
|
607
|
+
const ownPaths = c.stagedPaths.filter((p) => authored.has(p));
|
|
608
|
+
const allEfforts = c.registry.efforts({ project: c.project });
|
|
609
|
+
const efforts = new Map(allEfforts.map((e) => [e.id, e]));
|
|
610
|
+
const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
|
|
611
|
+
const mine = openEfforts.find(
|
|
612
|
+
(e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
|
|
613
|
+
);
|
|
378
614
|
for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
|
|
379
|
-
if (lease.owner.session === c.ownerSession) continue;
|
|
380
615
|
const holder = efforts.get(lease.effort);
|
|
381
|
-
|
|
616
|
+
const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
|
|
617
|
+
if (holder && holderHasWorktree && c.checkoutRoot) {
|
|
618
|
+
if (same(holder.worktree, c.checkoutRoot)) continue;
|
|
619
|
+
} else if (lease.owner.session === c.ownerSession) {
|
|
382
620
|
continue;
|
|
621
|
+
}
|
|
622
|
+
if (holder && mine && holder.area === mine.area) continue;
|
|
383
623
|
const globs = c.m.commons[String(lease.value)] ?? [];
|
|
384
|
-
const hit = pathsMatching(
|
|
624
|
+
const hit = pathsMatching(ownPaths, globs);
|
|
385
625
|
if (hit.length > 0) {
|
|
386
626
|
refusals.push(
|
|
387
627
|
`commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
|
|
388
628
|
);
|
|
389
629
|
}
|
|
390
630
|
}
|
|
391
|
-
|
|
631
|
+
for (const other of openEfforts) {
|
|
632
|
+
if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (!c.checkoutRoot && other.branch === c.branch) continue;
|
|
636
|
+
const hit = pathsMatching(ownPaths, other.scopes);
|
|
637
|
+
if (hit.length > 0) {
|
|
638
|
+
refusals.push(
|
|
639
|
+
`path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (mine && ownPaths.length > 0) {
|
|
644
|
+
const allowed = allowedScopesForEffort(c.m, mine);
|
|
645
|
+
const inScope = new Set(pathsMatching(ownPaths, allowed));
|
|
646
|
+
const outside = ownPaths.filter((p) => !inScope.has(p));
|
|
647
|
+
if (outside.length > 0) {
|
|
648
|
+
warnings.push(
|
|
649
|
+
`${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return { refusals, warnings };
|
|
392
654
|
}
|
|
393
655
|
function checkPush(c) {
|
|
394
656
|
const refusals = [];
|
|
@@ -401,15 +663,15 @@ function checkPush(c) {
|
|
|
401
663
|
return { refusals, warnings };
|
|
402
664
|
}
|
|
403
665
|
if (c.isDelete) return { refusals, warnings };
|
|
404
|
-
const effort = c.registry.efforts({ project: c.project
|
|
666
|
+
const effort = c.registry.efforts({ project: c.project }).find((e) => (e.state === "active" || e.state === "parked") && e.branch === c.branch);
|
|
405
667
|
const commons = commonsTouched(c.m, c.changedPaths);
|
|
406
668
|
if (effort) {
|
|
407
|
-
const
|
|
408
|
-
const inScope = new Set(pathsMatching(c.changedPaths,
|
|
669
|
+
const allowed = allowedScopesForEffort(c.m, effort);
|
|
670
|
+
const inScope = new Set(pathsMatching(c.changedPaths, allowed));
|
|
409
671
|
const outside = c.changedPaths.filter((p) => !inScope.has(p));
|
|
410
672
|
if (outside.length > 0) {
|
|
411
673
|
warnings.push(
|
|
412
|
-
`${outside.length} changed path(s) are outside the effort's scope (${effort
|
|
674
|
+
`${outside.length} changed path(s) are outside the effort's scope (${effortLabel(effort)}): ${outside.join(", ")} \u2014 add a scope template or expect a merge collision`
|
|
413
675
|
);
|
|
414
676
|
}
|
|
415
677
|
}
|
|
@@ -418,13 +680,13 @@ function checkPush(c) {
|
|
|
418
680
|
return { refusals, warnings };
|
|
419
681
|
}
|
|
420
682
|
|
|
421
|
-
//
|
|
683
|
+
// src/core/hooks.ts
|
|
422
684
|
import { chmodSync, lstatSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
423
|
-
import { join as
|
|
685
|
+
import { join as join4 } from "path";
|
|
424
686
|
|
|
425
|
-
//
|
|
426
|
-
import { existsSync as
|
|
427
|
-
import { join as
|
|
687
|
+
// src/core/manifest.ts
|
|
688
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
689
|
+
import { join as join3 } from "path";
|
|
428
690
|
import { pathToFileURL } from "url";
|
|
429
691
|
import { z } from "zod";
|
|
430
692
|
var MANIFEST_FILES = [
|
|
@@ -434,14 +696,23 @@ var MANIFEST_FILES = [
|
|
|
434
696
|
];
|
|
435
697
|
var BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
436
698
|
var BRANCH_NAME_HINT = 'must be a plain ref name: letters, digits, ".", "_", "/", "-"';
|
|
699
|
+
var TASK_SLUG = /^(?!.*--)[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
437
700
|
var Service = z.object({
|
|
438
701
|
start: z.string().min(1),
|
|
439
702
|
stop: z.string().min(1),
|
|
440
703
|
health: z.string().min(1),
|
|
441
704
|
healthyCodes: z.array(z.number().int()).optional(),
|
|
442
705
|
reset: z.string().optional(),
|
|
443
|
-
healthTimeoutMs: z.number().int().positive().optional()
|
|
706
|
+
healthTimeoutMs: z.number().int().positive().optional(),
|
|
707
|
+
modes: z.array(z.enum(["backend", "fullstack"])).optional()
|
|
444
708
|
});
|
|
709
|
+
var ExtraScopes = z.union([
|
|
710
|
+
z.array(z.string()),
|
|
711
|
+
z.object({
|
|
712
|
+
frontend: z.array(z.string()).optional(),
|
|
713
|
+
backend: z.array(z.string()).optional()
|
|
714
|
+
})
|
|
715
|
+
]);
|
|
445
716
|
var Schema = z.object({
|
|
446
717
|
project: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
447
718
|
baseBranch: z.string().regex(BRANCH_NAME, BRANCH_NAME_HINT),
|
|
@@ -459,21 +730,24 @@ var Schema = z.object({
|
|
|
459
730
|
storybook: z.object({ hostRoot: z.string() }).optional(),
|
|
460
731
|
logs: z.object({ dir: z.string() }).default({ dir: ".logs" }),
|
|
461
732
|
env: z.object({ file: z.string(), template: z.record(z.string(), z.string()) }).optional(),
|
|
733
|
+
seed: z.array(z.string().min(1)).optional(),
|
|
462
734
|
refresh: z.array(
|
|
463
735
|
z.object({
|
|
464
736
|
when: z.string().optional(),
|
|
465
737
|
always: z.boolean().optional(),
|
|
466
738
|
run: z.string().optional(),
|
|
467
|
-
mark: z.string().optional()
|
|
739
|
+
mark: z.string().optional(),
|
|
740
|
+
restoreWhenIfClean: z.boolean().optional()
|
|
468
741
|
})
|
|
469
742
|
).default([]),
|
|
470
743
|
scopes: z.object({ frontend: z.array(z.string()), backend: z.array(z.string()) }),
|
|
471
744
|
areas: z.record(
|
|
472
|
-
z.string().regex(/^[a-z0-9-]+(\/[a-z0-9-]+)*$/),
|
|
745
|
+
z.string().regex(/^(?!.*--)[a-z0-9-]+(\/[a-z0-9-]+)*$/),
|
|
473
746
|
z.object({
|
|
474
747
|
module: z.string().optional(),
|
|
475
748
|
commons: z.array(z.string()).optional(),
|
|
476
|
-
extraScopes:
|
|
749
|
+
extraScopes: ExtraScopes.optional(),
|
|
750
|
+
sharedScopes: z.array(z.string()).optional()
|
|
477
751
|
})
|
|
478
752
|
),
|
|
479
753
|
commons: z.record(z.string(), z.array(z.string())).default({}),
|
|
@@ -496,13 +770,21 @@ function templateVariables(m) {
|
|
|
496
770
|
"AREA",
|
|
497
771
|
"BRANCH",
|
|
498
772
|
"WORKTREE",
|
|
499
|
-
"PORT_STORYBOOK"
|
|
773
|
+
"PORT_STORYBOOK",
|
|
774
|
+
"STORYBROKR_HOST"
|
|
500
775
|
]);
|
|
501
776
|
for (const s of Object.keys(m.ports.services)) vars.add(`PORT_${s.toUpperCase()}`);
|
|
502
777
|
return vars;
|
|
503
778
|
}
|
|
504
779
|
function defaultEnv(services) {
|
|
505
|
-
const template = {
|
|
780
|
+
const template = {
|
|
781
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
|
|
782
|
+
SLOT: "${SLOT}",
|
|
783
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
|
|
784
|
+
LOG_DIR: "${LOG_DIR}",
|
|
785
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
|
|
786
|
+
STORYBROKR_HOST: "${STORYBROKR_HOST}"
|
|
787
|
+
};
|
|
506
788
|
for (const s of services) template[`PORT_${s.toUpperCase()}`] = `\${PORT_${s.toUpperCase()}}`;
|
|
507
789
|
return { file: ".env.slot", template };
|
|
508
790
|
}
|
|
@@ -555,8 +837,8 @@ function validateManifest(raw) {
|
|
|
555
837
|
}
|
|
556
838
|
async function loadManifest(repoRoot) {
|
|
557
839
|
for (const name of MANIFEST_FILES) {
|
|
558
|
-
const file =
|
|
559
|
-
if (!
|
|
840
|
+
const file = join3(repoRoot, name);
|
|
841
|
+
if (!existsSync4(file)) continue;
|
|
560
842
|
if (name.endsWith(".json")) return validateManifest(JSON.parse(readFileSync2(file, "utf8")));
|
|
561
843
|
const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
|
|
562
844
|
return validateManifest(mod.default);
|
|
@@ -567,7 +849,7 @@ async function loadManifest(repoRoot) {
|
|
|
567
849
|
);
|
|
568
850
|
}
|
|
569
851
|
|
|
570
|
-
//
|
|
852
|
+
// src/core/hooks.ts
|
|
571
853
|
var HOOKS_DIR = ".helmsmith/hooks";
|
|
572
854
|
function preCommitScript(protectedBranches) {
|
|
573
855
|
return `#!/bin/sh
|
|
@@ -580,11 +862,21 @@ for p in $PROTECTED; do
|
|
|
580
862
|
exit 2
|
|
581
863
|
fi
|
|
582
864
|
done
|
|
583
|
-
if
|
|
865
|
+
if command -v workbrokr >/dev/null 2>&1; then
|
|
866
|
+
workbrokr hook pre-commit "$@" || exit $?
|
|
867
|
+
else
|
|
584
868
|
echo "work: workbrokr is not on PATH; commons and scope checks skipped (npm i -g @helmsmith/workbrokr)" >&2
|
|
585
|
-
exit 0
|
|
586
869
|
fi
|
|
587
|
-
|
|
870
|
+
COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
871
|
+
LEGACY="$COMMON_DIR/hooks/pre-commit"
|
|
872
|
+
if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
|
|
873
|
+
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
874
|
+
LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
|
|
875
|
+
if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
|
|
876
|
+
exec "$LEGACY" "$@"
|
|
877
|
+
fi
|
|
878
|
+
fi
|
|
879
|
+
exit 0
|
|
588
880
|
`;
|
|
589
881
|
}
|
|
590
882
|
function prePushScript(protectedBranches) {
|
|
@@ -602,11 +894,21 @@ printf '%s\\n' "$input" | while read -r lref lsha rref rsha; do
|
|
|
602
894
|
fi
|
|
603
895
|
done
|
|
604
896
|
done || exit 2
|
|
605
|
-
if
|
|
897
|
+
if command -v workbrokr >/dev/null 2>&1; then
|
|
898
|
+
printf '%s\\n' "$input" | workbrokr hook pre-push "$@" || exit $?
|
|
899
|
+
else
|
|
606
900
|
echo "work: workbrokr is not on PATH; scope and commons warnings skipped" >&2
|
|
607
|
-
exit 0
|
|
608
901
|
fi
|
|
609
|
-
|
|
902
|
+
COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
903
|
+
LEGACY="$COMMON_DIR/hooks/pre-push"
|
|
904
|
+
if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
|
|
905
|
+
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
906
|
+
LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
|
|
907
|
+
if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
|
|
908
|
+
printf '%s\\n' "$input" | exec "$LEGACY" "$@"
|
|
909
|
+
fi
|
|
910
|
+
fi
|
|
911
|
+
exit 0
|
|
610
912
|
`;
|
|
611
913
|
}
|
|
612
914
|
function installHooks(repoRoot, opts) {
|
|
@@ -614,7 +916,7 @@ function installHooks(repoRoot, opts) {
|
|
|
614
916
|
if (!BRANCH_NAME.test(b))
|
|
615
917
|
throw new WorkkitError("MANIFEST_INVALID", `protected branch "${b}" is not a plain ref name`);
|
|
616
918
|
}
|
|
617
|
-
const hooksDir =
|
|
919
|
+
const hooksDir = join4(repoRoot, HOOKS_DIR);
|
|
618
920
|
mkdirSync3(hooksDir, { recursive: true });
|
|
619
921
|
const files = [];
|
|
620
922
|
const scripts = [
|
|
@@ -622,7 +924,7 @@ function installHooks(repoRoot, opts) {
|
|
|
622
924
|
["pre-push", prePushScript(opts.protectedBranches)]
|
|
623
925
|
];
|
|
624
926
|
for (const [name, body] of scripts) {
|
|
625
|
-
const file =
|
|
927
|
+
const file = join4(hooksDir, name);
|
|
626
928
|
let existing;
|
|
627
929
|
try {
|
|
628
930
|
existing = lstatSync(file);
|
|
@@ -642,16 +944,16 @@ function installHooks(repoRoot, opts) {
|
|
|
642
944
|
return { hooksDir, files };
|
|
643
945
|
}
|
|
644
946
|
|
|
645
|
-
//
|
|
947
|
+
// src/core/paths.ts
|
|
646
948
|
import { homedir } from "os";
|
|
647
|
-
import { join as
|
|
949
|
+
import { join as join5 } from "path";
|
|
648
950
|
function homeDir(env = process.env) {
|
|
649
|
-
return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME :
|
|
951
|
+
return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME : join5(homedir(), ".cache", "helmsmith");
|
|
650
952
|
}
|
|
651
|
-
var registryFile = (home) =>
|
|
652
|
-
var lockDir = (home) =>
|
|
953
|
+
var registryFile = (home) => join5(home, "registry.json");
|
|
954
|
+
var lockDir = (home) => join5(home, "registry.lock");
|
|
653
955
|
|
|
654
|
-
//
|
|
956
|
+
// src/core/ports.ts
|
|
655
957
|
function portsForSlot(m, slot) {
|
|
656
958
|
return Object.fromEntries(
|
|
657
959
|
Object.entries(m.ports.services).map(([service, { base }]) => [service, base + slot])
|
|
@@ -668,8 +970,19 @@ function rangesFor(m) {
|
|
|
668
970
|
function rangesOverlap(a, b) {
|
|
669
971
|
return a.base < b.base + b.count && b.base < a.base + a.count;
|
|
670
972
|
}
|
|
973
|
+
function ensureRanges(registry, m, repoRoot) {
|
|
974
|
+
const mine = rangesFor(m);
|
|
975
|
+
assertNoOverlap(registry, m, mine);
|
|
976
|
+
const current = registry.project(m.project);
|
|
977
|
+
if (current && JSON.stringify(current.ranges) === JSON.stringify(mine)) return;
|
|
978
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
979
|
+
}
|
|
671
980
|
function claimRanges(registry, m, repoRoot) {
|
|
672
981
|
const mine = rangesFor(m);
|
|
982
|
+
assertNoOverlap(registry, m, mine);
|
|
983
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
984
|
+
}
|
|
985
|
+
function assertNoOverlap(registry, m, mine) {
|
|
673
986
|
const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
|
|
674
987
|
for (const [service, range] of Object.entries(mine)) {
|
|
675
988
|
for (const [name, entry] of others) {
|
|
@@ -684,28 +997,100 @@ function claimRanges(registry, m, repoRoot) {
|
|
|
684
997
|
}
|
|
685
998
|
}
|
|
686
999
|
}
|
|
687
|
-
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
688
1000
|
}
|
|
689
1001
|
|
|
690
|
-
//
|
|
1002
|
+
// src/core/refresh.ts
|
|
691
1003
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
1004
|
+
import { createHash } from "crypto";
|
|
1005
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1006
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
692
1007
|
import picomatch2 from "picomatch";
|
|
693
1008
|
function matchesWhen(glob) {
|
|
694
1009
|
const isMatch = picomatch2(glob, { dot: true });
|
|
695
1010
|
return (p) => isMatch(p);
|
|
696
1011
|
}
|
|
697
|
-
function
|
|
1012
|
+
function readStamps(stampFile) {
|
|
1013
|
+
if (!stampFile || !existsSync5(stampFile)) return {};
|
|
1014
|
+
try {
|
|
1015
|
+
return JSON.parse(readFileSync3(stampFile, "utf8"));
|
|
1016
|
+
} catch {
|
|
1017
|
+
return {};
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
function writeStamps(stampFile, stamps) {
|
|
1021
|
+
mkdirSync4(dirname2(stampFile), { recursive: true });
|
|
1022
|
+
writeFileSync3(stampFile, `${JSON.stringify(stamps, null, 2)}
|
|
1023
|
+
`);
|
|
1024
|
+
}
|
|
1025
|
+
function matchedFilesInWorktree(worktree, whenGlob) {
|
|
1026
|
+
if (!/[*?[{]/.test(whenGlob)) {
|
|
1027
|
+
return existsSync5(join6(worktree, whenGlob)) ? [whenGlob] : [];
|
|
1028
|
+
}
|
|
1029
|
+
const r = spawnSync2("git", ["ls-files"], { cwd: worktree, encoding: "utf8" });
|
|
1030
|
+
if (r.status !== 0) return [];
|
|
1031
|
+
const isMatch = matchesWhen(whenGlob);
|
|
1032
|
+
return (r.stdout ?? "").split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && isMatch(l) && existsSync5(join6(worktree, l)));
|
|
1033
|
+
}
|
|
1034
|
+
function hashFiles(worktree, relPaths) {
|
|
1035
|
+
if (relPaths.length === 0) return null;
|
|
1036
|
+
const h = createHash("sha256");
|
|
1037
|
+
for (const rel of [...relPaths].sort()) {
|
|
1038
|
+
const abs = join6(worktree, rel);
|
|
1039
|
+
if (!existsSync5(abs)) continue;
|
|
1040
|
+
h.update(`${rel}\0`);
|
|
1041
|
+
h.update(readFileSync3(abs));
|
|
1042
|
+
h.update("\0");
|
|
1043
|
+
}
|
|
1044
|
+
return h.digest("hex");
|
|
1045
|
+
}
|
|
1046
|
+
function isFileCleanInGit(worktree, relPath) {
|
|
1047
|
+
const r = spawnSync2("git", ["status", "--porcelain", "--", relPath], {
|
|
1048
|
+
cwd: worktree,
|
|
1049
|
+
encoding: "utf8"
|
|
1050
|
+
});
|
|
1051
|
+
return r.status === 0 && (r.stdout ?? "").trim().length === 0;
|
|
1052
|
+
}
|
|
1053
|
+
function selectRefresh(entries, changedPaths, opts) {
|
|
698
1054
|
const runs = [];
|
|
699
1055
|
const marks = [];
|
|
1056
|
+
const stored = readStamps(opts?.stampFile);
|
|
1057
|
+
const pendingStamps = { ...stored };
|
|
1058
|
+
const cleanWhenFiles = /* @__PURE__ */ new Set();
|
|
700
1059
|
for (const e of entries) {
|
|
701
|
-
|
|
1060
|
+
let hit = e.always === true;
|
|
1061
|
+
let matchedFiles = [];
|
|
1062
|
+
if (e.when !== void 0) {
|
|
1063
|
+
const byChanged = changedPaths.some(matchesWhen(e.when));
|
|
1064
|
+
if (opts?.worktree) {
|
|
1065
|
+
matchedFiles = matchedFilesInWorktree(opts.worktree, e.when);
|
|
1066
|
+
const digest = hashFiles(opts.worktree, matchedFiles);
|
|
1067
|
+
if (digest !== null) {
|
|
1068
|
+
if (stored[e.when] !== digest) hit = true;
|
|
1069
|
+
pendingStamps[e.when] = digest;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (byChanged) hit = true;
|
|
1073
|
+
}
|
|
702
1074
|
if (!hit) continue;
|
|
703
|
-
if (e.run)
|
|
1075
|
+
if (e.run) {
|
|
1076
|
+
runs.push(e.run);
|
|
1077
|
+
if (opts?.worktree && e.when && e.restoreWhenIfClean !== false) {
|
|
1078
|
+
for (const f of matchedFiles) {
|
|
1079
|
+
if (isFileCleanInGit(opts.worktree, f)) cleanWhenFiles.add(f);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
704
1083
|
if (e.mark) marks.push(e.mark);
|
|
705
1084
|
}
|
|
706
|
-
return { runs, marks };
|
|
1085
|
+
if (!opts?.worktree) return { runs, marks };
|
|
1086
|
+
return {
|
|
1087
|
+
runs,
|
|
1088
|
+
marks,
|
|
1089
|
+
pendingStamps,
|
|
1090
|
+
cleanWhenFiles: [...cleanWhenFiles]
|
|
1091
|
+
};
|
|
707
1092
|
}
|
|
708
|
-
function runRefresh(runs, cwd, log, env = process.env) {
|
|
1093
|
+
function runRefresh(runs, cwd, log, env = process.env, opts) {
|
|
709
1094
|
for (const cmd of runs) {
|
|
710
1095
|
log(`$ ${cmd}`);
|
|
711
1096
|
const r = spawnSync2("sh", ["-c", cmd], { cwd, env, encoding: "utf8" });
|
|
@@ -718,23 +1103,33 @@ function runRefresh(runs, cwd, log, env = process.env) {
|
|
|
718
1103
|
});
|
|
719
1104
|
}
|
|
720
1105
|
}
|
|
1106
|
+
if (opts?.restoreFiles && opts.restoreFiles.length > 0) {
|
|
1107
|
+
for (const rel of opts.restoreFiles) {
|
|
1108
|
+
if (!isFileCleanInGit(cwd, rel)) {
|
|
1109
|
+
spawnSync2("git", ["checkout", "--", rel], { cwd, encoding: "utf8" });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (opts?.stampFile && opts.pendingStamps) {
|
|
1114
|
+
writeStamps(opts.stampFile, opts.pendingStamps);
|
|
1115
|
+
}
|
|
721
1116
|
}
|
|
722
1117
|
|
|
723
|
-
//
|
|
1118
|
+
// src/core/registry.ts
|
|
724
1119
|
import { randomBytes } from "crypto";
|
|
725
1120
|
import {
|
|
726
|
-
existsSync as
|
|
727
|
-
mkdirSync as
|
|
728
|
-
readFileSync as
|
|
1121
|
+
existsSync as existsSync6,
|
|
1122
|
+
mkdirSync as mkdirSync5,
|
|
1123
|
+
readFileSync as readFileSync4,
|
|
729
1124
|
renameSync,
|
|
730
|
-
rmSync,
|
|
731
|
-
statSync,
|
|
732
|
-
writeFileSync as
|
|
1125
|
+
rmSync as rmSync2,
|
|
1126
|
+
statSync as statSync2,
|
|
1127
|
+
writeFileSync as writeFileSync4
|
|
733
1128
|
} from "fs";
|
|
734
1129
|
var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
|
|
735
1130
|
var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
|
|
736
|
-
var LOCK_STALE_MS =
|
|
737
|
-
var LOCK_WAIT_MS =
|
|
1131
|
+
var LOCK_STALE_MS = 1e4;
|
|
1132
|
+
var LOCK_WAIT_MS = 15e3;
|
|
738
1133
|
function sleep(ms) {
|
|
739
1134
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
740
1135
|
}
|
|
@@ -746,16 +1141,16 @@ var Registry = class _Registry {
|
|
|
746
1141
|
home;
|
|
747
1142
|
now;
|
|
748
1143
|
static open(home, now2 = () => /* @__PURE__ */ new Date()) {
|
|
749
|
-
|
|
1144
|
+
mkdirSync5(home, { recursive: true });
|
|
750
1145
|
return new _Registry(home, now2);
|
|
751
1146
|
}
|
|
752
1147
|
/** The registry as it is on disk. A file that does not parse is refused, never replaced. */
|
|
753
1148
|
read() {
|
|
754
1149
|
const file = registryFile(this.home);
|
|
755
|
-
if (!
|
|
1150
|
+
if (!existsSync6(file)) return structuredClone(EMPTY);
|
|
756
1151
|
let parsed;
|
|
757
1152
|
try {
|
|
758
|
-
parsed = JSON.parse(
|
|
1153
|
+
parsed = JSON.parse(readFileSync4(file, "utf8"));
|
|
759
1154
|
} catch (err) {
|
|
760
1155
|
throw new WorkkitError(
|
|
761
1156
|
"INTERNAL",
|
|
@@ -785,12 +1180,14 @@ var Registry = class _Registry {
|
|
|
785
1180
|
const started = Date.now();
|
|
786
1181
|
for (; ; ) {
|
|
787
1182
|
try {
|
|
788
|
-
|
|
1183
|
+
mkdirSync5(lock);
|
|
789
1184
|
break;
|
|
790
1185
|
} catch {
|
|
791
1186
|
try {
|
|
792
|
-
if (Date.now() -
|
|
793
|
-
|
|
1187
|
+
if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
|
|
1188
|
+
const claimed = `${lock}.stale.${process.pid}.${Date.now()}`;
|
|
1189
|
+
renameSync(lock, claimed);
|
|
1190
|
+
rmSync2(claimed, { recursive: true, force: true });
|
|
794
1191
|
continue;
|
|
795
1192
|
}
|
|
796
1193
|
} catch {
|
|
@@ -810,12 +1207,12 @@ var Registry = class _Registry {
|
|
|
810
1207
|
const out = fn(r);
|
|
811
1208
|
const file = registryFile(this.home);
|
|
812
1209
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
813
|
-
|
|
1210
|
+
writeFileSync4(tmp, `${JSON.stringify(r, null, 2)}
|
|
814
1211
|
`);
|
|
815
1212
|
renameSync(tmp, file);
|
|
816
1213
|
return out;
|
|
817
1214
|
} finally {
|
|
818
|
-
|
|
1215
|
+
rmSync2(lock, { recursive: true, force: true });
|
|
819
1216
|
}
|
|
820
1217
|
}
|
|
821
1218
|
project(name) {
|
|
@@ -836,18 +1233,29 @@ var Registry = class _Registry {
|
|
|
836
1233
|
(e) => Object.entries(filter).every(([k, v]) => e[k] === v)
|
|
837
1234
|
);
|
|
838
1235
|
}
|
|
839
|
-
activeEffortFor(project, area) {
|
|
840
|
-
return this.efforts({ project, area, state: "active" })
|
|
1236
|
+
activeEffortFor(project, area, task) {
|
|
1237
|
+
return this.efforts({ project, area, state: "active" }).find(
|
|
1238
|
+
(e) => (e.task ?? void 0) === (task ?? void 0)
|
|
1239
|
+
);
|
|
841
1240
|
}
|
|
842
|
-
/** An effort that is being started counts as open: nobody else may start on the area meanwhile. */
|
|
843
|
-
openEffortFor(project, area) {
|
|
1241
|
+
/** An effort that is being started counts as open: nobody else may start on the same (area, task) meanwhile. */
|
|
1242
|
+
openEffortFor(project, area, task) {
|
|
844
1243
|
return this.efforts({ project, area }).find(
|
|
845
|
-
(e) => e.state === "starting" || e.state === "active"
|
|
1244
|
+
(e) => (e.state === "starting" || e.state === "active") && (e.task ?? void 0) === (task ?? void 0)
|
|
846
1245
|
);
|
|
847
1246
|
}
|
|
848
1247
|
addNotice(project, area, notice) {
|
|
849
1248
|
this.update((r) => {
|
|
850
|
-
|
|
1249
|
+
const existing = r.notices ?? [];
|
|
1250
|
+
const dup = existing.findIndex(
|
|
1251
|
+
(n) => n.project === project && n.area === area && n.notice.from === notice.from && n.notice.text === notice.text
|
|
1252
|
+
);
|
|
1253
|
+
if (dup >= 0) {
|
|
1254
|
+
existing[dup] = { project, area, notice };
|
|
1255
|
+
r.notices = existing;
|
|
1256
|
+
} else {
|
|
1257
|
+
r.notices = [...existing, { project, area, notice }];
|
|
1258
|
+
}
|
|
851
1259
|
});
|
|
852
1260
|
}
|
|
853
1261
|
notices(project, area) {
|
|
@@ -858,6 +1266,21 @@ var Registry = class _Registry {
|
|
|
858
1266
|
r.notices = (r.notices ?? []).filter((n) => !(n.project === project && n.area === area));
|
|
859
1267
|
});
|
|
860
1268
|
}
|
|
1269
|
+
clearNoticesFrom(project, branches) {
|
|
1270
|
+
const set = new Set(branches);
|
|
1271
|
+
this.update((r) => {
|
|
1272
|
+
r.notices = (r.notices ?? []).filter(
|
|
1273
|
+
(n) => !(n.project === project && set.has(n.notice.from))
|
|
1274
|
+
);
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
pruneNoticesWhere(project, area, remove) {
|
|
1278
|
+
this.update((r) => {
|
|
1279
|
+
r.notices = (r.notices ?? []).filter(
|
|
1280
|
+
(n) => !(n.project === project && n.area === area && remove(n.notice))
|
|
1281
|
+
);
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
861
1284
|
allocateSlot(project, m, owner, effort, exclude = /* @__PURE__ */ new Set()) {
|
|
862
1285
|
return this.update((r) => {
|
|
863
1286
|
const taken = new Set(
|
|
@@ -874,7 +1297,7 @@ var Registry = class _Registry {
|
|
|
874
1297
|
if (slot < 0) {
|
|
875
1298
|
throw new WorkkitError(
|
|
876
1299
|
"NO_FREE_SLOT",
|
|
877
|
-
`no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")})`
|
|
1300
|
+
`no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")}): \`work status\` lists the efforts holding them; \`work finish\` or \`work reap\` frees one`
|
|
878
1301
|
);
|
|
879
1302
|
}
|
|
880
1303
|
const lease = this.lease(
|
|
@@ -958,10 +1381,10 @@ var Registry = class _Registry {
|
|
|
958
1381
|
}
|
|
959
1382
|
};
|
|
960
1383
|
|
|
961
|
-
//
|
|
1384
|
+
// src/core/runner.ts
|
|
962
1385
|
import { spawn, spawnSync as spawnSync3 } from "child_process";
|
|
963
1386
|
import { closeSync, openSync } from "fs";
|
|
964
|
-
import { join as
|
|
1387
|
+
import { join as join7 } from "path";
|
|
965
1388
|
var DEFAULT_HEALTH_TIMEOUT_MS = 18e4;
|
|
966
1389
|
var HEALTH_POLL_MS = 1e3;
|
|
967
1390
|
var PROBE_CAP_MS = 1e4;
|
|
@@ -980,6 +1403,10 @@ function pidSignature(pid) {
|
|
|
980
1403
|
const r = spawnSync3("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
|
|
981
1404
|
return r.status === 0 ? (r.stdout ?? "").trim() : "";
|
|
982
1405
|
}
|
|
1406
|
+
function serviceModes(name, spec) {
|
|
1407
|
+
if (spec.modes && spec.modes.length > 0) return spec.modes;
|
|
1408
|
+
return name === "frontend" ? ["fullstack"] : ["backend", "fullstack"];
|
|
1409
|
+
}
|
|
983
1410
|
function probe(spec, cwd, env, timeoutMs = PROBE_CAP_MS) {
|
|
984
1411
|
const r = spawnSync3("sh", ["-c", spec.health], {
|
|
985
1412
|
cwd,
|
|
@@ -1013,7 +1440,7 @@ async function waitHealthy(o) {
|
|
|
1013
1440
|
);
|
|
1014
1441
|
}
|
|
1015
1442
|
function spawnService(name, spec, a) {
|
|
1016
|
-
const fd = openSync(
|
|
1443
|
+
const fd = openSync(join7(a.logDir, `${name}.log`), "a");
|
|
1017
1444
|
try {
|
|
1018
1445
|
const child = spawn("sh", ["-c", spec.start], {
|
|
1019
1446
|
cwd: a.cwd,
|
|
@@ -1035,7 +1462,12 @@ function spawnService(name, spec, a) {
|
|
|
1035
1462
|
async function startServices(a) {
|
|
1036
1463
|
const pids = {};
|
|
1037
1464
|
const signatures = {};
|
|
1038
|
-
|
|
1465
|
+
const targetMode = a.mode ?? "fullstack";
|
|
1466
|
+
const entries = Object.entries(a.m.services).filter(([name, spec]) => {
|
|
1467
|
+
if (a.onlyServices) return a.onlyServices.includes(name);
|
|
1468
|
+
return serviceModes(name, spec).includes(targetMode);
|
|
1469
|
+
});
|
|
1470
|
+
for (const [name, spec] of entries) {
|
|
1039
1471
|
a.onLog?.(`starting ${name}: ${spec.start}`);
|
|
1040
1472
|
pids[name] = spawnService(name, spec, a);
|
|
1041
1473
|
signatures[name] = pidSignature(pids[name]);
|
|
@@ -1099,7 +1531,7 @@ async function stopServices(o) {
|
|
|
1099
1531
|
while (alive().length > 0 && Date.now() < hardDeadline) await sleep2(100);
|
|
1100
1532
|
}
|
|
1101
1533
|
|
|
1102
|
-
//
|
|
1534
|
+
// src/core/session.ts
|
|
1103
1535
|
import { hostname, userInfo } from "os";
|
|
1104
1536
|
function currentOwner(env = process.env) {
|
|
1105
1537
|
const named = [env.WORK_SESSION, env.CLAUDE_SESSION_NAME, env.CLAUDE_CODE_SESSION_ID].find(
|
|
@@ -1108,11 +1540,23 @@ function currentOwner(env = process.env) {
|
|
|
1108
1540
|
return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
|
|
1109
1541
|
}
|
|
1110
1542
|
|
|
1111
|
-
// src/lib/
|
|
1112
|
-
import
|
|
1543
|
+
// src/lib/pritty.ts
|
|
1544
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
1113
1545
|
|
|
1114
1546
|
// src/lib/storybrokr.ts
|
|
1115
1547
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
1548
|
+
var MIN_STORYBROKR_VERSION = "0.3.0";
|
|
1549
|
+
var versionChecked = false;
|
|
1550
|
+
var versionFound = "";
|
|
1551
|
+
function compareSemver(a, b) {
|
|
1552
|
+
const pa = a.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1553
|
+
const pb = b.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1554
|
+
for (let i = 0; i < 3; i++) {
|
|
1555
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1556
|
+
if (diff !== 0) return diff;
|
|
1557
|
+
}
|
|
1558
|
+
return 0;
|
|
1559
|
+
}
|
|
1116
1560
|
function run(args) {
|
|
1117
1561
|
const r = spawnSync4("storybrokr", args, { encoding: "utf8" });
|
|
1118
1562
|
if (r.error) {
|
|
@@ -1123,9 +1567,26 @@ function run(args) {
|
|
|
1123
1567
|
}
|
|
1124
1568
|
return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
|
|
1125
1569
|
}
|
|
1570
|
+
function assertStorybrokrVersion() {
|
|
1571
|
+
if (versionChecked) return;
|
|
1572
|
+
const r = run(["--version"]);
|
|
1573
|
+
const found = r.out.trim();
|
|
1574
|
+
if (r.code !== 0 || compareSemver(found, MIN_STORYBROKR_VERSION) < 0) {
|
|
1575
|
+
throw new WorkkitError(
|
|
1576
|
+
"STORYBROKR_FAILED",
|
|
1577
|
+
`storybrokr >= ${MIN_STORYBROKR_VERSION} required (found ${found || "unknown"}); upgrade @helmsmith/storybrokr`
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
versionChecked = true;
|
|
1581
|
+
versionFound = found;
|
|
1582
|
+
}
|
|
1126
1583
|
var storybrokrCli = {
|
|
1127
|
-
async up(story, hostRoot) {
|
|
1128
|
-
|
|
1584
|
+
async up(story, hostRoot, opts) {
|
|
1585
|
+
assertStorybrokrVersion();
|
|
1586
|
+
const args = ["up", story, "--host", hostRoot, "--json"];
|
|
1587
|
+
if (opts?.ttlMinutes !== void 0) args.push("--ttl", String(opts.ttlMinutes));
|
|
1588
|
+
if (opts?.includeChildren) args.push("--include-children");
|
|
1589
|
+
const r = run(args);
|
|
1129
1590
|
if (r.code !== 0) {
|
|
1130
1591
|
throw new WorkkitError(
|
|
1131
1592
|
"STORYBROKR_FAILED",
|
|
@@ -1139,6 +1600,107 @@ var storybrokrCli = {
|
|
|
1139
1600
|
const r = run(["down", id]);
|
|
1140
1601
|
if (r.code !== 0)
|
|
1141
1602
|
throw new WorkkitError("STORYBROKR_FAILED", `storybrokr down ${id} failed: ${r.err.trim()}`);
|
|
1603
|
+
},
|
|
1604
|
+
async downHost(hostRoot) {
|
|
1605
|
+
const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--if-running", "--json"], {
|
|
1606
|
+
encoding: "utf8"
|
|
1607
|
+
});
|
|
1608
|
+
if (r.error) return [];
|
|
1609
|
+
if (r.status !== 0) {
|
|
1610
|
+
throw new WorkkitError(
|
|
1611
|
+
"STORYBROKR_FAILED",
|
|
1612
|
+
`storybrokr down --host ${hostRoot} failed: ${(r.stderr || r.stdout || "").trim()}`
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
try {
|
|
1616
|
+
const parsed = JSON.parse(r.stdout);
|
|
1617
|
+
return parsed.stopped ?? [];
|
|
1618
|
+
} catch {
|
|
1619
|
+
return [];
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
version() {
|
|
1623
|
+
assertStorybrokrVersion();
|
|
1624
|
+
return versionFound;
|
|
1625
|
+
},
|
|
1626
|
+
async touch(id) {
|
|
1627
|
+
const r = spawnSync4("storybrokr", ["touch", id], { encoding: "utf8" });
|
|
1628
|
+
void r;
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
// src/lib/pritty.ts
|
|
1633
|
+
var MIN_PRITTY_VERSION = "0.1.1";
|
|
1634
|
+
function assertPrittyVersion(client) {
|
|
1635
|
+
const found = client.version();
|
|
1636
|
+
if (compareSemver(found, MIN_PRITTY_VERSION) < 0) {
|
|
1637
|
+
throw new WorkkitError(
|
|
1638
|
+
"PRITTY_FAILED",
|
|
1639
|
+
`pritty >= ${MIN_PRITTY_VERSION} required (found ${found || "unknown"}); npm i -g @helmsmith/pritty`
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
function run2(args, cwd) {
|
|
1644
|
+
const r = spawnSync5("pritty", args, { cwd, encoding: "utf8", env: process.env });
|
|
1645
|
+
if (r.error) {
|
|
1646
|
+
throw new WorkkitError(
|
|
1647
|
+
"PRITTY_FAILED",
|
|
1648
|
+
`pritty is not installed (npm i -g @helmsmith/pritty): ${r.error.message}`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
|
|
1652
|
+
}
|
|
1653
|
+
function lastJsonLine(out, what) {
|
|
1654
|
+
const line = out.trim().split("\n").filter(Boolean).pop() ?? "";
|
|
1655
|
+
try {
|
|
1656
|
+
return JSON.parse(line);
|
|
1657
|
+
} catch {
|
|
1658
|
+
throw new WorkkitError("PRITTY_FAILED", `pritty ${what} printed no JSON: ${out.trim()}`);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
var versionFound2;
|
|
1662
|
+
var prittyCli = {
|
|
1663
|
+
version() {
|
|
1664
|
+
if (versionFound2 === void 0) {
|
|
1665
|
+
const r = run2(["--version"]);
|
|
1666
|
+
versionFound2 = r.code === 0 ? r.out.trim() : "";
|
|
1667
|
+
}
|
|
1668
|
+
return versionFound2;
|
|
1669
|
+
},
|
|
1670
|
+
async pr(o) {
|
|
1671
|
+
const args = ["pr", "--base", o.base, "--no-push", "--auto-approve", "--json"];
|
|
1672
|
+
if (o.draft) args.push("--draft");
|
|
1673
|
+
if (o.title !== void 0) args.push("--title", o.title);
|
|
1674
|
+
if (o.body !== void 0) args.push("--body", o.body);
|
|
1675
|
+
if (o.bodyFile !== void 0) args.push("--body-file", o.bodyFile);
|
|
1676
|
+
const r = run2(args, o.cwd);
|
|
1677
|
+
if (r.code !== 0) {
|
|
1678
|
+
throw new WorkkitError("PRITTY_FAILED", `pritty pr failed: ${(r.err || r.out).trim()}`, {
|
|
1679
|
+
stderr: r.err
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
const j = lastJsonLine(
|
|
1683
|
+
r.out,
|
|
1684
|
+
"pr"
|
|
1685
|
+
);
|
|
1686
|
+
return { number: j.number, url: j.url, draft: Boolean(j.draft), created: Boolean(j.created) };
|
|
1687
|
+
},
|
|
1688
|
+
async check(o) {
|
|
1689
|
+
const r = run2(["pr", "--check", "--base", o.base], o.cwd);
|
|
1690
|
+
if (r.code !== 0) {
|
|
1691
|
+
throw new WorkkitError(
|
|
1692
|
+
"PRITTY_FAILED",
|
|
1693
|
+
`pritty pr --check failed: ${(r.err || r.out).trim()}`,
|
|
1694
|
+
{
|
|
1695
|
+
stderr: r.err
|
|
1696
|
+
}
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
const j = lastJsonLine(
|
|
1700
|
+
r.out,
|
|
1701
|
+
"pr --check"
|
|
1702
|
+
);
|
|
1703
|
+
return j.open ? { number: j.open.number, url: j.open.url, draft: Boolean(j.open.draft) } : null;
|
|
1142
1704
|
}
|
|
1143
1705
|
};
|
|
1144
1706
|
|
|
@@ -1152,7 +1714,7 @@ async function buildCtx(opts = {}) {
|
|
|
1152
1714
|
const manifest = await loadManifest(repoRoot);
|
|
1153
1715
|
const home = homeDir();
|
|
1154
1716
|
const registry = Registry.open(home);
|
|
1155
|
-
|
|
1717
|
+
ensureRanges(registry, manifest, repoRoot);
|
|
1156
1718
|
return {
|
|
1157
1719
|
home,
|
|
1158
1720
|
repoRoot,
|
|
@@ -1161,6 +1723,7 @@ async function buildCtx(opts = {}) {
|
|
|
1161
1723
|
owner: currentOwner(),
|
|
1162
1724
|
runner: { start: startServices, stop: stopServices },
|
|
1163
1725
|
storybrokr: storybrokrCli,
|
|
1726
|
+
pritty: prittyCli,
|
|
1164
1727
|
log: opts.quiet ? () => {
|
|
1165
1728
|
} : (line) => console.error(chalk.gray(line))
|
|
1166
1729
|
};
|
|
@@ -1170,26 +1733,34 @@ async function buildCtx(opts = {}) {
|
|
|
1170
1733
|
import {
|
|
1171
1734
|
appendFileSync as appendFileSync2,
|
|
1172
1735
|
cpSync,
|
|
1173
|
-
existsSync as
|
|
1174
|
-
mkdirSync as
|
|
1736
|
+
existsSync as existsSync7,
|
|
1737
|
+
mkdirSync as mkdirSync6,
|
|
1175
1738
|
readdirSync,
|
|
1739
|
+
readFileSync as readFileSync5,
|
|
1176
1740
|
realpathSync as realpathSync4,
|
|
1177
|
-
rmSync as
|
|
1178
|
-
statSync as
|
|
1741
|
+
rmSync as rmSync3,
|
|
1742
|
+
statSync as statSync3,
|
|
1743
|
+
writeFileSync as writeFileSync5
|
|
1179
1744
|
} from "fs";
|
|
1180
1745
|
import { connect, createServer } from "net";
|
|
1181
|
-
import { dirname as
|
|
1182
|
-
var worktreePathFor = (ctx, area) =>
|
|
1183
|
-
var branchFor = (type, area) => `${type}/${area}`;
|
|
1746
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
1747
|
+
var worktreePathFor = (ctx, area, task) => task ? join8(ctx.repoRoot, ctx.manifest.worktreeRoot, "_tasks", `${area}--${task}`) : join8(ctx.repoRoot, ctx.manifest.worktreeRoot, area);
|
|
1748
|
+
var branchFor = (type, area, task) => task ? `${type}/${area}--${task}` : `${type}/${area}`;
|
|
1749
|
+
var effortLabel2 = (area, task) => task ? `${area}--${task}` : area;
|
|
1184
1750
|
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1185
1751
|
var STARTING_GRACE_MS = 30 * 6e4;
|
|
1186
|
-
var
|
|
1752
|
+
var JOIN_WAIT_MS = 6e4;
|
|
1753
|
+
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1754
|
+
var realpathOr2 = (p) => {
|
|
1187
1755
|
try {
|
|
1188
1756
|
return realpathSync4(p);
|
|
1189
1757
|
} catch {
|
|
1190
1758
|
return p;
|
|
1191
1759
|
}
|
|
1192
1760
|
};
|
|
1761
|
+
function fetchBaseOnce(ctx) {
|
|
1762
|
+
new Git(ctx.repoRoot).tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
|
|
1763
|
+
}
|
|
1193
1764
|
function hoursFor(m, kind) {
|
|
1194
1765
|
return {
|
|
1195
1766
|
slot: m.expiry.slotHours,
|
|
@@ -1197,40 +1768,76 @@ function hoursFor(m, kind) {
|
|
|
1197
1768
|
commons: m.expiry.commonsHours
|
|
1198
1769
|
}[kind];
|
|
1199
1770
|
}
|
|
1771
|
+
function modeCovers(existing, requested) {
|
|
1772
|
+
if (existing === "fullstack") return true;
|
|
1773
|
+
return existing === requested;
|
|
1774
|
+
}
|
|
1775
|
+
var STORYBROKR_TOUCH_THROTTLE_MS = 6e4;
|
|
1200
1776
|
function touchEffort(ctx, effort) {
|
|
1201
|
-
|
|
1777
|
+
const prevMs = Date.parse(effort.lastActivity);
|
|
1778
|
+
const shouldTouchStorybrokr = Number.isNaN(prevMs) || Date.now() - prevMs >= STORYBROKR_TOUCH_THROTTLE_MS;
|
|
1779
|
+
for (const l of ctx.registry.leases({ effort: effort.id })) {
|
|
1202
1780
|
ctx.registry.touch(l.id, hoursFor(ctx.manifest, l.kind));
|
|
1781
|
+
if (l.kind === "storybook" && shouldTouchStorybrokr) {
|
|
1782
|
+
void Promise.resolve(ctx.storybrokr.touch?.(String(l.value))).catch(() => {
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1203
1786
|
ctx.registry.updateEffort(effort.id, { lastActivity: now() });
|
|
1204
1787
|
}
|
|
1205
|
-
function
|
|
1206
|
-
return new Promise((
|
|
1788
|
+
function bindFails(port, host) {
|
|
1789
|
+
return new Promise((resolve4) => {
|
|
1207
1790
|
const srv = createServer();
|
|
1208
|
-
srv.once("error", () =>
|
|
1209
|
-
srv.listen(port,
|
|
1791
|
+
srv.once("error", (err) => resolve4(err.code === "EADDRINUSE"));
|
|
1792
|
+
srv.listen(port, host, () => srv.close(() => resolve4(false)));
|
|
1210
1793
|
});
|
|
1211
1794
|
}
|
|
1795
|
+
async function portBusy(port) {
|
|
1796
|
+
for (const host of ["127.0.0.1", "0.0.0.0", "::"]) if (await bindFails(port, host)) return true;
|
|
1797
|
+
return false;
|
|
1798
|
+
}
|
|
1212
1799
|
async function busyPorts(ports) {
|
|
1213
1800
|
const out = [];
|
|
1214
1801
|
for (const [service, port] of Object.entries(ports))
|
|
1215
1802
|
if (await portBusy(port)) out.push(`${service}=${port}`);
|
|
1216
1803
|
return out;
|
|
1217
1804
|
}
|
|
1218
|
-
function
|
|
1219
|
-
return new Promise((
|
|
1220
|
-
const sock = connect({ port, host
|
|
1805
|
+
function portAnswersOn(port, host) {
|
|
1806
|
+
return new Promise((resolve4) => {
|
|
1807
|
+
const sock = connect({ port, host });
|
|
1221
1808
|
const done = (v) => {
|
|
1222
1809
|
sock.destroy();
|
|
1223
|
-
|
|
1810
|
+
resolve4(v);
|
|
1224
1811
|
};
|
|
1225
1812
|
sock.setTimeout(300, () => done(false));
|
|
1226
1813
|
sock.once("connect", () => done(true));
|
|
1227
1814
|
sock.once("error", () => done(false));
|
|
1228
1815
|
});
|
|
1229
1816
|
}
|
|
1817
|
+
async function portAnswers(port) {
|
|
1818
|
+
return await portAnswersOn(port, "127.0.0.1") || await portAnswersOn(port, "::1");
|
|
1819
|
+
}
|
|
1230
1820
|
async function anyPortAnswers(ports) {
|
|
1231
1821
|
for (const port of Object.values(ports ?? {})) if (await portAnswers(port)) return true;
|
|
1232
1822
|
return false;
|
|
1233
1823
|
}
|
|
1824
|
+
async function claimFreeSlot(ctx, effortId, warnings) {
|
|
1825
|
+
const m = ctx.manifest;
|
|
1826
|
+
const exclude = /* @__PURE__ */ new Set();
|
|
1827
|
+
const busyNotes = [];
|
|
1828
|
+
for (; ; ) {
|
|
1829
|
+
const candidate = ctx.registry.allocateSlot(m.project, m, ctx.owner, effortId, exclude);
|
|
1830
|
+
const busy = await busyPorts(candidate.ports ?? {});
|
|
1831
|
+
if (busy.length === 0) {
|
|
1832
|
+
if (busyNotes.length) warnings.push(...busyNotes);
|
|
1833
|
+
return candidate;
|
|
1834
|
+
}
|
|
1835
|
+
ctx.registry.release(candidate.id);
|
|
1836
|
+
exclude.add(candidate.value);
|
|
1837
|
+
busyNotes.push(`slot ${candidate.value}: ${busy.join(", ")} already in use`);
|
|
1838
|
+
ctx.log(busyNotes[busyNotes.length - 1]);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1234
1841
|
function guardStart(ctx, o) {
|
|
1235
1842
|
const m = ctx.manifest;
|
|
1236
1843
|
const spec = m.areas[o.area];
|
|
@@ -1240,28 +1847,37 @@ function guardStart(ctx, o) {
|
|
|
1240
1847
|
`unknown area "${o.area}" (known: ${Object.keys(m.areas).join(", ")})`,
|
|
1241
1848
|
{ area: o.area }
|
|
1242
1849
|
);
|
|
1850
|
+
if (o.task !== void 0 && !TASK_SLUG.test(o.task))
|
|
1851
|
+
throw new WorkkitError(
|
|
1852
|
+
"TASK_INVALID",
|
|
1853
|
+
`invalid task slug "${o.task}" (must match /^[a-z0-9][a-z0-9_-]{0,47}$/ with no "--")`,
|
|
1854
|
+
{ task: o.task }
|
|
1855
|
+
);
|
|
1243
1856
|
if (!m.branch.types.includes(o.type))
|
|
1244
1857
|
throw new WorkkitError(
|
|
1245
1858
|
"TYPE_UNKNOWN",
|
|
1246
1859
|
`unknown type "${o.type}" (allowed: ${m.branch.types.join(", ")})`,
|
|
1247
1860
|
{ type: o.type }
|
|
1248
1861
|
);
|
|
1249
|
-
if (o.mode !== "frontend" && o.mode !== "fullstack")
|
|
1250
|
-
throw new WorkkitError(
|
|
1251
|
-
|
|
1862
|
+
if (o.mode !== "frontend" && o.mode !== "backend" && o.mode !== "fullstack")
|
|
1863
|
+
throw new WorkkitError(
|
|
1864
|
+
"MODE_UNKNOWN",
|
|
1865
|
+
`unknown mode "${o.mode}" (frontend | backend | fullstack)`
|
|
1866
|
+
);
|
|
1867
|
+
const branch = branchFor(o.type, o.area, o.task);
|
|
1252
1868
|
assertNotProtected(m, branch);
|
|
1253
|
-
const scopes = expandScopes(m, o.area, o.mode);
|
|
1869
|
+
const scopes = expandScopes(m, o.area, o.mode, o.scopes, Boolean(o.task));
|
|
1254
1870
|
const commons = spec.commons ?? [];
|
|
1255
1871
|
const warnings = [];
|
|
1256
1872
|
if (commons.length > 0) {
|
|
1257
1873
|
for (const e of ctx.registry.efforts({ project: m.project })) {
|
|
1258
|
-
if (e.area === o.area || e.state !== "active" && e.state !== "starting" || !
|
|
1874
|
+
if (e.area === o.area && (e.task ?? void 0) === (o.task ?? void 0) || e.state !== "active" && e.state !== "starting" || !existsSync7(e.worktree))
|
|
1259
1875
|
continue;
|
|
1260
1876
|
const changed = new Git(e.worktree).tryRun(["diff", "--name-only", "--no-renames", `origin/${m.baseBranch}...HEAD`]).out.split("\n").filter(Boolean);
|
|
1261
1877
|
const hit = commonsTouched(m, changed).filter((c) => commons.includes(c));
|
|
1262
1878
|
if (hit.length > 0)
|
|
1263
1879
|
warnings.push(
|
|
1264
|
-
`${e.area} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
|
|
1880
|
+
`${effortLabel2(e.area, e.task)} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
|
|
1265
1881
|
);
|
|
1266
1882
|
}
|
|
1267
1883
|
}
|
|
@@ -1273,10 +1889,11 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
|
|
|
1273
1889
|
id: newId("eff"),
|
|
1274
1890
|
project: m.project,
|
|
1275
1891
|
area: o.area,
|
|
1892
|
+
...o.task ? { task: o.task } : {},
|
|
1276
1893
|
type: o.type,
|
|
1277
1894
|
mode: o.mode,
|
|
1278
1895
|
branch,
|
|
1279
|
-
worktree: worktreePathFor(ctx, o.area),
|
|
1896
|
+
worktree: worktreePathFor(ctx, o.area, o.task),
|
|
1280
1897
|
owner: ctx.owner,
|
|
1281
1898
|
state: "starting",
|
|
1282
1899
|
scopes,
|
|
@@ -1290,21 +1907,25 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
|
|
|
1290
1907
|
const open = r.efforts.filter(
|
|
1291
1908
|
(e) => e.project === m.project && (e.state === "active" || e.state === "starting")
|
|
1292
1909
|
);
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1910
|
+
const onTarget = open.find(
|
|
1911
|
+
(e) => e.area === o.area && (e.task ?? void 0) === (o.task ?? void 0)
|
|
1912
|
+
);
|
|
1913
|
+
if (onTarget) {
|
|
1914
|
+
const label = effortLabel2(o.area, o.task);
|
|
1295
1915
|
throw new WorkkitError(
|
|
1296
1916
|
"EFFORT_OPEN",
|
|
1297
|
-
`an effort is open on ${
|
|
1298
|
-
{ owner:
|
|
1917
|
+
`an effort is open on ${label} (${onTarget.branch}, ${onTarget.mode}) owned by ${onTarget.owner.session} since ${onTarget.started}; finish it, or pass --join to work in the same checkout`,
|
|
1918
|
+
{ owner: onTarget.owner.session, effort: onTarget.id, branch: onTarget.branch }
|
|
1299
1919
|
);
|
|
1300
1920
|
}
|
|
1301
1921
|
for (const e of open) {
|
|
1302
1922
|
const pairs = globsOverlap(scopes, e.scopes);
|
|
1303
1923
|
if (pairs.length > 0) {
|
|
1924
|
+
const otherLabel = effortLabel2(e.area, e.task);
|
|
1304
1925
|
throw new WorkkitError(
|
|
1305
1926
|
"SCOPE_CONFLICT",
|
|
1306
|
-
`scope overlaps the open effort on ${
|
|
1307
|
-
{ with:
|
|
1927
|
+
`scope overlaps the open effort on ${otherLabel} (${e.branch}, owner ${e.owner.session}): ${pairs.join("; ")}`,
|
|
1928
|
+
{ with: otherLabel, owner: e.owner.session, effort: e.id, pairs }
|
|
1308
1929
|
);
|
|
1309
1930
|
}
|
|
1310
1931
|
}
|
|
@@ -1312,55 +1933,136 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
|
|
|
1312
1933
|
});
|
|
1313
1934
|
return effort;
|
|
1314
1935
|
}
|
|
1315
|
-
function
|
|
1936
|
+
function areaBranchFor(ctx, area, preferredType) {
|
|
1937
|
+
const main = new Git(ctx.repoRoot);
|
|
1938
|
+
const types = [preferredType, ...ctx.manifest.branch.types.filter((t) => t !== preferredType)];
|
|
1939
|
+
for (const type of types) {
|
|
1940
|
+
const b = branchFor(type, area);
|
|
1941
|
+
if (main.branchExists(b) || main.remoteRef(b)) return b;
|
|
1942
|
+
}
|
|
1943
|
+
return branchFor(preferredType, area);
|
|
1944
|
+
}
|
|
1945
|
+
function ensureWorktree(ctx, area, branch, task) {
|
|
1316
1946
|
const m = ctx.manifest;
|
|
1317
|
-
const path = worktreePathFor(ctx, area);
|
|
1947
|
+
const path = worktreePathFor(ctx, area, task);
|
|
1318
1948
|
const main = new Git(ctx.repoRoot);
|
|
1319
|
-
|
|
1320
|
-
if (!existsSync5(path)) {
|
|
1949
|
+
if (!existsSync7(path)) {
|
|
1321
1950
|
main.worktreeAdd(path, branch, `origin/${m.baseBranch}`);
|
|
1322
|
-
|
|
1951
|
+
const wg2 = new Git(path);
|
|
1952
|
+
wg2.repairSubmodules(ctx.repoRoot, ctx.log);
|
|
1953
|
+
return { wg: wg2, created: true };
|
|
1323
1954
|
}
|
|
1324
|
-
const registered = main.run(["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).map((l) =>
|
|
1325
|
-
const real =
|
|
1955
|
+
const registered = main.run(["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).map((l) => realpathOr2(l.slice(9)));
|
|
1956
|
+
const real = realpathOr2(path);
|
|
1326
1957
|
const wg = new Git(path);
|
|
1327
1958
|
const topLevel = wg.tryRun(["rev-parse", "--show-toplevel"]);
|
|
1328
|
-
if (!registered.includes(real) || topLevel.code !== 0 ||
|
|
1959
|
+
if (!registered.includes(real) || topLevel.code !== 0 || realpathOr2(topLevel.out.trim()) !== real || wg.isMainCheckout()) {
|
|
1329
1960
|
throw new WorkkitError(
|
|
1330
1961
|
"WORKTREE_INVALID",
|
|
1331
|
-
`${path} exists but is not this repository's worktree for ${area} (a leftover directory?); move it away, or \`git worktree prune\` and retry`,
|
|
1962
|
+
`${path} exists but is not this repository's worktree for ${effortLabel2(area, task)} (a leftover directory?); move it away, or \`git worktree prune\` and retry`,
|
|
1332
1963
|
{ worktree: path }
|
|
1333
1964
|
);
|
|
1334
1965
|
}
|
|
1966
|
+
wg.repairSubmodules(ctx.repoRoot, ctx.log);
|
|
1335
1967
|
const current = wg.currentBranch();
|
|
1336
1968
|
if (current !== branch) {
|
|
1337
|
-
if (current.endsWith(`/${area}`) && wg.commitsNotOn(m.baseBranch).length > 0) {
|
|
1338
|
-
throw new WorkkitError(
|
|
1339
|
-
"EFFORT_OPEN",
|
|
1340
|
-
`${current} holds ${wg.commitsNotOn(m.baseBranch).length} commit(s) not yet on ${m.baseBranch}: ship or finish it first, or --join it`,
|
|
1341
|
-
{ branch: current, area }
|
|
1342
|
-
);
|
|
1343
|
-
}
|
|
1344
1969
|
if (wg.isDirty())
|
|
1345
1970
|
throw new WorkkitError(
|
|
1346
1971
|
"DIRTY_WORKTREE",
|
|
1347
1972
|
`${path} has uncommitted changes on ${current}; commit or park them before switching to ${branch}`,
|
|
1348
1973
|
{ worktree: path }
|
|
1349
1974
|
);
|
|
1975
|
+
const unshipped = wg.commitsNotOn(m.baseBranch);
|
|
1976
|
+
if (unshipped.length > 0 && !wg.isEquivalentToBase(m.baseBranch)) {
|
|
1977
|
+
throw new WorkkitError(
|
|
1978
|
+
"EFFORT_OPEN",
|
|
1979
|
+
`${current} holds ${unshipped.length} commit(s) not yet on ${m.baseBranch}: ship or finish it first, or --join it`,
|
|
1980
|
+
{ branch: current, area }
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1350
1983
|
wg.checkout(branch, `origin/${m.baseBranch}`);
|
|
1984
|
+
if (!wg.isDirty() && wg.commitsNotOn(m.baseBranch).length > 0 && wg.isEquivalentToBase(m.baseBranch)) {
|
|
1985
|
+
wg.run(["reset", "--hard", `origin/${m.baseBranch}`]);
|
|
1986
|
+
}
|
|
1987
|
+
} else if (!wg.isDirty() && wg.commitsNotOn(m.baseBranch).length > 0 && wg.isEquivalentToBase(m.baseBranch)) {
|
|
1988
|
+
wg.run(["reset", "--hard", `origin/${m.baseBranch}`]);
|
|
1351
1989
|
}
|
|
1352
|
-
return wg;
|
|
1990
|
+
return { wg, created: false };
|
|
1353
1991
|
}
|
|
1354
1992
|
function appendLog(file, line) {
|
|
1355
1993
|
appendFileSync2(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
1356
1994
|
`);
|
|
1357
1995
|
}
|
|
1996
|
+
var PENDING_MARKS = ".pending-marks.json";
|
|
1997
|
+
function readPendingMarks(logDir) {
|
|
1998
|
+
try {
|
|
1999
|
+
const raw = JSON.parse(readFileSync5(join8(logDir, PENDING_MARKS), "utf8"));
|
|
2000
|
+
return Array.isArray(raw) ? raw.filter((x) => typeof x === "string") : [];
|
|
2001
|
+
} catch {
|
|
2002
|
+
return [];
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
function writePendingMarks(logDir, marks) {
|
|
2006
|
+
mkdirSync6(logDir, { recursive: true });
|
|
2007
|
+
writeFileSync5(join8(logDir, PENDING_MARKS), `${JSON.stringify(marks)}
|
|
2008
|
+
`);
|
|
2009
|
+
}
|
|
1358
2010
|
async function startEffort(ctx, o) {
|
|
1359
2011
|
const m = ctx.manifest;
|
|
2012
|
+
await reapLeases(ctx).catch(
|
|
2013
|
+
(err) => ctx.log(`pre-flight reap: ${err instanceof Error ? err.message : String(err)}`)
|
|
2014
|
+
);
|
|
2015
|
+
fetchBaseOnce(ctx);
|
|
1360
2016
|
const { branch, scopes, commons, warnings } = guardStart(ctx, o);
|
|
1361
2017
|
const notices = ctx.registry.notices(m.project, o.area);
|
|
1362
|
-
|
|
2018
|
+
let open = ctx.registry.openEffortFor(m.project, o.area, o.task);
|
|
1363
2019
|
if (open && o.join) {
|
|
2020
|
+
const deadline = Date.now() + JOIN_WAIT_MS;
|
|
2021
|
+
while (open && open.state === "starting" && Date.now() < deadline) {
|
|
2022
|
+
await sleep3(200);
|
|
2023
|
+
open = ctx.registry.openEffortFor(m.project, o.area, o.task);
|
|
2024
|
+
}
|
|
2025
|
+
if (!open || open.state !== "active") {
|
|
2026
|
+
throw new WorkkitError(
|
|
2027
|
+
"EFFORT_NOT_FOUND",
|
|
2028
|
+
`effort on ${effortLabel2(o.area, o.task)} did not become active in time to join`,
|
|
2029
|
+
{ area: o.area, task: o.task }
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
if (!modeCovers(open.mode, o.mode)) {
|
|
2033
|
+
const label = effortLabel2(o.area, o.task);
|
|
2034
|
+
throw new WorkkitError(
|
|
2035
|
+
"MODE_MISMATCH",
|
|
2036
|
+
`open effort on ${label} is in ${open.mode} mode, which does not cover ${o.mode}; run \`work promote --area ${o.area}${o.task ? ` --task ${o.task}` : ""} --mode fullstack\` first`,
|
|
2037
|
+
{ area: o.area, task: o.task, openMode: open.mode, requestedMode: o.mode }
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
const currentLeases = ctx.registry.leases({ effort: open.id });
|
|
2041
|
+
if (o.story && !currentLeases.some((l) => l.kind === "storybook")) {
|
|
2042
|
+
const hostRoot = storybookHostFor(m, open.worktree);
|
|
2043
|
+
const inst = await ctx.storybrokr.up(o.story, hostRoot, {
|
|
2044
|
+
ttlMinutes: Math.ceil(m.expiry.storybookHours * 60)
|
|
2045
|
+
});
|
|
2046
|
+
const sbLease = ctx.registry.addLease(
|
|
2047
|
+
"storybook",
|
|
2048
|
+
m.project,
|
|
2049
|
+
inst.id,
|
|
2050
|
+
ctx.owner,
|
|
2051
|
+
open.id,
|
|
2052
|
+
m.expiry.storybookHours,
|
|
2053
|
+
{ ports: { storybook: inst.port } }
|
|
2054
|
+
);
|
|
2055
|
+
open = ctx.registry.updateEffort(open.id, {
|
|
2056
|
+
leases: [...open.leases, sbLease.id]
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
const existingParticipants = open.participants ?? [];
|
|
2060
|
+
const isAlreadyMember = open.owner.session === ctx.owner.session || existingParticipants.some((p) => p.session === ctx.owner.session);
|
|
2061
|
+
if (!isAlreadyMember) {
|
|
2062
|
+
open = ctx.registry.updateEffort(open.id, {
|
|
2063
|
+
participants: [...existingParticipants, ctx.owner]
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
1364
2066
|
touchEffort(ctx, open);
|
|
1365
2067
|
return {
|
|
1366
2068
|
effort: open,
|
|
@@ -1386,6 +2088,8 @@ async function startEffort(ctx, o) {
|
|
|
1386
2088
|
signatures: l.signatures,
|
|
1387
2089
|
onLog: ctx.log
|
|
1388
2090
|
}).catch(() => void 0);
|
|
2091
|
+
} else if (stopPids && l.kind === "storybook") {
|
|
2092
|
+
await ctx.storybrokr.down(String(l.value)).catch(() => void 0);
|
|
1389
2093
|
}
|
|
1390
2094
|
ctx.registry.release(l.id);
|
|
1391
2095
|
}
|
|
@@ -1397,17 +2101,28 @@ async function startEffort(ctx, o) {
|
|
|
1397
2101
|
process.once("SIGINT", onSignal);
|
|
1398
2102
|
process.once("SIGTERM", onSignal);
|
|
1399
2103
|
try {
|
|
1400
|
-
const wg = ensureWorktree(ctx, o.area, branch);
|
|
2104
|
+
const { wg, created } = ensureWorktree(ctx, o.area, branch, o.task);
|
|
1401
2105
|
const worktree = wg.root();
|
|
1402
2106
|
ctx.registry.updateEffort(effort.id, { worktree });
|
|
1403
2107
|
effort.worktree = worktree;
|
|
1404
|
-
const logDir =
|
|
1405
|
-
|
|
1406
|
-
const refreshLog =
|
|
2108
|
+
const logDir = join8(worktree, m.logs.dir);
|
|
2109
|
+
mkdirSync6(logDir, { recursive: true });
|
|
2110
|
+
const refreshLog = join8(logDir, "refresh.log");
|
|
2111
|
+
const stampFile = join8(logDir, ".refresh-stamps.json");
|
|
2112
|
+
if (created && m.seed && m.seed.length > 0) {
|
|
2113
|
+
runRefresh(
|
|
2114
|
+
m.seed,
|
|
2115
|
+
worktree,
|
|
2116
|
+
(l) => {
|
|
2117
|
+
appendLog(refreshLog, l);
|
|
2118
|
+
ctx.log(l);
|
|
2119
|
+
},
|
|
2120
|
+
{ ...process.env, WORKTREE: worktree, MAIN_ROOT: ctx.repoRoot }
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
1407
2123
|
let sync = "skipped";
|
|
1408
2124
|
let changed = [];
|
|
1409
2125
|
if (o.sync !== false) {
|
|
1410
|
-
wg.fetch(m.baseBranch);
|
|
1411
2126
|
const r = wg.syncFrom(m.baseBranch);
|
|
1412
2127
|
if (r.kind === "conflict") {
|
|
1413
2128
|
throw new WorkkitError(
|
|
@@ -1418,12 +2133,31 @@ async function startEffort(ctx, o) {
|
|
|
1418
2133
|
}
|
|
1419
2134
|
sync = r.kind;
|
|
1420
2135
|
if (r.kind === "ff" || r.kind === "merge") changed = wg.changedPaths(r.before, r.after);
|
|
2136
|
+
ctx.registry.pruneNoticesWhere(
|
|
2137
|
+
m.project,
|
|
2138
|
+
o.area,
|
|
2139
|
+
(n) => wg.isAncestor(`origin/${n.from}`, "HEAD") || wg.isAncestor(n.from, "HEAD")
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
const sel = selectRefresh(m.refresh, changed, { worktree, stampFile });
|
|
2143
|
+
runRefresh(
|
|
2144
|
+
sel.runs,
|
|
2145
|
+
worktree,
|
|
2146
|
+
(l) => {
|
|
2147
|
+
appendLog(refreshLog, l);
|
|
2148
|
+
ctx.log(l);
|
|
2149
|
+
},
|
|
2150
|
+
process.env,
|
|
2151
|
+
{
|
|
2152
|
+
stampFile,
|
|
2153
|
+
pendingStamps: sel.pendingStamps,
|
|
2154
|
+
restoreFiles: sel.cleanWhenFiles
|
|
2155
|
+
}
|
|
2156
|
+
);
|
|
2157
|
+
const baseSha = wg.rev("HEAD");
|
|
2158
|
+
if (o.mode === "frontend" && sel.marks.length > 0) {
|
|
2159
|
+
writePendingMarks(logDir, [.../* @__PURE__ */ new Set([...readPendingMarks(logDir), ...sel.marks])]);
|
|
1421
2160
|
}
|
|
1422
|
-
const sel = selectRefresh(m.refresh, changed);
|
|
1423
|
-
runRefresh(sel.runs, worktree, (l) => {
|
|
1424
|
-
appendLog(refreshLog, l);
|
|
1425
|
-
ctx.log(l);
|
|
1426
|
-
});
|
|
1427
2161
|
for (const name of commons)
|
|
1428
2162
|
leases.push(
|
|
1429
2163
|
ctx.registry.addLease(
|
|
@@ -1435,25 +2169,9 @@ async function startEffort(ctx, o) {
|
|
|
1435
2169
|
m.expiry.commonsHours
|
|
1436
2170
|
)
|
|
1437
2171
|
);
|
|
1438
|
-
if (o.mode === "fullstack") {
|
|
1439
|
-
const
|
|
1440
|
-
let slot;
|
|
1441
|
-
const busyNotes = [];
|
|
1442
|
-
for (; ; ) {
|
|
1443
|
-
const candidate = ctx.registry.allocateSlot(m.project, m, ctx.owner, effort.id, exclude);
|
|
1444
|
-
const busy = await busyPorts(candidate.ports ?? {});
|
|
1445
|
-
if (busy.length === 0) {
|
|
1446
|
-
slot = candidate;
|
|
1447
|
-
break;
|
|
1448
|
-
}
|
|
1449
|
-
ctx.registry.release(candidate.id);
|
|
1450
|
-
exclude.add(candidate.value);
|
|
1451
|
-
busyNotes.push(`slot ${candidate.value}: ${busy.join(", ")} already in use`);
|
|
1452
|
-
ctx.log(busyNotes[busyNotes.length - 1]);
|
|
1453
|
-
}
|
|
1454
|
-
if (!slot) throw new WorkkitError("NO_FREE_SLOT", busyNotes.join("; "));
|
|
2172
|
+
if (o.mode === "fullstack" || o.mode === "backend") {
|
|
2173
|
+
const slot = await claimFreeSlot(ctx, effort.id, warnings);
|
|
1455
2174
|
leases.push(slot);
|
|
1456
|
-
if (busyNotes.length) warnings.push(...busyNotes);
|
|
1457
2175
|
const cfg = emitWorktreeConfig({
|
|
1458
2176
|
m,
|
|
1459
2177
|
worktree,
|
|
@@ -1463,8 +2181,13 @@ async function startEffort(ctx, o) {
|
|
|
1463
2181
|
branch
|
|
1464
2182
|
});
|
|
1465
2183
|
const env = { ...process.env, ...cfg.vars };
|
|
1466
|
-
|
|
2184
|
+
const owedMarks = [.../* @__PURE__ */ new Set([...sel.marks, ...readPendingMarks(logDir)])];
|
|
2185
|
+
if (owedMarks.includes("db-reset") && m.services.postgres?.reset) {
|
|
1467
2186
|
runRefresh([m.services.postgres.reset], worktree, (l) => appendLog(refreshLog, l), env);
|
|
2187
|
+
writePendingMarks(
|
|
2188
|
+
logDir,
|
|
2189
|
+
owedMarks.filter((x) => x !== "db-reset")
|
|
2190
|
+
);
|
|
1468
2191
|
}
|
|
1469
2192
|
const pids = {};
|
|
1470
2193
|
const signatures = {};
|
|
@@ -1473,6 +2196,7 @@ async function startEffort(ctx, o) {
|
|
|
1473
2196
|
cwd: worktree,
|
|
1474
2197
|
env,
|
|
1475
2198
|
logDir,
|
|
2199
|
+
mode: o.mode,
|
|
1476
2200
|
onLog: ctx.log,
|
|
1477
2201
|
onSpawn: (service, pid, signature) => {
|
|
1478
2202
|
pids[service] = pid;
|
|
@@ -1487,8 +2211,10 @@ async function startEffort(ctx, o) {
|
|
|
1487
2211
|
} else {
|
|
1488
2212
|
let storybookPort;
|
|
1489
2213
|
if (o.story) {
|
|
1490
|
-
const hostRoot = m
|
|
1491
|
-
const inst = await ctx.storybrokr.up(o.story, hostRoot
|
|
2214
|
+
const hostRoot = storybookHostFor(m, worktree);
|
|
2215
|
+
const inst = await ctx.storybrokr.up(o.story, hostRoot, {
|
|
2216
|
+
ttlMinutes: Math.ceil(m.expiry.storybookHours * 60)
|
|
2217
|
+
});
|
|
1492
2218
|
storybookPort = inst.port;
|
|
1493
2219
|
leases.push(
|
|
1494
2220
|
ctx.registry.addLease(
|
|
@@ -1506,6 +2232,7 @@ async function startEffort(ctx, o) {
|
|
|
1506
2232
|
}
|
|
1507
2233
|
const active = ctx.registry.updateEffort(effort.id, {
|
|
1508
2234
|
state: "active",
|
|
2235
|
+
baseSha,
|
|
1509
2236
|
leases: leases.map((l) => l.id),
|
|
1510
2237
|
lastActivity: now()
|
|
1511
2238
|
});
|
|
@@ -1527,28 +2254,154 @@ async function startEffort(ctx, o) {
|
|
|
1527
2254
|
process.off("SIGTERM", onSignal);
|
|
1528
2255
|
}
|
|
1529
2256
|
}
|
|
2257
|
+
async function promoteEffort(ctx, o) {
|
|
2258
|
+
const m = ctx.manifest;
|
|
2259
|
+
if (o.mode !== "backend" && o.mode !== "fullstack") {
|
|
2260
|
+
throw new WorkkitError("MODE_UNKNOWN", `promote target mode must be backend or fullstack`);
|
|
2261
|
+
}
|
|
2262
|
+
const open = ctx.registry.openEffortFor(m.project, o.area, o.task);
|
|
2263
|
+
if (!open) {
|
|
2264
|
+
throw new WorkkitError(
|
|
2265
|
+
"EFFORT_NOT_FOUND",
|
|
2266
|
+
`no open effort on ${effortLabel2(o.area, o.task)} to promote`,
|
|
2267
|
+
{ area: o.area, task: o.task }
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
if (open.mode === "fullstack" && o.mode === "backend" || open.mode === "frontend" && o.mode === "backend") {
|
|
2271
|
+
throw new WorkkitError(
|
|
2272
|
+
"MODE_MISMATCH",
|
|
2273
|
+
`cannot transition effort on ${effortLabel2(o.area, o.task)} from ${open.mode} to ${o.mode}`,
|
|
2274
|
+
{ from: open.mode, to: o.mode }
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
const modeScopes = expandScopes(m, o.area, o.mode);
|
|
2278
|
+
const nextScopes = open.task ? [.../* @__PURE__ */ new Set([...open.scopes, ...modeScopes])] : modeScopes;
|
|
2279
|
+
const refuseOverlap = (r) => {
|
|
2280
|
+
const others = r.efforts.filter(
|
|
2281
|
+
(e) => e.project === m.project && e.id !== open.id && (e.state === "active" || e.state === "starting")
|
|
2282
|
+
);
|
|
2283
|
+
for (const e of others) {
|
|
2284
|
+
const pairs = globsOverlap(nextScopes, e.scopes);
|
|
2285
|
+
if (pairs.length > 0) {
|
|
2286
|
+
const otherLabel = effortLabel2(e.area, e.task);
|
|
2287
|
+
throw new WorkkitError(
|
|
2288
|
+
"SCOPE_CONFLICT",
|
|
2289
|
+
`promoting ${effortLabel2(o.area, o.task)} to ${o.mode} overlaps ${otherLabel} (${e.branch}): ${pairs.join("; ")}`,
|
|
2290
|
+
{ with: otherLabel, owner: e.owner.session, effort: e.id, pairs }
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
};
|
|
2295
|
+
ctx.registry.update(refuseOverlap);
|
|
2296
|
+
const warnings = [];
|
|
2297
|
+
let slot = ctx.registry.leases({ effort: open.id, kind: "slot" })[0];
|
|
2298
|
+
const sb = ctx.registry.leases({ effort: open.id, kind: "storybook" })[0];
|
|
2299
|
+
const claimed = !slot;
|
|
2300
|
+
if (!slot) slot = await claimFreeSlot(ctx, open.id, warnings);
|
|
2301
|
+
try {
|
|
2302
|
+
ctx.registry.update((r) => {
|
|
2303
|
+
refuseOverlap(r);
|
|
2304
|
+
const idx = r.efforts.findIndex((e) => e.id === open.id);
|
|
2305
|
+
if (idx >= 0) {
|
|
2306
|
+
r.efforts[idx] = {
|
|
2307
|
+
...r.efforts[idx],
|
|
2308
|
+
mode: o.mode,
|
|
2309
|
+
scopes: nextScopes,
|
|
2310
|
+
leases: claimed ? [...open.leases, slot.id] : r.efforts[idx].leases,
|
|
2311
|
+
lastActivity: now()
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
});
|
|
2315
|
+
} catch (err) {
|
|
2316
|
+
if (claimed) ctx.registry.release(slot.id);
|
|
2317
|
+
throw err;
|
|
2318
|
+
}
|
|
2319
|
+
const owed = readPendingMarks(join8(open.worktree, m.logs.dir));
|
|
2320
|
+
if (owed.includes("db-reset") && m.services.postgres?.reset) {
|
|
2321
|
+
const cfgForReset = emitWorktreeConfig({
|
|
2322
|
+
m,
|
|
2323
|
+
worktree: open.worktree,
|
|
2324
|
+
slot: slot.value,
|
|
2325
|
+
ports: slot.ports ?? {},
|
|
2326
|
+
area: o.area,
|
|
2327
|
+
branch: open.branch
|
|
2328
|
+
});
|
|
2329
|
+
runRefresh([m.services.postgres.reset], open.worktree, ctx.log, {
|
|
2330
|
+
...process.env,
|
|
2331
|
+
...cfgForReset.vars
|
|
2332
|
+
});
|
|
2333
|
+
writePendingMarks(
|
|
2334
|
+
join8(open.worktree, m.logs.dir),
|
|
2335
|
+
owed.filter((x) => x !== "db-reset")
|
|
2336
|
+
);
|
|
2337
|
+
}
|
|
2338
|
+
const cfg = emitWorktreeConfig({
|
|
2339
|
+
m,
|
|
2340
|
+
worktree: open.worktree,
|
|
2341
|
+
slot: slot.value,
|
|
2342
|
+
ports: slot.ports ?? {},
|
|
2343
|
+
storybookPort: sb?.ports?.storybook,
|
|
2344
|
+
area: o.area,
|
|
2345
|
+
branch: open.branch
|
|
2346
|
+
});
|
|
2347
|
+
const env = { ...process.env, ...cfg.vars };
|
|
2348
|
+
const logDir = join8(open.worktree, m.logs.dir);
|
|
2349
|
+
mkdirSync6(logDir, { recursive: true });
|
|
2350
|
+
const existingPids = { ...slot.pids ?? {} };
|
|
2351
|
+
const existingSigs = { ...slot.signatures ?? {} };
|
|
2352
|
+
const missingServices = Object.entries(m.services).filter(([name, spec]) => serviceModes(name, spec).includes(o.mode) && !existingPids[name]).map(([name]) => name);
|
|
2353
|
+
if (missingServices.length > 0) {
|
|
2354
|
+
const result = await ctx.runner.start({
|
|
2355
|
+
m,
|
|
2356
|
+
cwd: open.worktree,
|
|
2357
|
+
env,
|
|
2358
|
+
logDir,
|
|
2359
|
+
mode: o.mode,
|
|
2360
|
+
onlyServices: missingServices,
|
|
2361
|
+
onLog: ctx.log,
|
|
2362
|
+
onSpawn: (service, pid, signature) => {
|
|
2363
|
+
existingPids[service] = pid;
|
|
2364
|
+
existingSigs[service] = signature;
|
|
2365
|
+
ctx.registry.patchLease(slot.id, {
|
|
2366
|
+
pids: { ...existingPids },
|
|
2367
|
+
signatures: { ...existingSigs }
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
});
|
|
2371
|
+
ctx.registry.patchLease(slot.id, {
|
|
2372
|
+
pids: { ...existingPids, ...result.pids },
|
|
2373
|
+
signatures: { ...existingSigs, ...result.signatures ?? {} }
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
const updated = ctx.registry.updateEffort(open.id, { lastActivity: now() });
|
|
2377
|
+
touchEffort(ctx, updated);
|
|
2378
|
+
return {
|
|
2379
|
+
effort: updated,
|
|
2380
|
+
leases: ctx.registry.leases({ effort: open.id }),
|
|
2381
|
+
startedServices: missingServices,
|
|
2382
|
+
warnings
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
1530
2385
|
function behindBase(ctx, worktree) {
|
|
1531
|
-
if (!
|
|
1532
|
-
|
|
1533
|
-
g.tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
|
|
1534
|
-
const r = g.tryRun(["rev-list", "--count", `HEAD..origin/${ctx.manifest.baseBranch}`]);
|
|
1535
|
-
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
2386
|
+
if (!existsSync7(worktree)) return 0;
|
|
2387
|
+
return new Git(worktree).behindCount(ctx.manifest.baseBranch);
|
|
1536
2388
|
}
|
|
1537
|
-
function statusFor(ctx, area) {
|
|
2389
|
+
function statusFor(ctx, area, task) {
|
|
1538
2390
|
const m = ctx.manifest;
|
|
2391
|
+
fetchBaseOnce(ctx);
|
|
1539
2392
|
const names = (area ? [area] : Object.keys(m.areas)).sort();
|
|
1540
2393
|
const areas = names.map((name) => {
|
|
1541
|
-
const effort = ctx.registry.openEffortFor(m.project, name);
|
|
2394
|
+
const effort = ctx.registry.openEffortFor(m.project, name, area ? task : void 0);
|
|
1542
2395
|
if (effort && area) touchEffort(ctx, effort);
|
|
1543
|
-
const worktree = worktreePathFor(ctx, name);
|
|
2396
|
+
const worktree = worktreePathFor(ctx, name, area ? task : void 0);
|
|
1544
2397
|
const out = {
|
|
1545
|
-
area: name,
|
|
2398
|
+
area: area && task ? `${name}--${task}` : name,
|
|
1546
2399
|
leases: effort ? ctx.registry.leases({ effort: effort.id }) : [],
|
|
1547
2400
|
behindBase: behindBase(ctx, worktree),
|
|
1548
2401
|
notices: ctx.registry.notices(m.project, name)
|
|
1549
2402
|
};
|
|
1550
2403
|
if (effort) out.effort = effort;
|
|
1551
|
-
if (
|
|
2404
|
+
if (existsSync7(worktree)) out.worktree = worktree;
|
|
1552
2405
|
return out;
|
|
1553
2406
|
});
|
|
1554
2407
|
return { project: m.project, areas, leases: ctx.registry.leases({ project: m.project }) };
|
|
@@ -1556,14 +2409,14 @@ function statusFor(ctx, area) {
|
|
|
1556
2409
|
function slotEnv(m, slot) {
|
|
1557
2410
|
return slotVars(m, { slot, ports: portsForSlot(m, slot) });
|
|
1558
2411
|
}
|
|
1559
|
-
function effortEnv(ctx, area) {
|
|
2412
|
+
function effortEnv(ctx, area, task) {
|
|
1560
2413
|
const m = ctx.manifest;
|
|
1561
|
-
const effort = ctx.registry.openEffortFor(m.project, area);
|
|
2414
|
+
const effort = ctx.registry.openEffortFor(m.project, area, task);
|
|
1562
2415
|
if (!effort)
|
|
1563
2416
|
throw new WorkkitError(
|
|
1564
2417
|
"EFFORT_NOT_FOUND",
|
|
1565
|
-
`no open effort on ${area}; \`work start --area ${area} \u2026\` first`,
|
|
1566
|
-
{ area }
|
|
2418
|
+
`no open effort on ${effortLabel2(area, task)}; \`work start --area ${area} \u2026\` first`,
|
|
2419
|
+
{ area, task }
|
|
1567
2420
|
);
|
|
1568
2421
|
touchEffort(ctx, effort);
|
|
1569
2422
|
const slot = ctx.registry.leases({ effort: effort.id, kind: "slot" })[0];
|
|
@@ -1575,7 +2428,7 @@ function effortEnv(ctx, area) {
|
|
|
1575
2428
|
area,
|
|
1576
2429
|
branch: effort.branch,
|
|
1577
2430
|
worktree: effort.worktree,
|
|
1578
|
-
logDir:
|
|
2431
|
+
logDir: join8(effort.worktree, m.logs.dir)
|
|
1579
2432
|
});
|
|
1580
2433
|
return { ...vars, MODE: effort.mode };
|
|
1581
2434
|
}
|
|
@@ -1586,14 +2439,14 @@ function leaseEnv(ctx, effort, lease) {
|
|
|
1586
2439
|
area: effort.area,
|
|
1587
2440
|
branch: effort.branch,
|
|
1588
2441
|
worktree: effort.worktree,
|
|
1589
|
-
logDir:
|
|
2442
|
+
logDir: join8(effort.worktree, ctx.manifest.logs.dir)
|
|
1590
2443
|
});
|
|
1591
2444
|
return { ...process.env, ...vars };
|
|
1592
2445
|
}
|
|
1593
2446
|
async function releaseLease(ctx, effort, lease) {
|
|
1594
2447
|
try {
|
|
1595
2448
|
if (lease.kind === "slot" && lease.pids && Object.keys(lease.pids).length > 0) {
|
|
1596
|
-
const hasWorktree = effort !== void 0 &&
|
|
2449
|
+
const hasWorktree = effort !== void 0 && existsSync7(effort.worktree);
|
|
1597
2450
|
await ctx.runner.stop({
|
|
1598
2451
|
m: ctx.manifest,
|
|
1599
2452
|
cwd: hasWorktree && effort ? effort.worktree : ctx.repoRoot,
|
|
@@ -1613,6 +2466,15 @@ async function releaseLease(ctx, effort, lease) {
|
|
|
1613
2466
|
}
|
|
1614
2467
|
ctx.registry.release(lease.id);
|
|
1615
2468
|
}
|
|
2469
|
+
async function sweepStorybook(ctx, effort) {
|
|
2470
|
+
if (effort.mode === "backend" || !ctx.storybrokr.downHost) return;
|
|
2471
|
+
try {
|
|
2472
|
+
const stopped = await ctx.storybrokr.downHost(storybookHostFor(ctx.manifest, effort.worktree));
|
|
2473
|
+
if (stopped.length > 0) ctx.log(`storybrokr: stopped ${stopped.join(", ")}`);
|
|
2474
|
+
} catch (err) {
|
|
2475
|
+
ctx.log(`storybrokr sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
1616
2478
|
function leaveNotices(ctx, effort, commons) {
|
|
1617
2479
|
if (commons.length === 0) return 0;
|
|
1618
2480
|
const notice = {
|
|
@@ -1630,29 +2492,117 @@ function leaveNotices(ctx, effort, commons) {
|
|
|
1630
2492
|
}
|
|
1631
2493
|
async function finishEffort(ctx, o) {
|
|
1632
2494
|
const m = ctx.manifest;
|
|
1633
|
-
const effort = ctx.registry.openEffortFor(m.project, o.area) ?? ctx.registry.efforts({ project: m.project, area: o.area, state: "parked" }).sort((a, b) => b.started.localeCompare(a.started))[0];
|
|
2495
|
+
const effort = ctx.registry.openEffortFor(m.project, o.area, o.task) ?? ctx.registry.efforts({ project: m.project, area: o.area, state: "parked" }).filter((e) => (e.task ?? void 0) === (o.task ?? void 0)).sort((a, b) => b.started.localeCompare(a.started))[0];
|
|
1634
2496
|
if (!effort)
|
|
1635
|
-
throw new WorkkitError(
|
|
1636
|
-
|
|
1637
|
-
|
|
2497
|
+
throw new WorkkitError(
|
|
2498
|
+
"EFFORT_NOT_FOUND",
|
|
2499
|
+
`no open or parked effort on ${effortLabel2(o.area, o.task)}`,
|
|
2500
|
+
{ area: o.area, task: o.task }
|
|
2501
|
+
);
|
|
2502
|
+
if (!o.force && effort.participants && effort.participants.length > 0) {
|
|
2503
|
+
if (ctx.owner.session === effort.owner.session) {
|
|
2504
|
+
const [nextOwner, ...rest] = effort.participants;
|
|
2505
|
+
const updated = ctx.registry.updateEffort(effort.id, {
|
|
2506
|
+
owner: nextOwner,
|
|
2507
|
+
participants: rest,
|
|
2508
|
+
lastActivity: now()
|
|
2509
|
+
});
|
|
2510
|
+
touchEffort(ctx, updated);
|
|
2511
|
+
return { effort: updated, pushed: false, parked: false, left: true, notices: 0 };
|
|
2512
|
+
}
|
|
2513
|
+
if (effort.participants.some((p) => p.session === ctx.owner.session)) {
|
|
2514
|
+
const updated = ctx.registry.updateEffort(effort.id, {
|
|
2515
|
+
participants: effort.participants.filter((p) => p.session !== ctx.owner.session),
|
|
2516
|
+
lastActivity: now()
|
|
2517
|
+
});
|
|
2518
|
+
touchEffort(ctx, updated);
|
|
2519
|
+
return { effort: updated, pushed: false, parked: false, left: true, notices: 0 };
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
const label = effortLabel2(effort.area, effort.task);
|
|
1638
2523
|
const g = new Git(effort.worktree);
|
|
1639
2524
|
let parked = false;
|
|
1640
2525
|
if (g.isDirty()) {
|
|
1641
2526
|
if (!o.park) {
|
|
1642
2527
|
throw new WorkkitError(
|
|
1643
2528
|
"DIRTY_WORKTREE",
|
|
1644
|
-
`${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${
|
|
2529
|
+
`${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${label}) and push`,
|
|
1645
2530
|
{ worktree: effort.worktree }
|
|
1646
2531
|
);
|
|
1647
2532
|
}
|
|
1648
2533
|
g.run(["add", "-A"]);
|
|
1649
|
-
g.run(["commit", "-q", "-m", `wip(${
|
|
2534
|
+
g.run(["commit", "-q", "-m", `wip(${label}): parked ${now()}`]);
|
|
1650
2535
|
parked = true;
|
|
1651
2536
|
}
|
|
2537
|
+
if (o.intoArea) {
|
|
2538
|
+
if (!effort.task) {
|
|
2539
|
+
throw new WorkkitError(
|
|
2540
|
+
"MANIFEST_INVALID",
|
|
2541
|
+
"--into-area requires a task sub-effort (--task <slug>)",
|
|
2542
|
+
{ area: effort.area }
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
fetchBaseOnce(ctx);
|
|
2546
|
+
const parentBranch = areaBranchFor(ctx, effort.area, effort.type);
|
|
2547
|
+
const { wg: areaGit } = ensureWorktree(ctx, effort.area, parentBranch);
|
|
2548
|
+
if (areaGit.isDirty()) {
|
|
2549
|
+
throw new WorkkitError(
|
|
2550
|
+
"DIRTY_WORKTREE",
|
|
2551
|
+
`parent area worktree ${areaGit.cwd} has uncommitted changes; commit or clean it before merging task ${effort.branch} --into-area`,
|
|
2552
|
+
{ worktree: areaGit.cwd }
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
const merged = areaGit.tryRun(["merge", "--no-edit", "-q", effort.branch]);
|
|
2556
|
+
if (merged.code !== 0) {
|
|
2557
|
+
const conflicted = areaGit.tryRun(["diff", "--name-only", "--diff-filter=U"]).out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
2558
|
+
areaGit.tryRun(["merge", "--abort"]);
|
|
2559
|
+
throw new WorkkitError(
|
|
2560
|
+
"SYNC_CONFLICT",
|
|
2561
|
+
`merging ${effort.branch} into ${parentBranch} conflicts in ${conflicted.join(", ")}; resolve in ${effort.worktree} before retrying \`work finish --into-area\``,
|
|
2562
|
+
{ conflicted, worktree: areaGit.cwd }
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
if (!o.intoArea) {
|
|
2567
|
+
try {
|
|
2568
|
+
g.push(effort.branch);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
throw new WorkkitError(
|
|
2571
|
+
"PUSH_REJECTED",
|
|
2572
|
+
`push of ${effort.branch} was rejected (${err instanceof Error ? err.message : String(err)}); nothing was stopped or released. Merge origin/${effort.branch} into the worktree and run \`work finish\` again.`,
|
|
2573
|
+
{ branch: effort.branch, worktree: effort.worktree }
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
1652
2577
|
for (const lease of ctx.registry.leases({ effort: effort.id }))
|
|
1653
2578
|
await releaseLease(ctx, effort, lease);
|
|
1654
|
-
|
|
1655
|
-
|
|
2579
|
+
await sweepStorybook(ctx, effort);
|
|
2580
|
+
let notices = 0;
|
|
2581
|
+
if (!o.intoArea) {
|
|
2582
|
+
if (g.commitsNotOn(m.baseBranch).length > 0 && !g.isEquivalentToBase(m.baseBranch)) {
|
|
2583
|
+
const since = g.changedSince(m.baseBranch);
|
|
2584
|
+
const sinceSet = new Set(since);
|
|
2585
|
+
const changed = effort.baseSha ? g.changedPaths(effort.baseSha, "HEAD").filter((p) => sinceSet.has(p)) : since;
|
|
2586
|
+
notices = leaveNotices(ctx, effort, commonsTouched(m, changed));
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
if (effort.task) {
|
|
2590
|
+
archiveWorktree(ctx, label, effort.worktree);
|
|
2591
|
+
const main = new Git(ctx.repoRoot);
|
|
2592
|
+
main.worktreeRemove(effort.worktree);
|
|
2593
|
+
if (o.intoArea) {
|
|
2594
|
+
main.deleteLocalBranch(effort.branch);
|
|
2595
|
+
}
|
|
2596
|
+
const finished2 = { ...effort, state: "finished", lastActivity: now() };
|
|
2597
|
+
ctx.registry.removeEfforts((e) => e.id === effort.id);
|
|
2598
|
+
return {
|
|
2599
|
+
effort: finished2,
|
|
2600
|
+
pushed: !o.intoArea,
|
|
2601
|
+
parked,
|
|
2602
|
+
mergedIntoArea: Boolean(o.intoArea),
|
|
2603
|
+
notices
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
1656
2606
|
const finished = ctx.registry.updateEffort(effort.id, { state: "finished", lastActivity: now() });
|
|
1657
2607
|
return { effort: finished, pushed: true, parked, notices };
|
|
1658
2608
|
}
|
|
@@ -1667,57 +2617,67 @@ async function reapLeases(ctx, o = {}) {
|
|
|
1667
2617
|
if (effort?.state === "starting" && Date.now() - Date.parse(effort.started) < STARTING_GRACE_MS)
|
|
1668
2618
|
continue;
|
|
1669
2619
|
const expired = Date.parse(l.expires) < at.getTime();
|
|
1670
|
-
const orphaned = !effort || !
|
|
2620
|
+
const orphaned = !effort || !existsSync7(effort.worktree);
|
|
1671
2621
|
const garbage = orphaned && !await anyPortAnswers(l.ports);
|
|
1672
2622
|
if (expired || garbage) doomed.add(l.effort);
|
|
1673
2623
|
}
|
|
2624
|
+
for (const e of efforts.values()) {
|
|
2625
|
+
if (e.state !== "starting" || doomed.has(e.id)) continue;
|
|
2626
|
+
if (leases.some((l) => l.effort === e.id)) continue;
|
|
2627
|
+
if (Date.now() - Date.parse(e.started) < STARTING_GRACE_MS) continue;
|
|
2628
|
+
doomed.add(e.id);
|
|
2629
|
+
}
|
|
1674
2630
|
const reaped = leases.filter((l) => doomed.has(l.effort));
|
|
1675
2631
|
if (o.dryRun) return { reaped };
|
|
1676
2632
|
for (const lease of reaped) await releaseLease(ctx, efforts.get(lease.effort), lease);
|
|
1677
2633
|
for (const id of doomed) {
|
|
1678
2634
|
const effort = efforts.get(id);
|
|
1679
|
-
if (effort
|
|
1680
|
-
ctx
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
2635
|
+
if (effort) {
|
|
2636
|
+
await sweepStorybook(ctx, effort);
|
|
2637
|
+
if (effort.state === "active" || effort.state === "starting") {
|
|
2638
|
+
const hadLease = leases.some((l) => l.effort === effort.id);
|
|
2639
|
+
ctx.registry.updateEffort(effort.id, {
|
|
2640
|
+
state: "parked",
|
|
2641
|
+
parkedReason: hadLease ? `idle: a lease expired by ${at.toISOString()}` : `start died before its first lease (started ${effort.started})`,
|
|
2642
|
+
lastActivity: now()
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
1685
2645
|
}
|
|
1686
2646
|
}
|
|
1687
2647
|
return { reaped };
|
|
1688
2648
|
}
|
|
1689
2649
|
var ARCHIVE_DIR = ".slot-archive";
|
|
1690
2650
|
function dirSize(path) {
|
|
1691
|
-
if (!
|
|
2651
|
+
if (!existsSync7(path)) return 0;
|
|
1692
2652
|
let total = 0;
|
|
1693
2653
|
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
1694
|
-
const p =
|
|
1695
|
-
total += entry.isDirectory() ? dirSize(p) :
|
|
2654
|
+
const p = join8(path, entry.name);
|
|
2655
|
+
total += entry.isDirectory() ? dirSize(p) : statSync3(p).size;
|
|
1696
2656
|
}
|
|
1697
2657
|
return total;
|
|
1698
2658
|
}
|
|
1699
2659
|
function archiveWorktree(ctx, area, worktree) {
|
|
1700
2660
|
const m = ctx.manifest;
|
|
1701
|
-
const areaRoot =
|
|
2661
|
+
const areaRoot = join8(ctx.repoRoot, ARCHIVE_DIR, area);
|
|
1702
2662
|
const stamp = now().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
1703
|
-
const target =
|
|
2663
|
+
const target = join8(areaRoot, stamp);
|
|
1704
2664
|
const archived = [];
|
|
1705
2665
|
for (const rel of m.archive.paths) {
|
|
1706
|
-
const src =
|
|
1707
|
-
if (!
|
|
1708
|
-
const dst =
|
|
1709
|
-
|
|
2666
|
+
const src = join8(worktree, rel);
|
|
2667
|
+
if (!existsSync7(src)) continue;
|
|
2668
|
+
const dst = join8(target, rel);
|
|
2669
|
+
mkdirSync6(dirname3(dst), { recursive: true });
|
|
1710
2670
|
cpSync(src, dst, { recursive: true });
|
|
1711
2671
|
archived.push(dst);
|
|
1712
2672
|
}
|
|
1713
2673
|
if (archived.length > 0) ensureExcluded(ctx.repoRoot, [`${ARCHIVE_DIR}/`]);
|
|
1714
|
-
if (
|
|
2674
|
+
if (existsSync7(areaRoot)) {
|
|
1715
2675
|
const stamps = readdirSync(areaRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse();
|
|
1716
2676
|
const cutoff = Date.now() - m.archive.keepDays * 864e5;
|
|
1717
2677
|
stamps.forEach((d, i) => {
|
|
1718
|
-
const t =
|
|
2678
|
+
const t = statSync3(join8(areaRoot, d)).mtimeMs;
|
|
1719
2679
|
if (i >= m.archive.keepPerArea || t < cutoff)
|
|
1720
|
-
|
|
2680
|
+
rmSync3(join8(areaRoot, d), { recursive: true, force: true });
|
|
1721
2681
|
});
|
|
1722
2682
|
}
|
|
1723
2683
|
return archived;
|
|
@@ -1726,17 +2686,17 @@ async function retireArea(ctx, o) {
|
|
|
1726
2686
|
const m = ctx.manifest;
|
|
1727
2687
|
if (!m.areas[o.area])
|
|
1728
2688
|
throw new WorkkitError("AREA_UNKNOWN", `unknown area "${o.area}"`, { area: o.area });
|
|
1729
|
-
const open = ctx.registry.
|
|
2689
|
+
const open = ctx.registry.efforts({ project: m.project, area: o.area }).find((e) => e.state === "starting" || e.state === "active");
|
|
1730
2690
|
if (open)
|
|
1731
2691
|
throw new WorkkitError(
|
|
1732
2692
|
"EFFORT_OPEN",
|
|
1733
|
-
`an effort is open on ${
|
|
2693
|
+
`an effort is open on ${effortLabel2(open.area, open.task)} (${open.owner.session}); \`work finish\` it first`,
|
|
1734
2694
|
{ owner: open.owner.session }
|
|
1735
2695
|
);
|
|
1736
|
-
const worktree = worktreePathFor(ctx, o.area);
|
|
2696
|
+
const worktree = realpathOr2(worktreePathFor(ctx, o.area));
|
|
1737
2697
|
const main = new Git(ctx.repoRoot);
|
|
1738
2698
|
let archived = [];
|
|
1739
|
-
if (
|
|
2699
|
+
if (existsSync7(worktree)) {
|
|
1740
2700
|
const g = new Git(worktree);
|
|
1741
2701
|
if (g.isDirty())
|
|
1742
2702
|
throw new WorkkitError(
|
|
@@ -1751,13 +2711,15 @@ async function retireArea(ctx, o) {
|
|
|
1751
2711
|
`${g.currentBranch()} has ${unpushed} commit(s) not on origin; push before retiring`,
|
|
1752
2712
|
{ worktree, unpushed }
|
|
1753
2713
|
);
|
|
2714
|
+
const hadStorybook = ctx.registry.efforts({ project: m.project, area: o.area }).some((e) => e.mode !== "backend");
|
|
2715
|
+
if (hadStorybook) await sweepStorybook(ctx, { worktree, mode: "frontend" });
|
|
1754
2716
|
archived = archiveWorktree(ctx, o.area, worktree);
|
|
1755
2717
|
main.worktreeRemove(worktree);
|
|
1756
2718
|
}
|
|
1757
2719
|
const branchesDeleted = [];
|
|
1758
2720
|
const branchesKept = [];
|
|
1759
|
-
|
|
1760
|
-
|
|
2721
|
+
const allAreaBranches = m.branch.types.map((type) => `${type}/${o.area}`);
|
|
2722
|
+
for (const branch of allAreaBranches) {
|
|
1761
2723
|
if (!main.branchExists(branch)) continue;
|
|
1762
2724
|
const onOrigin = main.remoteBranchExists(branch) && main.tryRun(["rev-list", "--count", `origin/${branch}..${branch}`]).out.trim() === "0";
|
|
1763
2725
|
if (onOrigin) {
|
|
@@ -1767,8 +2729,96 @@ async function retireArea(ctx, o) {
|
|
|
1767
2729
|
}
|
|
1768
2730
|
ctx.registry.removeEfforts((e) => e.project === m.project && e.area === o.area);
|
|
1769
2731
|
ctx.registry.clearNotices(m.project, o.area);
|
|
2732
|
+
ctx.registry.clearNoticesFrom(m.project, allAreaBranches);
|
|
1770
2733
|
return { archived, removed: worktree, branchesDeleted, branchesKept };
|
|
1771
2734
|
}
|
|
2735
|
+
function effortAt(ctx, cwd) {
|
|
2736
|
+
const top = new Git(cwd).tryRun(["rev-parse", "--show-toplevel"]);
|
|
2737
|
+
if (top.code !== 0) return void 0;
|
|
2738
|
+
const root = realpathOr2(top.out.trim());
|
|
2739
|
+
return ctx.registry.efforts({ project: ctx.manifest.project }).filter((e) => e.state === "active" || e.state === "starting" || e.state === "parked").sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)).find((e) => realpathOr2(e.worktree) === root);
|
|
2740
|
+
}
|
|
2741
|
+
async function prEffort(ctx, o) {
|
|
2742
|
+
const m = ctx.manifest;
|
|
2743
|
+
assertPrittyVersion(ctx.pritty);
|
|
2744
|
+
const effort = o.area ? ctx.registry.openEffortFor(m.project, o.area, o.task) ?? ctx.registry.efforts({ project: m.project, area: o.area, state: "parked" }).filter((e) => (e.task ?? void 0) === (o.task ?? void 0)).sort((a, b) => b.started.localeCompare(a.started))[0] : effortAt(ctx, o.cwd ?? process.cwd());
|
|
2745
|
+
if (!effort) {
|
|
2746
|
+
throw new WorkkitError(
|
|
2747
|
+
"EFFORT_NOT_FOUND",
|
|
2748
|
+
o.area ? `no open or parked effort on ${effortLabel2(o.area, o.task)}` : `${o.cwd ?? process.cwd()} is not inside an effort's worktree; pass the area`,
|
|
2749
|
+
{ area: o.area, task: o.task }
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
const label = effortLabel2(effort.area, effort.task);
|
|
2753
|
+
const g = new Git(effort.worktree);
|
|
2754
|
+
if (g.isDirty()) {
|
|
2755
|
+
throw new WorkkitError(
|
|
2756
|
+
"DIRTY_WORKTREE",
|
|
2757
|
+
`${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${label}) and push`,
|
|
2758
|
+
{ worktree: effort.worktree }
|
|
2759
|
+
);
|
|
2760
|
+
}
|
|
2761
|
+
fetchBaseOnce(ctx);
|
|
2762
|
+
const warnings = [];
|
|
2763
|
+
if (!g.hasUpstream() || g.unpushedCount(m.baseBranch) > 0) {
|
|
2764
|
+
try {
|
|
2765
|
+
g.push(effort.branch);
|
|
2766
|
+
} catch (err) {
|
|
2767
|
+
throw new WorkkitError(
|
|
2768
|
+
"PUSH_REJECTED",
|
|
2769
|
+
`push of ${effort.branch} was rejected (${err instanceof Error ? err.message : String(err)}); merge origin/${effort.branch} into the worktree and run \`work pr\` again`,
|
|
2770
|
+
{ branch: effort.branch, worktree: effort.worktree }
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
const behind = g.behindCount(m.baseBranch);
|
|
2775
|
+
if (behind > BEHIND_LIMIT) {
|
|
2776
|
+
warnings.push(
|
|
2777
|
+
`${effort.branch} is ${behind} commits behind ${m.baseBranch}; consider merging origin/${m.baseBranch} before review`
|
|
2778
|
+
);
|
|
2779
|
+
}
|
|
2780
|
+
const given = o.title !== void 0 || o.body !== void 0 || o.bodyFile !== void 0;
|
|
2781
|
+
const store = (pr2) => {
|
|
2782
|
+
const updated = ctx.registry.updateEffort(effort.id, { pr: pr2, lastActivity: now() });
|
|
2783
|
+
touchEffort(ctx, updated);
|
|
2784
|
+
return updated;
|
|
2785
|
+
};
|
|
2786
|
+
if (effort.pr) {
|
|
2787
|
+
const open = await ctx.pritty.check({ cwd: effort.worktree, base: m.baseBranch });
|
|
2788
|
+
if (open) {
|
|
2789
|
+
if (!given) {
|
|
2790
|
+
return { effort: store(open), pr: open, created: false, behindBase: behind, warnings };
|
|
2791
|
+
}
|
|
2792
|
+
const updated = await ctx.pritty.pr({
|
|
2793
|
+
cwd: effort.worktree,
|
|
2794
|
+
base: m.baseBranch,
|
|
2795
|
+
draft: o.draft,
|
|
2796
|
+
title: o.title,
|
|
2797
|
+
body: o.body,
|
|
2798
|
+
bodyFile: o.bodyFile
|
|
2799
|
+
});
|
|
2800
|
+
const pr2 = { number: updated.number, url: updated.url, draft: updated.draft };
|
|
2801
|
+
return { effort: store(pr2), pr: pr2, created: false, behindBase: behind, warnings };
|
|
2802
|
+
}
|
|
2803
|
+
if (!o.force) {
|
|
2804
|
+
throw new WorkkitError(
|
|
2805
|
+
"PR_CLOSED",
|
|
2806
|
+
`PR #${effort.pr.number} for ${label} is no longer open (${effort.pr.url}); pass --force to open a new one`,
|
|
2807
|
+
{ pr: effort.pr }
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
const res = await ctx.pritty.pr({
|
|
2812
|
+
cwd: effort.worktree,
|
|
2813
|
+
base: m.baseBranch,
|
|
2814
|
+
draft: o.draft,
|
|
2815
|
+
title: o.title,
|
|
2816
|
+
body: o.body,
|
|
2817
|
+
bodyFile: o.bodyFile
|
|
2818
|
+
});
|
|
2819
|
+
const pr = { number: res.number, url: res.url, draft: res.draft };
|
|
2820
|
+
return { effort: store(pr), pr, created: res.created, behindBase: behind, warnings };
|
|
2821
|
+
}
|
|
1772
2822
|
var BEHIND_LIMIT = 20;
|
|
1773
2823
|
var NOTICE_STALE_MS = 7 * 864e5;
|
|
1774
2824
|
var ARCHIVE_LIMIT_BYTES = 5 * 1024 ** 3;
|
|
@@ -1786,15 +2836,16 @@ function registeredWorktrees(ctx) {
|
|
|
1786
2836
|
}
|
|
1787
2837
|
}
|
|
1788
2838
|
if (current) out.push(current);
|
|
1789
|
-
const wtRoot =
|
|
1790
|
-
return out.filter((w) =>
|
|
2839
|
+
const wtRoot = join8(realpathOr2(ctx.repoRoot), ctx.manifest.worktreeRoot);
|
|
2840
|
+
return out.filter((w) => existsSync7(w.path) && realpathOr2(w.path).startsWith(wtRoot));
|
|
1791
2841
|
}
|
|
1792
2842
|
async function auditProject(ctx) {
|
|
1793
2843
|
const m = ctx.manifest;
|
|
2844
|
+
fetchBaseOnce(ctx);
|
|
1794
2845
|
const findings = [];
|
|
1795
2846
|
const types = m.branch.types.join("|");
|
|
1796
2847
|
const areaOf = (branch) => {
|
|
1797
|
-
const match = new RegExp(`^(${types})/(
|
|
2848
|
+
const match = new RegExp(`^(${types})/(.+?)(?:--([a-z0-9][a-z0-9_-]*))?$`).exec(branch);
|
|
1798
2849
|
return match && m.areas[match[2]] ? match[2] : null;
|
|
1799
2850
|
};
|
|
1800
2851
|
for (const w of registeredWorktrees(ctx)) {
|
|
@@ -1843,7 +2894,7 @@ async function auditProject(ctx) {
|
|
|
1843
2894
|
findings.push({ kind: "notice-stale", area, detail: `${n.text} (since ${n.at})` });
|
|
1844
2895
|
}
|
|
1845
2896
|
}
|
|
1846
|
-
const bytes = dirSize(
|
|
2897
|
+
const bytes = dirSize(join8(ctx.repoRoot, ARCHIVE_DIR));
|
|
1847
2898
|
if (bytes > ARCHIVE_LIMIT_BYTES)
|
|
1848
2899
|
findings.push({
|
|
1849
2900
|
kind: "archive-size",
|
|
@@ -1893,51 +2944,70 @@ function registerAudit(program2) {
|
|
|
1893
2944
|
|
|
1894
2945
|
// src/commands/env.ts
|
|
1895
2946
|
function registerEnv(program2) {
|
|
1896
|
-
program2.command("env").description("The slot-derived variables of an area's open effort, or of a slot number alone").option("--area <area>", "the area whose open effort to read").option(
|
|
2947
|
+
program2.command("env").description("The slot-derived variables of an area's open effort, or of a slot number alone").option("--area <area>", "the area whose open effort to read").option("--task <slug>", "intra-area task slug").option(
|
|
1897
2948
|
"--slot <n>",
|
|
1898
2949
|
"compute the variables for this slot from the manifest alone",
|
|
1899
2950
|
(v) => Number(v)
|
|
1900
|
-
).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print as JSON (default: KEY=value lines)").option("--sh", "print KEY=value lines (the default)").action(
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
2951
|
+
).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print as JSON (default: KEY=value lines)").option("--sh", "print KEY=value lines (the default)").action(
|
|
2952
|
+
async (o) => {
|
|
2953
|
+
try {
|
|
2954
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
|
|
2955
|
+
let vars;
|
|
2956
|
+
if (o.area) vars = effortEnv(ctx, o.area, o.task);
|
|
2957
|
+
else if (o.slot !== void 0 && Number.isInteger(o.slot))
|
|
2958
|
+
vars = slotEnv(ctx.manifest, o.slot);
|
|
2959
|
+
else throw new WorkkitError("EFFORT_NOT_FOUND", "pass --area <area> or --slot <n>");
|
|
2960
|
+
if (o.json) printJson(vars);
|
|
2961
|
+
else for (const [k, v] of Object.entries(vars)) console.log(`${k}=${v}`);
|
|
2962
|
+
} catch (err) {
|
|
2963
|
+
fail(err, Boolean(o.json));
|
|
2964
|
+
}
|
|
1912
2965
|
}
|
|
1913
|
-
|
|
2966
|
+
);
|
|
1914
2967
|
}
|
|
1915
2968
|
|
|
1916
2969
|
// src/commands/finish.ts
|
|
1917
2970
|
import chalk4 from "chalk";
|
|
1918
2971
|
function registerFinish(program2) {
|
|
1919
2972
|
program2.command("finish").description(
|
|
1920
|
-
"Stop the effort's runner, release its leases, push its branch;
|
|
1921
|
-
).requiredOption("--area <area>", "the area whose open effort to finish").option("--park", "commit uncommitted changes as wip(<area>) instead of refusing").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2973
|
+
"Stop the effort's runner, release its leases, push its branch (or merge --into-area); area worktrees stay, task worktrees are cleaned up"
|
|
2974
|
+
).requiredOption("--area <area>", "the area whose open effort to finish").option("--task <slug>", "finish an intra-area task sub-effort").option("--park", "commit uncommitted changes as wip(<area>) instead of refusing").option("--into-area", "merge task branch into <type>/<area> instead of pushing to origin").option("--force", "stop and finish even if other joined participants remain").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
2975
|
+
async (o) => {
|
|
2976
|
+
try {
|
|
2977
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2978
|
+
const r = await finishEffort(ctx, {
|
|
2979
|
+
area: o.area,
|
|
2980
|
+
task: o.task,
|
|
2981
|
+
park: o.park,
|
|
2982
|
+
intoArea: o.intoArea,
|
|
2983
|
+
force: o.force
|
|
2984
|
+
});
|
|
2985
|
+
if (o.json) printJson(r);
|
|
2986
|
+
else {
|
|
2987
|
+
const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
|
|
2988
|
+
if (r.left) {
|
|
2989
|
+
console.log(`${chalk4.bold(label)}: left shared effort (other participants remain)`);
|
|
2990
|
+
} else if (r.mergedIntoArea) {
|
|
2991
|
+
console.log(
|
|
2992
|
+
`${chalk4.bold(label)} finished: ${r.effort.branch} merged into ${r.effort.type}/${r.effort.area}`
|
|
2993
|
+
);
|
|
2994
|
+
} else {
|
|
2995
|
+
console.log(
|
|
2996
|
+
`${chalk4.bold(label)} finished: ${r.effort.branch} pushed${r.parked ? " (uncommitted changes parked as wip)" : ""}`
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
if (r.notices) console.log(` ${r.notices} area(s) told about commons changes`);
|
|
3000
|
+
}
|
|
3001
|
+
} catch (err) {
|
|
3002
|
+
fail(err, Boolean(o.json));
|
|
1931
3003
|
}
|
|
1932
|
-
} catch (err) {
|
|
1933
|
-
fail(err, Boolean(o.json));
|
|
1934
3004
|
}
|
|
1935
|
-
|
|
3005
|
+
);
|
|
1936
3006
|
}
|
|
1937
3007
|
|
|
1938
3008
|
// src/commands/hook.ts
|
|
1939
|
-
import { readFileSync as
|
|
1940
|
-
import { resolve } from "path";
|
|
3009
|
+
import { readFileSync as readFileSync6, realpathSync as realpathSync5 } from "fs";
|
|
3010
|
+
import { resolve as resolve2 } from "path";
|
|
1941
3011
|
import chalk5 from "chalk";
|
|
1942
3012
|
var ZEROS = /^0+$/;
|
|
1943
3013
|
async function hookContext(cwd) {
|
|
@@ -1956,7 +3026,7 @@ async function runPreCommit(cwd) {
|
|
|
1956
3026
|
if (!c) return 0;
|
|
1957
3027
|
const root = c.git.root();
|
|
1958
3028
|
touchByWorktree(c.registry, c.manifest, root);
|
|
1959
|
-
const { refusals } = checkCommit({
|
|
3029
|
+
const { refusals, warnings } = checkCommit({
|
|
1960
3030
|
m: c.manifest,
|
|
1961
3031
|
registry: c.registry,
|
|
1962
3032
|
git: c.git,
|
|
@@ -1966,6 +3036,7 @@ async function runPreCommit(cwd) {
|
|
|
1966
3036
|
ownerSession: currentOwner().session,
|
|
1967
3037
|
checkoutRoot: root
|
|
1968
3038
|
});
|
|
3039
|
+
for (const w of warnings) console.error(chalk5.yellow(`work: ${w}`));
|
|
1969
3040
|
for (const r of refusals) console.error(chalk5.red(`work: ${r}`));
|
|
1970
3041
|
return refusals.length ? 2 : 0;
|
|
1971
3042
|
}
|
|
@@ -2033,7 +3104,7 @@ function registerHook(program2) {
|
|
|
2033
3104
|
"Refuse pushes to protected branches; warn when paths leave the effort scope or touch commons"
|
|
2034
3105
|
).argument("[remote]", "passed by git, unused").argument("[url]", "passed by git, unused").action(async () => {
|
|
2035
3106
|
try {
|
|
2036
|
-
const stdin = process.stdin.isTTY ? "" :
|
|
3107
|
+
const stdin = process.stdin.isTTY ? "" : readFileSync6(0, "utf8");
|
|
2037
3108
|
process.exit(await runPrePush(process.cwd(), stdin));
|
|
2038
3109
|
} catch (err) {
|
|
2039
3110
|
fail(err, false);
|
|
@@ -2044,7 +3115,7 @@ function registerHook(program2) {
|
|
|
2044
3115
|
"Write .helmsmith/hooks and point this clone's core.hooksPath at them (for a repo whose manifest already exists)"
|
|
2045
3116
|
).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
|
|
2046
3117
|
try {
|
|
2047
|
-
const repoRoot = o.projectRoot ?
|
|
3118
|
+
const repoRoot = o.projectRoot ? resolve2(o.projectRoot) : new Git(process.cwd()).mainRoot();
|
|
2048
3119
|
const manifest = await loadManifest(repoRoot);
|
|
2049
3120
|
const out = installHooks(repoRoot, { protectedBranches: manifest.protectedBranches });
|
|
2050
3121
|
if (o.json) printJson(out);
|
|
@@ -2056,8 +3127,8 @@ function registerHook(program2) {
|
|
|
2056
3127
|
}
|
|
2057
3128
|
|
|
2058
3129
|
// src/commands/init.ts
|
|
2059
|
-
import { existsSync as
|
|
2060
|
-
import { join as
|
|
3130
|
+
import { existsSync as existsSync8, writeFileSync as writeFileSync6 } from "fs";
|
|
3131
|
+
import { join as join9, resolve as resolve3 } from "path";
|
|
2061
3132
|
var STORYBOOK_BASE = 6100;
|
|
2062
3133
|
function defaultService(name) {
|
|
2063
3134
|
const PORT = `\${PORT_${name.toUpperCase()}}`;
|
|
@@ -2121,9 +3192,9 @@ var HEADER = `// biome-ignore-all lint/suspicious/noTemplateCurlyInString: \${PO
|
|
|
2121
3192
|
// each with its backend module) and the commons. Nothing here is read by the application.
|
|
2122
3193
|
`;
|
|
2123
3194
|
function runInit(o) {
|
|
2124
|
-
const repoRoot =
|
|
3195
|
+
const repoRoot = resolve3(o.repoRoot);
|
|
2125
3196
|
for (const name of MANIFEST_FILES) {
|
|
2126
|
-
if (
|
|
3197
|
+
if (existsSync8(join9(repoRoot, name)) && !o.force) {
|
|
2127
3198
|
throw new WorkkitError(
|
|
2128
3199
|
"MANIFEST_INVALID",
|
|
2129
3200
|
`${name} already exists in ${repoRoot}; pass --force to overwrite`
|
|
@@ -2131,8 +3202,8 @@ function runInit(o) {
|
|
|
2131
3202
|
}
|
|
2132
3203
|
}
|
|
2133
3204
|
const manifest = buildStarterManifest(o);
|
|
2134
|
-
const manifestPath =
|
|
2135
|
-
|
|
3205
|
+
const manifestPath = join9(repoRoot, "helmsmith.config.mjs");
|
|
3206
|
+
writeFileSync6(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
|
|
2136
3207
|
`);
|
|
2137
3208
|
claimRanges(Registry.open(o.home), validateManifest(manifest), repoRoot);
|
|
2138
3209
|
const hooks = o.hooks ? installHooks(repoRoot, { protectedBranches: [o.baseBranch] }).files : [];
|
|
@@ -2162,7 +3233,7 @@ function registerInit(program2) {
|
|
|
2162
3233
|
).option("--storybook-host <dir>", "the package whose .storybook storybrokr boots from").option("--worktree-root <dir>", "where area worktrees live (default .claude/worktrees)").option("--repo-root <dir>", "repository root (default: the main checkout of the cwd)").option("--no-hooks", "do not install the git hooks").option("--force", "overwrite an existing manifest").option("--json", "print the result as JSON").action(
|
|
2163
3234
|
(o) => {
|
|
2164
3235
|
try {
|
|
2165
|
-
const repoRoot = o.repoRoot ?
|
|
3236
|
+
const repoRoot = o.repoRoot ? resolve3(o.repoRoot) : defaultRepoRoot();
|
|
2166
3237
|
if (o.service.length === 0)
|
|
2167
3238
|
throw new WorkkitError(
|
|
2168
3239
|
"MANIFEST_INVALID",
|
|
@@ -2200,21 +3271,21 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
2200
3271
|
import { z as z2 } from "zod";
|
|
2201
3272
|
|
|
2202
3273
|
// src/version.ts
|
|
2203
|
-
import { existsSync as
|
|
2204
|
-
import { dirname as
|
|
3274
|
+
import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
|
|
3275
|
+
import { dirname as dirname4, join as join10 } from "path";
|
|
2205
3276
|
import { fileURLToPath } from "url";
|
|
2206
3277
|
function findPackageJson(startDir) {
|
|
2207
3278
|
let dir = startDir;
|
|
2208
3279
|
for (; ; ) {
|
|
2209
|
-
const candidate =
|
|
2210
|
-
if (
|
|
2211
|
-
const parent =
|
|
3280
|
+
const candidate = join10(dir, "package.json");
|
|
3281
|
+
if (existsSync9(candidate)) return candidate;
|
|
3282
|
+
const parent = dirname4(dir);
|
|
2212
3283
|
if (parent === dir) throw new Error(`package.json not found above ${startDir}`);
|
|
2213
3284
|
dir = parent;
|
|
2214
3285
|
}
|
|
2215
3286
|
}
|
|
2216
|
-
var pkgFile = findPackageJson(
|
|
2217
|
-
var VERSION = JSON.parse(
|
|
3287
|
+
var pkgFile = findPackageJson(dirname4(fileURLToPath(import.meta.url)));
|
|
3288
|
+
var VERSION = JSON.parse(readFileSync7(pkgFile, "utf8")).version;
|
|
2218
3289
|
|
|
2219
3290
|
// src/mcp/server.ts
|
|
2220
3291
|
var ok = (value) => ({
|
|
@@ -2236,64 +3307,143 @@ function buildMcpServer(ctxFactory) {
|
|
|
2236
3307
|
server.registerTool(
|
|
2237
3308
|
"work_start",
|
|
2238
3309
|
{
|
|
2239
|
-
description: "Start an effort on an area: its worktree on <type>/<area
|
|
3310
|
+
description: "Start an effort on an area (or intra-area task): its worktree on <type>/<area> (or <type>/<area>--<task>), synced from the base branch, refreshed, leased (a slot for fullstack/backend, a storybrokr instance for frontend with a story) and running. Refuses with EFFORT_OPEN (another effort is open; join=true to share it), SCOPE_CONFLICT, SYNC_CONFLICT (resolve in the worktree, rerun with sync=false).",
|
|
2240
3311
|
inputSchema: {
|
|
2241
3312
|
area: z2.string().describe("a leaf area from helmsmith.config (e.g. org/applications)"),
|
|
3313
|
+
task: z2.string().optional().describe(
|
|
3314
|
+
"optional intra-area task slug for parallel sub-efforts (<type>/<area>--<task>)"
|
|
3315
|
+
),
|
|
2242
3316
|
type: z2.string().describe("feature | chore | fix"),
|
|
2243
|
-
mode: z2.enum(["frontend", "fullstack"]),
|
|
3317
|
+
mode: z2.enum(["frontend", "backend", "fullstack"]),
|
|
3318
|
+
scopes: z2.array(z2.string()).optional().describe("optional narrowed scope globs for a task sub-effort"),
|
|
2244
3319
|
story: z2.string().optional().describe(
|
|
2245
3320
|
"frontend: story file or component folder for storybrokr, relative to its host"
|
|
2246
3321
|
),
|
|
2247
3322
|
join: z2.boolean().optional().describe("work in the already-open effort instead of refusing"),
|
|
2248
|
-
sync: z2.boolean().optional().describe("fetch and merge the base branch first (default true)")
|
|
3323
|
+
sync: z2.boolean().optional().describe("fetch and merge the base branch first (default true)"),
|
|
3324
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3325
|
+
}
|
|
3326
|
+
},
|
|
3327
|
+
async ({ area, task, type, mode, scopes, story, join: join11, sync, projectRoot }) => guard(
|
|
3328
|
+
async () => startEffort(await ctxFactory(projectRoot), {
|
|
3329
|
+
area,
|
|
3330
|
+
task,
|
|
3331
|
+
type,
|
|
3332
|
+
mode,
|
|
3333
|
+
scopes,
|
|
3334
|
+
story,
|
|
3335
|
+
join: join11,
|
|
3336
|
+
sync
|
|
3337
|
+
})
|
|
3338
|
+
)
|
|
3339
|
+
);
|
|
3340
|
+
server.registerTool(
|
|
3341
|
+
"work_promote",
|
|
3342
|
+
{
|
|
3343
|
+
description: "Promote an open effort in place to backend or fullstack mode, claiming a slot if needed and starting only newly required services.",
|
|
3344
|
+
inputSchema: {
|
|
3345
|
+
area: z2.string().describe("the area of the open effort"),
|
|
3346
|
+
task: z2.string().optional().describe("optional intra-area task slug"),
|
|
3347
|
+
mode: z2.enum(["backend", "fullstack"]).describe("target mode to promote into"),
|
|
3348
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
2249
3349
|
}
|
|
2250
3350
|
},
|
|
2251
|
-
async ({ area,
|
|
3351
|
+
async ({ area, task, mode, projectRoot }) => guard(async () => promoteEffort(await ctxFactory(projectRoot), { area, task, mode }))
|
|
3352
|
+
);
|
|
3353
|
+
server.registerTool(
|
|
3354
|
+
"work_pr",
|
|
3355
|
+
{
|
|
3356
|
+
description: "Open the effort's pull request through pritty: pushes the branch first, reuses a stored PR that is still open (updating its title/body when given), refuses with PR_CLOSED when it was merged or closed unless force, and stores { number, url, draft } on the effort. Pass title and body: the agent writes the words, pritty's AI path is only the fallback.",
|
|
3357
|
+
inputSchema: {
|
|
3358
|
+
area: z2.string().optional().describe("the area of the effort (default: the effort of the cwd worktree)"),
|
|
3359
|
+
task: z2.string().optional().describe("optional intra-area task slug"),
|
|
3360
|
+
draft: z2.boolean().optional().describe("open a draft PR"),
|
|
3361
|
+
title: z2.string().optional().describe("PR title"),
|
|
3362
|
+
// body text only: a file path here would let any MCP caller have pritty read an
|
|
3363
|
+
// arbitrary file and publish it as a PR body; read the file yourself and pass the text
|
|
3364
|
+
body: z2.string().optional().describe("PR body text (read your body file and pass its text)"),
|
|
3365
|
+
force: z2.boolean().optional().describe("open a new PR when the stored one was closed"),
|
|
3366
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3367
|
+
}
|
|
3368
|
+
},
|
|
3369
|
+
async ({ area, task, draft, title, body, force, projectRoot }) => guard(
|
|
3370
|
+
async () => prEffort(await ctxFactory(projectRoot), {
|
|
3371
|
+
area,
|
|
3372
|
+
task,
|
|
3373
|
+
cwd: process.cwd(),
|
|
3374
|
+
draft,
|
|
3375
|
+
title,
|
|
3376
|
+
body,
|
|
3377
|
+
force
|
|
3378
|
+
})
|
|
3379
|
+
)
|
|
2252
3380
|
);
|
|
2253
3381
|
server.registerTool(
|
|
2254
3382
|
"work_status",
|
|
2255
3383
|
{
|
|
2256
3384
|
description: "Per area: the open effort, its leases, how far behind the base branch it is, pending notices; plus every lease of the project.",
|
|
2257
|
-
inputSchema: {
|
|
3385
|
+
inputSchema: {
|
|
3386
|
+
area: z2.string().optional(),
|
|
3387
|
+
task: z2.string().optional(),
|
|
3388
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3389
|
+
}
|
|
2258
3390
|
},
|
|
2259
|
-
async ({ area }) => guard(async () => statusFor(await ctxFactory(), area))
|
|
3391
|
+
async ({ area, task, projectRoot }) => guard(async () => statusFor(await ctxFactory(projectRoot), area, task))
|
|
2260
3392
|
);
|
|
2261
3393
|
server.registerTool(
|
|
2262
3394
|
"work_finish",
|
|
2263
3395
|
{
|
|
2264
|
-
description: "Stop the effort's runner, release its leases, push its branch
|
|
2265
|
-
inputSchema: {
|
|
3396
|
+
description: "Stop the effort's runner, release its leases, push its branch (or merge --into-area for task sub-efforts), leave commons notices. Refuses DIRTY_WORKTREE unless park=true (commits as wip).",
|
|
3397
|
+
inputSchema: {
|
|
3398
|
+
area: z2.string(),
|
|
3399
|
+
task: z2.string().optional().describe("optional intra-area task slug"),
|
|
3400
|
+
park: z2.boolean().optional(),
|
|
3401
|
+
intoArea: z2.boolean().optional().describe("merge task branch into <type>/<area> instead of pushing to origin"),
|
|
3402
|
+
force: z2.boolean().optional().describe("stop and finish even if other joined participants remain"),
|
|
3403
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3404
|
+
}
|
|
2266
3405
|
},
|
|
2267
|
-
async ({ area, park
|
|
3406
|
+
async ({ area, task, park, intoArea, force, projectRoot }) => guard(
|
|
3407
|
+
async () => finishEffort(await ctxFactory(projectRoot), { area, task, park, intoArea, force })
|
|
3408
|
+
)
|
|
2268
3409
|
);
|
|
2269
3410
|
server.registerTool(
|
|
2270
3411
|
"work_reap",
|
|
2271
3412
|
{
|
|
2272
3413
|
description: "Expire idle leases: stop only what they started, release them, park their efforts. dryRun lists without touching.",
|
|
2273
|
-
inputSchema: {
|
|
3414
|
+
inputSchema: {
|
|
3415
|
+
dryRun: z2.boolean().optional(),
|
|
3416
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3417
|
+
}
|
|
2274
3418
|
},
|
|
2275
|
-
async ({ dryRun }) => guard(async () => reapLeases(await ctxFactory(), { dryRun }))
|
|
3419
|
+
async ({ dryRun, projectRoot }) => guard(async () => reapLeases(await ctxFactory(projectRoot), { dryRun }))
|
|
2276
3420
|
);
|
|
2277
3421
|
server.registerTool(
|
|
2278
3422
|
"work_audit",
|
|
2279
3423
|
{
|
|
2280
3424
|
description: "Findings: dirty or unpushed area worktrees, foreign worktrees, aging leases, drift from the base branch, stale notices, archive size.",
|
|
2281
|
-
inputSchema: {
|
|
3425
|
+
inputSchema: {
|
|
3426
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3427
|
+
}
|
|
2282
3428
|
},
|
|
2283
|
-
async () => guard(async () => auditProject(await ctxFactory()))
|
|
3429
|
+
async ({ projectRoot }) => guard(async () => auditProject(await ctxFactory(projectRoot)))
|
|
2284
3430
|
);
|
|
2285
3431
|
server.registerTool(
|
|
2286
3432
|
"work_env",
|
|
2287
3433
|
{
|
|
2288
3434
|
description: "The slot-derived variables of an area's open effort (PORT_*, SLOT, LOG_DIR, WORKTREE, BRANCH, MODE).",
|
|
2289
|
-
inputSchema: {
|
|
3435
|
+
inputSchema: {
|
|
3436
|
+
area: z2.string(),
|
|
3437
|
+
task: z2.string().optional(),
|
|
3438
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3439
|
+
}
|
|
2290
3440
|
},
|
|
2291
|
-
async ({ area }) => guard(async () => effortEnv(await ctxFactory(), area))
|
|
3441
|
+
async ({ area, task, projectRoot }) => guard(async () => effortEnv(await ctxFactory(projectRoot), area, task))
|
|
2292
3442
|
);
|
|
2293
3443
|
return server;
|
|
2294
3444
|
}
|
|
2295
3445
|
async function runStdio() {
|
|
2296
|
-
const server = buildMcpServer(() => buildCtx({ quiet: true }));
|
|
3446
|
+
const server = buildMcpServer((projectRoot) => buildCtx({ projectRoot, quiet: true }));
|
|
2297
3447
|
await server.connect(new StdioServerTransport());
|
|
2298
3448
|
}
|
|
2299
3449
|
|
|
@@ -2304,8 +3454,68 @@ function registerMcp(program2) {
|
|
|
2304
3454
|
});
|
|
2305
3455
|
}
|
|
2306
3456
|
|
|
2307
|
-
// src/commands/
|
|
3457
|
+
// src/commands/pr.ts
|
|
2308
3458
|
import chalk6 from "chalk";
|
|
3459
|
+
function registerPr(program2) {
|
|
3460
|
+
program2.command("pr [area]").description(
|
|
3461
|
+
"Open the effort's pull request through pritty (push first, reuse an open PR, store it on the effort). No area: the effort of the cwd's worktree."
|
|
3462
|
+
).option("--task <slug>", "intra-area task slug").option("--draft", "open a draft PR").option("--title <title>", "PR title (with --body or --body-file: no AI involved)").option("--body <text>", "PR body text").option("--body-file <path>", "PR body from a file").option("--force", "open a new PR when the stored one was merged or closed").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
3463
|
+
async (area, o) => {
|
|
3464
|
+
try {
|
|
3465
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
3466
|
+
const r = await prEffort(ctx, {
|
|
3467
|
+
area,
|
|
3468
|
+
task: o.task,
|
|
3469
|
+
cwd: process.cwd(),
|
|
3470
|
+
draft: o.draft,
|
|
3471
|
+
title: o.title,
|
|
3472
|
+
body: o.body,
|
|
3473
|
+
bodyFile: o.bodyFile,
|
|
3474
|
+
force: o.force
|
|
3475
|
+
});
|
|
3476
|
+
if (o.json) printJson(r);
|
|
3477
|
+
else {
|
|
3478
|
+
for (const w of r.warnings) console.error(chalk6.yellow(w));
|
|
3479
|
+
console.log(
|
|
3480
|
+
`${r.created ? "opened" : "open"} PR #${r.pr.number}${r.pr.draft ? " (draft)" : ""} for ${r.effort.branch}: ${r.pr.url}`
|
|
3481
|
+
);
|
|
3482
|
+
}
|
|
3483
|
+
} catch (err) {
|
|
3484
|
+
fail(err, Boolean(o.json));
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
);
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
// src/commands/promote.ts
|
|
3491
|
+
import chalk7 from "chalk";
|
|
3492
|
+
function registerPromote(program2) {
|
|
3493
|
+
program2.command("promote").description("Promote an open effort in place to backend or fullstack mode").requiredOption("--area <area>", "the area of the open effort").option("--task <slug>", "intra-area task slug if promoting a task sub-effort").requiredOption("--mode <mode>", "target mode: backend | fullstack").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
3494
|
+
async (o) => {
|
|
3495
|
+
try {
|
|
3496
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
3497
|
+
const r = await promoteEffort(ctx, {
|
|
3498
|
+
area: o.area,
|
|
3499
|
+
task: o.task,
|
|
3500
|
+
mode: o.mode
|
|
3501
|
+
});
|
|
3502
|
+
if (o.json) printJson(r);
|
|
3503
|
+
else {
|
|
3504
|
+
const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
|
|
3505
|
+
console.log(
|
|
3506
|
+
`${chalk7.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
|
|
3507
|
+
);
|
|
3508
|
+
for (const w of r.warnings) warn(w);
|
|
3509
|
+
}
|
|
3510
|
+
} catch (err) {
|
|
3511
|
+
fail(err, Boolean(o.json));
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
);
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
// src/commands/reap.ts
|
|
3518
|
+
import chalk8 from "chalk";
|
|
2309
3519
|
function registerReap(program2) {
|
|
2310
3520
|
program2.command("reap").description(
|
|
2311
3521
|
"Expire idle leases: stop only what they started, release them, park their efforts"
|
|
@@ -2314,7 +3524,7 @@ function registerReap(program2) {
|
|
|
2314
3524
|
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2315
3525
|
const r = await reapLeases(ctx, { dryRun: o.dryRun });
|
|
2316
3526
|
if (o.json) printJson(r);
|
|
2317
|
-
else if (r.reaped.length === 0) console.log(
|
|
3527
|
+
else if (r.reaped.length === 0) console.log(chalk8.gray("nothing to reap"));
|
|
2318
3528
|
else
|
|
2319
3529
|
for (const l of r.reaped)
|
|
2320
3530
|
console.log(
|
|
@@ -2327,7 +3537,7 @@ function registerReap(program2) {
|
|
|
2327
3537
|
}
|
|
2328
3538
|
|
|
2329
3539
|
// src/commands/retire.ts
|
|
2330
|
-
import
|
|
3540
|
+
import chalk9 from "chalk";
|
|
2331
3541
|
function registerRetire(program2) {
|
|
2332
3542
|
program2.command("retire").description(
|
|
2333
3543
|
"Archive an area's evidence, remove its worktree and its local branches that are on origin (the only destructive verb)"
|
|
@@ -2337,11 +3547,11 @@ function registerRetire(program2) {
|
|
|
2337
3547
|
const r = await retireArea(ctx, { area: o.area });
|
|
2338
3548
|
if (o.json) printJson(r);
|
|
2339
3549
|
else {
|
|
2340
|
-
console.log(`${
|
|
3550
|
+
console.log(`${chalk9.bold(o.area)} retired: removed ${r.removed}`);
|
|
2341
3551
|
for (const a of r.archived) console.log(` archived ${a}`);
|
|
2342
3552
|
for (const b of r.branchesDeleted) console.log(` deleted local branch ${b}`);
|
|
2343
3553
|
for (const b of r.branchesKept)
|
|
2344
|
-
console.log(
|
|
3554
|
+
console.log(chalk9.yellow(` kept ${b}: not fully on origin`));
|
|
2345
3555
|
}
|
|
2346
3556
|
} catch (err) {
|
|
2347
3557
|
fail(err, Boolean(o.json));
|
|
@@ -2350,11 +3560,12 @@ function registerRetire(program2) {
|
|
|
2350
3560
|
}
|
|
2351
3561
|
|
|
2352
3562
|
// src/commands/start.ts
|
|
2353
|
-
import
|
|
3563
|
+
import chalk10 from "chalk";
|
|
2354
3564
|
function printStart(r) {
|
|
2355
3565
|
const e = r.effort;
|
|
3566
|
+
const label = e.task ? `${e.area}--${e.task}` : e.area;
|
|
2356
3567
|
console.log(
|
|
2357
|
-
`${
|
|
3568
|
+
`${chalk10.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk10.yellow(" (joined)") : ""}`
|
|
2358
3569
|
);
|
|
2359
3570
|
console.log(` worktree ${e.worktree}`);
|
|
2360
3571
|
console.log(
|
|
@@ -2365,12 +3576,12 @@ function printStart(r) {
|
|
|
2365
3576
|
console.log(` lease ${l.kind} ${l.value}${ports} expires ${l.expires}`);
|
|
2366
3577
|
}
|
|
2367
3578
|
for (const w of r.warnings) warn(w);
|
|
2368
|
-
for (const n of r.notices) console.log(
|
|
3579
|
+
for (const n of r.notices) console.log(chalk10.yellow(` notice ${n.text} (${n.at})`));
|
|
2369
3580
|
}
|
|
2370
3581
|
function registerStart(program2) {
|
|
2371
3582
|
program2.command("start").description(
|
|
2372
|
-
"Start an effort: the area's worktree on <type>/<area
|
|
2373
|
-
).requiredOption("--area <area>", "a leaf area from helmsmith.config (e.g. org/applications)").requiredOption("--type <type>", "feature | chore | fix (as the manifest allows)").requiredOption("--mode <mode>", "fullstack (a slot) | frontend (storybrokr)").option(
|
|
3583
|
+
"Start an effort: the area's worktree on <type>/<area> (or <type>/<area>--<task>), synced from the base branch, leased and running"
|
|
3584
|
+
).requiredOption("--area <area>", "a leaf area from helmsmith.config (e.g. org/applications)").option("--task <slug>", "intra-area task slug for parallel sub-efforts (<type>/<area>--<slug>)").requiredOption("--type <type>", "feature | chore | fix (as the manifest allows)").requiredOption("--mode <mode>", "fullstack | backend (a slot) | frontend (storybrokr)").option("--scope <glob...>", "narrow exclusive scope globs for this task sub-effort").option(
|
|
2374
3585
|
"--story <path>",
|
|
2375
3586
|
"frontend: the story file or component folder storybrokr boots, relative to the Storybook host"
|
|
2376
3587
|
).option("--join", "work in the already-open effort on this area instead of refusing").option("--no-sync", "skip the fetch and merge from the base branch (reproducing something)").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
@@ -2379,8 +3590,10 @@ function registerStart(program2) {
|
|
|
2379
3590
|
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2380
3591
|
const r = await startEffort(ctx, {
|
|
2381
3592
|
area: o.area,
|
|
3593
|
+
task: o.task,
|
|
2382
3594
|
type: o.type,
|
|
2383
3595
|
mode: o.mode,
|
|
3596
|
+
scopes: o.scope,
|
|
2384
3597
|
story: o.story,
|
|
2385
3598
|
join: o.join,
|
|
2386
3599
|
sync: o.sync
|
|
@@ -2395,36 +3608,47 @@ function registerStart(program2) {
|
|
|
2395
3608
|
}
|
|
2396
3609
|
|
|
2397
3610
|
// src/commands/status.ts
|
|
2398
|
-
import
|
|
3611
|
+
import chalk11 from "chalk";
|
|
2399
3612
|
var age = (iso) => {
|
|
2400
3613
|
const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 6e4));
|
|
2401
3614
|
return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
|
|
2402
3615
|
};
|
|
2403
3616
|
var expiresIn = (iso) => {
|
|
2404
3617
|
const mins = Math.round((Date.parse(iso) - Date.now()) / 6e4);
|
|
2405
|
-
return mins <= 0 ?
|
|
3618
|
+
return mins <= 0 ? chalk11.red("expired") : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
|
|
2406
3619
|
};
|
|
2407
3620
|
function printStatus(r) {
|
|
2408
|
-
console.log(
|
|
2409
|
-
const cols = [
|
|
3621
|
+
console.log(chalk11.bold(`${r.project}`));
|
|
3622
|
+
const cols = [
|
|
3623
|
+
"AREA",
|
|
3624
|
+
"TYPE/MODE",
|
|
3625
|
+
"BRANCH",
|
|
3626
|
+
"OWNER",
|
|
3627
|
+
"STARTED",
|
|
3628
|
+
"LEASES",
|
|
3629
|
+
"PR",
|
|
3630
|
+
"BEHIND",
|
|
3631
|
+
"NOTICES"
|
|
3632
|
+
];
|
|
2410
3633
|
const rows = r.areas.map((a) => [
|
|
2411
3634
|
a.area,
|
|
2412
|
-
a.effort ? `${a.effort.type}/${a.effort.mode}` :
|
|
2413
|
-
a.effort?.branch ??
|
|
2414
|
-
a.effort?.owner.session ??
|
|
2415
|
-
a.effort ? age(a.effort.started) :
|
|
2416
|
-
a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") :
|
|
2417
|
-
a.
|
|
2418
|
-
a.
|
|
3635
|
+
a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk11.gray("-"),
|
|
3636
|
+
a.effort?.branch ?? chalk11.gray("-"),
|
|
3637
|
+
a.effort?.owner.session ?? chalk11.gray("-"),
|
|
3638
|
+
a.effort ? age(a.effort.started) : chalk11.gray("-"),
|
|
3639
|
+
a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk11.gray("-"),
|
|
3640
|
+
a.effort?.pr ? `#${a.effort.pr.number}${a.effort.pr.draft ? " draft" : ""}` : chalk11.gray("-"),
|
|
3641
|
+
a.behindBase > 0 ? chalk11.yellow(String(a.behindBase)) : "0",
|
|
3642
|
+
a.notices.length ? chalk11.yellow(String(a.notices.length)) : "0"
|
|
2419
3643
|
]);
|
|
2420
3644
|
const plain = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
|
|
2421
3645
|
const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((row) => plain(row[i]).length)));
|
|
2422
3646
|
const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - plain(c).length)).join(" ");
|
|
2423
|
-
console.log(
|
|
3647
|
+
console.log(chalk11.bold(line(cols)));
|
|
2424
3648
|
for (const row of rows) console.log(line(row));
|
|
2425
3649
|
if (r.leases.length) {
|
|
2426
3650
|
console.log("");
|
|
2427
|
-
console.log(
|
|
3651
|
+
console.log(chalk11.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
|
|
2428
3652
|
for (const l of r.leases) {
|
|
2429
3653
|
console.log(
|
|
2430
3654
|
`${l.kind.padEnd(10)} ${String(l.value).padEnd(12)} ${l.owner.session.padEnd(21)} ${age(l.created).padEnd(5)} ${expiresIn(l.expires)}`
|
|
@@ -2433,10 +3657,10 @@ function printStatus(r) {
|
|
|
2433
3657
|
}
|
|
2434
3658
|
}
|
|
2435
3659
|
function registerStatus(program2) {
|
|
2436
|
-
program2.command("status").description("Open efforts, leases, drift from the base branch and notices, per area").option("--area <area>", "one area only").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the report as JSON").action(async (o) => {
|
|
3660
|
+
program2.command("status").description("Open efforts, leases, drift from the base branch and notices, per area").option("--area <area>", "one area only").option("--task <slug>", "intra-area task slug").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the report as JSON").action(async (o) => {
|
|
2437
3661
|
try {
|
|
2438
3662
|
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
|
|
2439
|
-
const r = statusFor(ctx, o.area);
|
|
3663
|
+
const r = statusFor(ctx, o.area, o.task);
|
|
2440
3664
|
if (o.json) printJson(r);
|
|
2441
3665
|
else printStatus(r);
|
|
2442
3666
|
} catch (err) {
|
|
@@ -2449,12 +3673,14 @@ function registerStatus(program2) {
|
|
|
2449
3673
|
var { program } = createCli({
|
|
2450
3674
|
name: "work",
|
|
2451
3675
|
version: VERSION,
|
|
2452
|
-
description: "Area-based work: start, status, finish, reap, retire, audit."
|
|
3676
|
+
description: "Area-based work: start, promote, status, pr, finish, reap, retire, audit."
|
|
2453
3677
|
});
|
|
2454
3678
|
registerInit(program);
|
|
2455
3679
|
registerStart(program);
|
|
3680
|
+
registerPromote(program);
|
|
2456
3681
|
registerStatus(program);
|
|
2457
3682
|
registerEnv(program);
|
|
3683
|
+
registerPr(program);
|
|
2458
3684
|
registerFinish(program);
|
|
2459
3685
|
registerReap(program);
|
|
2460
3686
|
registerRetire(program);
|