@helmsmith/workbrokr 0.1.0 → 0.2.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 +9 -8
- package/dist/cli.js +1135 -323
- package/dist/cli.js.map +1 -1
- package/dist/core/index.js +1515 -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",
|
|
@@ -76,11 +83,19 @@ function toErrorBody(err) {
|
|
|
76
83
|
return { code: "INTERNAL", message: err instanceof Error ? err.message : String(err) };
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
//
|
|
86
|
+
// src/core/git.ts
|
|
80
87
|
import { spawnSync } from "child_process";
|
|
81
|
-
import { mkdirSync, realpathSync } from "fs";
|
|
82
|
-
import { dirname } from "path";
|
|
83
|
-
var
|
|
88
|
+
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
|
|
89
|
+
import { dirname, join, resolve, sep } from "path";
|
|
90
|
+
var realpathOr = (p) => {
|
|
91
|
+
try {
|
|
92
|
+
return realpathSync(p);
|
|
93
|
+
} catch {
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var SAFE_SUBMODULE_NAME = /^[^\p{Cc}\s=]+$/u;
|
|
98
|
+
var Git = class _Git {
|
|
84
99
|
constructor(cwd) {
|
|
85
100
|
this.cwd = cwd;
|
|
86
101
|
}
|
|
@@ -206,9 +221,89 @@ var Git = class {
|
|
|
206
221
|
deleteLocalBranch(branch) {
|
|
207
222
|
this.run(["branch", "-q", "-D", branch]);
|
|
208
223
|
}
|
|
224
|
+
isAncestor(ancestor, descendant) {
|
|
225
|
+
return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).code === 0;
|
|
226
|
+
}
|
|
227
|
+
/** True when HEAD's tree matches origin/<base>, or every commit on HEAD is patch-equivalent to origin/<base> (squash-merged). */
|
|
228
|
+
isEquivalentToBase(base) {
|
|
229
|
+
const target = `origin/${base}`;
|
|
230
|
+
if (this.tryRun(["diff", "--quiet", target, "HEAD"]).code === 0) return true;
|
|
231
|
+
const cherry = this.tryRun(["cherry", target, "HEAD"]);
|
|
232
|
+
if (cherry.code !== 0) return false;
|
|
233
|
+
const lines = cherry.out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
234
|
+
return lines.length > 0 && lines.every((l) => l.startsWith("- "));
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Give a linked worktree its own clone of every submodule the main checkout has initialized,
|
|
238
|
+
* cloned locally from the main checkout's module directory (no network) and then re-pointed at
|
|
239
|
+
* the real URL. `git worktree add` under submodule.recurse leaves a gitfile aimed at a module
|
|
240
|
+
* directory that was never cloned; a gitfile aimed at the MAIN checkout's module directory is
|
|
241
|
+
* worse, because that module's core.worktree is the main checkout's submodule, so any git
|
|
242
|
+
* command in the worktree's submodule (checkout -f, reset, merge) rewrites the human's files.
|
|
243
|
+
* Both shapes are replaced; a submodule that is already its own working tree is left alone.
|
|
244
|
+
*/
|
|
245
|
+
repairSubmodules(mainRoot, log = () => {
|
|
246
|
+
}) {
|
|
247
|
+
const gitmodules = join(this.cwd, ".gitmodules");
|
|
248
|
+
if (!existsSync(gitmodules)) return;
|
|
249
|
+
const cfg = this.tryRun([
|
|
250
|
+
"config",
|
|
251
|
+
"-f",
|
|
252
|
+
".gitmodules",
|
|
253
|
+
"--get-regexp",
|
|
254
|
+
"^submodule\\..*\\.path$"
|
|
255
|
+
]);
|
|
256
|
+
if (cfg.code !== 0) return;
|
|
257
|
+
const wtGitDir = this.run(["rev-parse", "--path-format=absolute", "--git-dir"]).trim();
|
|
258
|
+
for (const line of cfg.out.split("\n").filter(Boolean)) {
|
|
259
|
+
const m = /^submodule\.(.+)\.path\s+(.+)$/.exec(line.trim());
|
|
260
|
+
if (!m) continue;
|
|
261
|
+
const [, name, subPath] = m;
|
|
262
|
+
const modulesDir = resolve(wtGitDir, "modules");
|
|
263
|
+
const moduleDir = resolve(modulesDir, name);
|
|
264
|
+
if (!SAFE_SUBMODULE_NAME.test(name) || name.split(/[\\/]/).some((seg) => seg === "" || seg === "." || seg === "..") || !moduleDir.startsWith(modulesDir + sep)) {
|
|
265
|
+
log(`submodule ${subPath}: refusing name ${JSON.stringify(name)}; not repaired`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const wtSub = join(this.cwd, subPath);
|
|
269
|
+
const wtGit = join(wtSub, ".git");
|
|
270
|
+
if (this.isOwnSubmodule(wtSub, wtGit)) continue;
|
|
271
|
+
const mainSub = join(mainRoot, subPath);
|
|
272
|
+
if (!existsSync(join(mainSub, ".git"))) continue;
|
|
273
|
+
const src = new _Git(mainSub).tryRun(["rev-parse", "--path-format=absolute", "--git-dir"]);
|
|
274
|
+
if (src.code !== 0 || !src.out.trim()) continue;
|
|
275
|
+
if (existsSync(wtGit) && statSync(wtGit).isFile()) rmSync(wtGit);
|
|
276
|
+
rmSync(moduleDir, { recursive: true, force: true });
|
|
277
|
+
const r = this.tryRun([
|
|
278
|
+
"-c",
|
|
279
|
+
"protocol.file.allow=always",
|
|
280
|
+
"-c",
|
|
281
|
+
`submodule.${name}.url=${src.out.trim()}`,
|
|
282
|
+
"submodule",
|
|
283
|
+
"update",
|
|
284
|
+
"--init",
|
|
285
|
+
"--",
|
|
286
|
+
subPath
|
|
287
|
+
]);
|
|
288
|
+
if (r.code !== 0) {
|
|
289
|
+
log(
|
|
290
|
+
`submodule ${subPath}: not repaired (${(r.err || r.out).trim()}); the project's refresh steps may still set it up`
|
|
291
|
+
);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
this.tryRun(["submodule", "sync", "-q", "--", subPath]);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** A submodule is the worktree's own when git, run inside it, says its top level IS that directory. */
|
|
298
|
+
isOwnSubmodule(wtSub, wtGit) {
|
|
299
|
+
if (!existsSync(wtGit)) return false;
|
|
300
|
+
const top = new _Git(wtSub).tryRun(["rev-parse", "--show-toplevel"]);
|
|
301
|
+
if (top.code !== 0) return false;
|
|
302
|
+
return realpathOr(top.out.trim()) === realpathOr(wtSub);
|
|
303
|
+
}
|
|
209
304
|
};
|
|
210
305
|
|
|
211
|
-
//
|
|
306
|
+
// src/core/config.ts
|
|
212
307
|
function renderTemplate(s, vars) {
|
|
213
308
|
return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => {
|
|
214
309
|
if (!(name in vars)) {
|
|
@@ -220,6 +315,10 @@ function renderTemplate(s, vars) {
|
|
|
220
315
|
return vars[name];
|
|
221
316
|
});
|
|
222
317
|
}
|
|
318
|
+
function storybookHostFor(m, worktree) {
|
|
319
|
+
if (!worktree) return "";
|
|
320
|
+
return m.storybook?.hostRoot ? join2(worktree, m.storybook.hostRoot) : worktree;
|
|
321
|
+
}
|
|
223
322
|
function slotVars(m, o) {
|
|
224
323
|
const vars = {
|
|
225
324
|
SLOT: o.slot === void 0 ? "" : String(o.slot),
|
|
@@ -228,7 +327,8 @@ function slotVars(m, o) {
|
|
|
228
327
|
BRANCH: o.branch ?? "",
|
|
229
328
|
WORKTREE: o.worktree ?? "",
|
|
230
329
|
LOG_DIR: o.logDir ?? "",
|
|
231
|
-
PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort)
|
|
330
|
+
PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort),
|
|
331
|
+
STORYBROKR_HOST: storybookHostFor(m, o.worktree)
|
|
232
332
|
};
|
|
233
333
|
for (const service of Object.keys(m.ports.services)) {
|
|
234
334
|
const p = o.ports[service];
|
|
@@ -240,16 +340,16 @@ function ensureExcluded(worktree, patterns) {
|
|
|
240
340
|
const r = new Git(worktree).tryRun(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
241
341
|
if (r.code !== 0) return;
|
|
242
342
|
const common = realpathSync2(r.out.trim());
|
|
243
|
-
const file =
|
|
244
|
-
mkdirSync2(
|
|
343
|
+
const file = join2(common, "info", "exclude");
|
|
344
|
+
mkdirSync2(join2(common, "info"), { recursive: true });
|
|
245
345
|
const present = new Set(
|
|
246
|
-
|
|
346
|
+
existsSync2(file) ? readFileSync(file, "utf8").split("\n").map((l) => l.trim()) : []
|
|
247
347
|
);
|
|
248
348
|
const missing = patterns.filter((p) => !present.has(p));
|
|
249
349
|
if (missing.length === 0) return;
|
|
250
350
|
appendFileSync(
|
|
251
351
|
file,
|
|
252
|
-
`${present.size ? "\n" : ""}# written by @helmsmith/
|
|
352
|
+
`${present.size ? "\n" : ""}# written by @helmsmith/workbrokr: per-worktree config and logs
|
|
253
353
|
${missing.join("\n")}
|
|
254
354
|
`
|
|
255
355
|
);
|
|
@@ -261,26 +361,26 @@ function emitWorktreeConfig(a) {
|
|
|
261
361
|
`${a.m.logs.dir.replace(/\/+$/, "")}/`,
|
|
262
362
|
`${a.m.worktreeRoot.replace(/\/+$/, "")}/`
|
|
263
363
|
]);
|
|
264
|
-
const logDir =
|
|
364
|
+
const logDir = join2(a.worktree, a.m.logs.dir);
|
|
265
365
|
mkdirSync2(logDir, { recursive: true });
|
|
266
366
|
const vars = slotVars(a.m, { ...a, logDir });
|
|
267
|
-
const envFile =
|
|
367
|
+
const envFile = join2(a.worktree, a.m.env.file);
|
|
268
368
|
const lines = Object.entries(a.m.env.template).map(([k, v]) => `${k}=${renderTemplate(v, vars)}`);
|
|
269
369
|
writeFileSync(envFile, `${lines.join("\n")}
|
|
270
370
|
`);
|
|
271
371
|
let slotFile;
|
|
272
372
|
if (a.slot !== void 0) {
|
|
273
|
-
slotFile =
|
|
373
|
+
slotFile = join2(a.worktree, ".slot");
|
|
274
374
|
writeFileSync(slotFile, `${a.slot}
|
|
275
375
|
`);
|
|
276
376
|
}
|
|
277
377
|
return slotFile ? { envFile, slotFile, logDir, vars } : { envFile, logDir, vars };
|
|
278
378
|
}
|
|
279
379
|
|
|
280
|
-
//
|
|
281
|
-
import { existsSync as
|
|
380
|
+
// src/core/guards.ts
|
|
381
|
+
import { existsSync as existsSync3, realpathSync as realpathSync3 } from "fs";
|
|
282
382
|
|
|
283
|
-
//
|
|
383
|
+
// src/core/scopes.ts
|
|
284
384
|
import picomatch from "picomatch";
|
|
285
385
|
function areaVars(area) {
|
|
286
386
|
const parts = area.split("/");
|
|
@@ -298,7 +398,42 @@ function fill(template, vars) {
|
|
|
298
398
|
});
|
|
299
399
|
return missing ? null : out;
|
|
300
400
|
}
|
|
301
|
-
function
|
|
401
|
+
function extraScopesForMode(spec, mode) {
|
|
402
|
+
if (!spec.extraScopes) return [];
|
|
403
|
+
if (Array.isArray(spec.extraScopes)) {
|
|
404
|
+
return mode === "frontend" || mode === "fullstack" ? spec.extraScopes : [];
|
|
405
|
+
}
|
|
406
|
+
const out = [];
|
|
407
|
+
if (mode === "frontend" || mode === "fullstack") out.push(...spec.extraScopes.frontend ?? []);
|
|
408
|
+
if (mode === "backend" || mode === "fullstack") out.push(...spec.extraScopes.backend ?? []);
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
var normalizeGlob = (g) => g.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
412
|
+
var hasWildcard = (s) => /[*?[{]/.test(s);
|
|
413
|
+
function isSubGlob(child, parent) {
|
|
414
|
+
const c = normalizeGlob(child);
|
|
415
|
+
const p = normalizeGlob(parent);
|
|
416
|
+
if (c === p) return true;
|
|
417
|
+
const cSegs = c.split("/");
|
|
418
|
+
const pSegs = p.split("/");
|
|
419
|
+
for (let i = 0; i < pSegs.length; i++) {
|
|
420
|
+
const ps = pSegs[i];
|
|
421
|
+
if (ps === "**") return true;
|
|
422
|
+
const cs = cSegs[i];
|
|
423
|
+
if (cs === void 0 || cs === "**") return false;
|
|
424
|
+
if (!hasWildcard(ps) && !hasWildcard(cs)) {
|
|
425
|
+
if (ps !== cs) return false;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (!hasWildcard(cs) && hasWildcard(ps)) {
|
|
429
|
+
if (!picomatch.isMatch(cs, ps, { dot: true })) return false;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (cs !== ps) return false;
|
|
433
|
+
}
|
|
434
|
+
return cSegs.length === pSegs.length;
|
|
435
|
+
}
|
|
436
|
+
function expandScopes(m, area, mode, narrowedScopes, hasTask = false) {
|
|
302
437
|
const spec = m.areas[area];
|
|
303
438
|
if (!spec)
|
|
304
439
|
throw new WorkkitError(
|
|
@@ -307,15 +442,17 @@ function expandScopes(m, area, mode) {
|
|
|
307
442
|
);
|
|
308
443
|
const vars = { ...areaVars(area), module: spec.module };
|
|
309
444
|
const out = [];
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
445
|
+
if (mode === "frontend" || mode === "fullstack") {
|
|
446
|
+
for (const t of m.scopes.frontend) {
|
|
447
|
+
const g = fill(t, vars);
|
|
448
|
+
if (g) out.push(g);
|
|
449
|
+
}
|
|
313
450
|
}
|
|
314
|
-
if (mode === "fullstack") {
|
|
451
|
+
if (mode === "backend" || mode === "fullstack") {
|
|
315
452
|
if (!spec.module) {
|
|
316
453
|
throw new WorkkitError(
|
|
317
454
|
"MANIFEST_INVALID",
|
|
318
|
-
`area "${area}" has no module, so a
|
|
455
|
+
`area "${area}" has no module, so a ${mode} effort cannot hold a backend scope; add areas["${area}"].module or use --mode frontend`
|
|
319
456
|
);
|
|
320
457
|
}
|
|
321
458
|
for (const t of m.scopes.backend) {
|
|
@@ -323,20 +460,65 @@ function expandScopes(m, area, mode) {
|
|
|
323
460
|
if (g) out.push(g);
|
|
324
461
|
}
|
|
325
462
|
}
|
|
326
|
-
out.push(...spec
|
|
327
|
-
return out;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
463
|
+
out.push(...extraScopesForMode(spec, mode));
|
|
464
|
+
if (!narrowedScopes || narrowedScopes.length === 0) return out;
|
|
465
|
+
if (!hasTask) {
|
|
466
|
+
throw new WorkkitError(
|
|
467
|
+
"SCOPE_REQUIRES_TASK",
|
|
468
|
+
"--scope narrowing requires --task <slug> so primary area efforts always protect the full area"
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const allowed = [...out, ...spec.sharedScopes ?? []];
|
|
472
|
+
for (const g of narrowedScopes) {
|
|
473
|
+
if (!allowed.some((base) => isSubGlob(g, base))) {
|
|
474
|
+
throw new WorkkitError(
|
|
475
|
+
"SCOPE_OUTSIDE_AREA",
|
|
476
|
+
`narrowed scope "${g}" is outside ${area}'s ${mode} scopes (${allowed.join(", ")})`,
|
|
477
|
+
{ area, mode, scope: g, allowed }
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return narrowedScopes;
|
|
482
|
+
}
|
|
483
|
+
function allowedScopesForEffort(m, effort) {
|
|
484
|
+
const shared = m.areas[effort.area]?.sharedScopes ?? [];
|
|
485
|
+
const allCommons = Object.values(m.commons).flat();
|
|
486
|
+
return [...effort.scopes, ...shared, ...allCommons];
|
|
487
|
+
}
|
|
488
|
+
function segmentsOverlap(sx, sy) {
|
|
489
|
+
if (sx === sy) return true;
|
|
490
|
+
const wx = hasWildcard(sx);
|
|
491
|
+
const wy = hasWildcard(sy);
|
|
492
|
+
if (!wx && !wy) return false;
|
|
493
|
+
if (!wx && wy) return picomatch.isMatch(sx, sy, { dot: true });
|
|
494
|
+
if (wx && !wy) return picomatch.isMatch(sy, sx, { dot: true });
|
|
495
|
+
const starToken = /^\*([^*?[{]+)\*$/;
|
|
496
|
+
const mx = starToken.exec(sx);
|
|
497
|
+
const my = starToken.exec(sy);
|
|
498
|
+
if (mx && my) {
|
|
499
|
+
const tx = mx[1];
|
|
500
|
+
const ty = my[1];
|
|
501
|
+
return tx.includes(ty) || ty.includes(tx);
|
|
502
|
+
}
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
function twoGlobsOverlap(x, y) {
|
|
506
|
+
const xs = normalizeGlob(x).split("/");
|
|
507
|
+
const ys = normalizeGlob(y).split("/");
|
|
508
|
+
const len = Math.min(xs.length, ys.length);
|
|
509
|
+
for (let i = 0; i < len; i++) {
|
|
510
|
+
if (xs[i] === "**" || ys[i] === "**") return true;
|
|
511
|
+
if (!segmentsOverlap(xs[i], ys[i])) return false;
|
|
512
|
+
}
|
|
513
|
+
if (xs.length === ys.length) return true;
|
|
514
|
+
const longer = xs.length > ys.length ? xs : ys;
|
|
515
|
+
return longer[len] === "**";
|
|
332
516
|
}
|
|
333
517
|
function globsOverlap(a, b) {
|
|
334
518
|
const pairs = [];
|
|
335
519
|
for (const x of a) {
|
|
336
|
-
const px = literalPrefix(x);
|
|
337
520
|
for (const y of b) {
|
|
338
|
-
|
|
339
|
-
if (px.startsWith(py) || py.startsWith(px)) pairs.push(`${x} ~ ${y}`);
|
|
521
|
+
if (twoGlobsOverlap(x, y)) pairs.push(`${x} ~ ${y}`);
|
|
340
522
|
}
|
|
341
523
|
}
|
|
342
524
|
return pairs;
|
|
@@ -350,7 +532,7 @@ function commonsTouched(m, paths) {
|
|
|
350
532
|
return Object.entries(m.commons).filter(([, globs]) => pathsMatching(paths, globs).length > 0).map(([name]) => name);
|
|
351
533
|
}
|
|
352
534
|
|
|
353
|
-
//
|
|
535
|
+
// src/core/guards.ts
|
|
354
536
|
function assertNotProtected(m, branch) {
|
|
355
537
|
if (m.protectedBranches.includes(branch)) {
|
|
356
538
|
throw new WorkkitError(
|
|
@@ -367,19 +549,25 @@ var same = (a, b) => {
|
|
|
367
549
|
return a === b;
|
|
368
550
|
}
|
|
369
551
|
};
|
|
552
|
+
var effortLabel = (e) => e.task ? `${e.area}--${e.task}` : e.area;
|
|
370
553
|
function checkCommit(c) {
|
|
371
554
|
const refusals = [];
|
|
555
|
+
const warnings = [];
|
|
372
556
|
if (c.m.protectedBranches.includes(c.branch)) {
|
|
373
557
|
refusals.push(
|
|
374
558
|
`branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
|
|
375
559
|
);
|
|
376
560
|
}
|
|
377
|
-
const
|
|
561
|
+
const allEfforts = c.registry.efforts({ project: c.project });
|
|
562
|
+
const efforts = new Map(allEfforts.map((e) => [e.id, e]));
|
|
378
563
|
for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
|
|
379
|
-
if (lease.owner.session === c.ownerSession) continue;
|
|
380
564
|
const holder = efforts.get(lease.effort);
|
|
381
|
-
|
|
565
|
+
const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
|
|
566
|
+
if (holder && holderHasWorktree && c.checkoutRoot) {
|
|
567
|
+
if (same(holder.worktree, c.checkoutRoot)) continue;
|
|
568
|
+
} else if (lease.owner.session === c.ownerSession) {
|
|
382
569
|
continue;
|
|
570
|
+
}
|
|
383
571
|
const globs = c.m.commons[String(lease.value)] ?? [];
|
|
384
572
|
const hit = pathsMatching(c.stagedPaths, globs);
|
|
385
573
|
if (hit.length > 0) {
|
|
@@ -388,7 +576,33 @@ function checkCommit(c) {
|
|
|
388
576
|
);
|
|
389
577
|
}
|
|
390
578
|
}
|
|
391
|
-
|
|
579
|
+
const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
|
|
580
|
+
for (const other of openEfforts) {
|
|
581
|
+
if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (!c.checkoutRoot && other.branch === c.branch) continue;
|
|
585
|
+
const hit = pathsMatching(c.stagedPaths, other.scopes);
|
|
586
|
+
if (hit.length > 0) {
|
|
587
|
+
refusals.push(
|
|
588
|
+
`path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const mine = openEfforts.find(
|
|
593
|
+
(e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
|
|
594
|
+
);
|
|
595
|
+
if (mine && c.stagedPaths.length > 0) {
|
|
596
|
+
const allowed = allowedScopesForEffort(c.m, mine);
|
|
597
|
+
const inScope = new Set(pathsMatching(c.stagedPaths, allowed));
|
|
598
|
+
const outside = c.stagedPaths.filter((p) => !inScope.has(p));
|
|
599
|
+
if (outside.length > 0) {
|
|
600
|
+
warnings.push(
|
|
601
|
+
`${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { refusals, warnings };
|
|
392
606
|
}
|
|
393
607
|
function checkPush(c) {
|
|
394
608
|
const refusals = [];
|
|
@@ -401,15 +615,15 @@ function checkPush(c) {
|
|
|
401
615
|
return { refusals, warnings };
|
|
402
616
|
}
|
|
403
617
|
if (c.isDelete) return { refusals, warnings };
|
|
404
|
-
const effort = c.registry.efforts({ project: c.project
|
|
618
|
+
const effort = c.registry.efforts({ project: c.project }).find((e) => (e.state === "active" || e.state === "parked") && e.branch === c.branch);
|
|
405
619
|
const commons = commonsTouched(c.m, c.changedPaths);
|
|
406
620
|
if (effort) {
|
|
407
|
-
const
|
|
408
|
-
const inScope = new Set(pathsMatching(c.changedPaths,
|
|
621
|
+
const allowed = allowedScopesForEffort(c.m, effort);
|
|
622
|
+
const inScope = new Set(pathsMatching(c.changedPaths, allowed));
|
|
409
623
|
const outside = c.changedPaths.filter((p) => !inScope.has(p));
|
|
410
624
|
if (outside.length > 0) {
|
|
411
625
|
warnings.push(
|
|
412
|
-
`${outside.length} changed path(s) are outside the effort's scope (${effort
|
|
626
|
+
`${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
627
|
);
|
|
414
628
|
}
|
|
415
629
|
}
|
|
@@ -418,13 +632,13 @@ function checkPush(c) {
|
|
|
418
632
|
return { refusals, warnings };
|
|
419
633
|
}
|
|
420
634
|
|
|
421
|
-
//
|
|
635
|
+
// src/core/hooks.ts
|
|
422
636
|
import { chmodSync, lstatSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
423
|
-
import { join as
|
|
637
|
+
import { join as join4 } from "path";
|
|
424
638
|
|
|
425
|
-
//
|
|
426
|
-
import { existsSync as
|
|
427
|
-
import { join as
|
|
639
|
+
// src/core/manifest.ts
|
|
640
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
641
|
+
import { join as join3 } from "path";
|
|
428
642
|
import { pathToFileURL } from "url";
|
|
429
643
|
import { z } from "zod";
|
|
430
644
|
var MANIFEST_FILES = [
|
|
@@ -434,14 +648,23 @@ var MANIFEST_FILES = [
|
|
|
434
648
|
];
|
|
435
649
|
var BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
436
650
|
var BRANCH_NAME_HINT = 'must be a plain ref name: letters, digits, ".", "_", "/", "-"';
|
|
651
|
+
var TASK_SLUG = /^(?!.*--)[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
437
652
|
var Service = z.object({
|
|
438
653
|
start: z.string().min(1),
|
|
439
654
|
stop: z.string().min(1),
|
|
440
655
|
health: z.string().min(1),
|
|
441
656
|
healthyCodes: z.array(z.number().int()).optional(),
|
|
442
657
|
reset: z.string().optional(),
|
|
443
|
-
healthTimeoutMs: z.number().int().positive().optional()
|
|
658
|
+
healthTimeoutMs: z.number().int().positive().optional(),
|
|
659
|
+
modes: z.array(z.enum(["backend", "fullstack"])).optional()
|
|
444
660
|
});
|
|
661
|
+
var ExtraScopes = z.union([
|
|
662
|
+
z.array(z.string()),
|
|
663
|
+
z.object({
|
|
664
|
+
frontend: z.array(z.string()).optional(),
|
|
665
|
+
backend: z.array(z.string()).optional()
|
|
666
|
+
})
|
|
667
|
+
]);
|
|
445
668
|
var Schema = z.object({
|
|
446
669
|
project: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
447
670
|
baseBranch: z.string().regex(BRANCH_NAME, BRANCH_NAME_HINT),
|
|
@@ -459,21 +682,24 @@ var Schema = z.object({
|
|
|
459
682
|
storybook: z.object({ hostRoot: z.string() }).optional(),
|
|
460
683
|
logs: z.object({ dir: z.string() }).default({ dir: ".logs" }),
|
|
461
684
|
env: z.object({ file: z.string(), template: z.record(z.string(), z.string()) }).optional(),
|
|
685
|
+
seed: z.array(z.string().min(1)).optional(),
|
|
462
686
|
refresh: z.array(
|
|
463
687
|
z.object({
|
|
464
688
|
when: z.string().optional(),
|
|
465
689
|
always: z.boolean().optional(),
|
|
466
690
|
run: z.string().optional(),
|
|
467
|
-
mark: z.string().optional()
|
|
691
|
+
mark: z.string().optional(),
|
|
692
|
+
restoreWhenIfClean: z.boolean().optional()
|
|
468
693
|
})
|
|
469
694
|
).default([]),
|
|
470
695
|
scopes: z.object({ frontend: z.array(z.string()), backend: z.array(z.string()) }),
|
|
471
696
|
areas: z.record(
|
|
472
|
-
z.string().regex(/^[a-z0-9-]+(\/[a-z0-9-]+)*$/),
|
|
697
|
+
z.string().regex(/^(?!.*--)[a-z0-9-]+(\/[a-z0-9-]+)*$/),
|
|
473
698
|
z.object({
|
|
474
699
|
module: z.string().optional(),
|
|
475
700
|
commons: z.array(z.string()).optional(),
|
|
476
|
-
extraScopes:
|
|
701
|
+
extraScopes: ExtraScopes.optional(),
|
|
702
|
+
sharedScopes: z.array(z.string()).optional()
|
|
477
703
|
})
|
|
478
704
|
),
|
|
479
705
|
commons: z.record(z.string(), z.array(z.string())).default({}),
|
|
@@ -496,13 +722,21 @@ function templateVariables(m) {
|
|
|
496
722
|
"AREA",
|
|
497
723
|
"BRANCH",
|
|
498
724
|
"WORKTREE",
|
|
499
|
-
"PORT_STORYBOOK"
|
|
725
|
+
"PORT_STORYBOOK",
|
|
726
|
+
"STORYBROKR_HOST"
|
|
500
727
|
]);
|
|
501
728
|
for (const s of Object.keys(m.ports.services)) vars.add(`PORT_${s.toUpperCase()}`);
|
|
502
729
|
return vars;
|
|
503
730
|
}
|
|
504
731
|
function defaultEnv(services) {
|
|
505
|
-
const template = {
|
|
732
|
+
const template = {
|
|
733
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
|
|
734
|
+
SLOT: "${SLOT}",
|
|
735
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
|
|
736
|
+
LOG_DIR: "${LOG_DIR}",
|
|
737
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
|
|
738
|
+
STORYBROKR_HOST: "${STORYBROKR_HOST}"
|
|
739
|
+
};
|
|
506
740
|
for (const s of services) template[`PORT_${s.toUpperCase()}`] = `\${PORT_${s.toUpperCase()}}`;
|
|
507
741
|
return { file: ".env.slot", template };
|
|
508
742
|
}
|
|
@@ -555,8 +789,8 @@ function validateManifest(raw) {
|
|
|
555
789
|
}
|
|
556
790
|
async function loadManifest(repoRoot) {
|
|
557
791
|
for (const name of MANIFEST_FILES) {
|
|
558
|
-
const file =
|
|
559
|
-
if (!
|
|
792
|
+
const file = join3(repoRoot, name);
|
|
793
|
+
if (!existsSync4(file)) continue;
|
|
560
794
|
if (name.endsWith(".json")) return validateManifest(JSON.parse(readFileSync2(file, "utf8")));
|
|
561
795
|
const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
|
|
562
796
|
return validateManifest(mod.default);
|
|
@@ -567,7 +801,7 @@ async function loadManifest(repoRoot) {
|
|
|
567
801
|
);
|
|
568
802
|
}
|
|
569
803
|
|
|
570
|
-
//
|
|
804
|
+
// src/core/hooks.ts
|
|
571
805
|
var HOOKS_DIR = ".helmsmith/hooks";
|
|
572
806
|
function preCommitScript(protectedBranches) {
|
|
573
807
|
return `#!/bin/sh
|
|
@@ -580,11 +814,21 @@ for p in $PROTECTED; do
|
|
|
580
814
|
exit 2
|
|
581
815
|
fi
|
|
582
816
|
done
|
|
583
|
-
if
|
|
817
|
+
if command -v workbrokr >/dev/null 2>&1; then
|
|
818
|
+
workbrokr hook pre-commit "$@" || exit $?
|
|
819
|
+
else
|
|
584
820
|
echo "work: workbrokr is not on PATH; commons and scope checks skipped (npm i -g @helmsmith/workbrokr)" >&2
|
|
585
|
-
exit 0
|
|
586
821
|
fi
|
|
587
|
-
|
|
822
|
+
COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
823
|
+
LEGACY="$COMMON_DIR/hooks/pre-commit"
|
|
824
|
+
if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
|
|
825
|
+
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
826
|
+
LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
|
|
827
|
+
if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
|
|
828
|
+
exec "$LEGACY" "$@"
|
|
829
|
+
fi
|
|
830
|
+
fi
|
|
831
|
+
exit 0
|
|
588
832
|
`;
|
|
589
833
|
}
|
|
590
834
|
function prePushScript(protectedBranches) {
|
|
@@ -602,11 +846,21 @@ printf '%s\\n' "$input" | while read -r lref lsha rref rsha; do
|
|
|
602
846
|
fi
|
|
603
847
|
done
|
|
604
848
|
done || exit 2
|
|
605
|
-
if
|
|
849
|
+
if command -v workbrokr >/dev/null 2>&1; then
|
|
850
|
+
printf '%s\\n' "$input" | workbrokr hook pre-push "$@" || exit $?
|
|
851
|
+
else
|
|
606
852
|
echo "work: workbrokr is not on PATH; scope and commons warnings skipped" >&2
|
|
607
|
-
exit 0
|
|
608
853
|
fi
|
|
609
|
-
|
|
854
|
+
COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
855
|
+
LEGACY="$COMMON_DIR/hooks/pre-push"
|
|
856
|
+
if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
|
|
857
|
+
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
858
|
+
LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
|
|
859
|
+
if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
|
|
860
|
+
printf '%s\\n' "$input" | exec "$LEGACY" "$@"
|
|
861
|
+
fi
|
|
862
|
+
fi
|
|
863
|
+
exit 0
|
|
610
864
|
`;
|
|
611
865
|
}
|
|
612
866
|
function installHooks(repoRoot, opts) {
|
|
@@ -614,7 +868,7 @@ function installHooks(repoRoot, opts) {
|
|
|
614
868
|
if (!BRANCH_NAME.test(b))
|
|
615
869
|
throw new WorkkitError("MANIFEST_INVALID", `protected branch "${b}" is not a plain ref name`);
|
|
616
870
|
}
|
|
617
|
-
const hooksDir =
|
|
871
|
+
const hooksDir = join4(repoRoot, HOOKS_DIR);
|
|
618
872
|
mkdirSync3(hooksDir, { recursive: true });
|
|
619
873
|
const files = [];
|
|
620
874
|
const scripts = [
|
|
@@ -622,7 +876,7 @@ function installHooks(repoRoot, opts) {
|
|
|
622
876
|
["pre-push", prePushScript(opts.protectedBranches)]
|
|
623
877
|
];
|
|
624
878
|
for (const [name, body] of scripts) {
|
|
625
|
-
const file =
|
|
879
|
+
const file = join4(hooksDir, name);
|
|
626
880
|
let existing;
|
|
627
881
|
try {
|
|
628
882
|
existing = lstatSync(file);
|
|
@@ -642,16 +896,16 @@ function installHooks(repoRoot, opts) {
|
|
|
642
896
|
return { hooksDir, files };
|
|
643
897
|
}
|
|
644
898
|
|
|
645
|
-
//
|
|
899
|
+
// src/core/paths.ts
|
|
646
900
|
import { homedir } from "os";
|
|
647
|
-
import { join as
|
|
901
|
+
import { join as join5 } from "path";
|
|
648
902
|
function homeDir(env = process.env) {
|
|
649
|
-
return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME :
|
|
903
|
+
return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME : join5(homedir(), ".cache", "helmsmith");
|
|
650
904
|
}
|
|
651
|
-
var registryFile = (home) =>
|
|
652
|
-
var lockDir = (home) =>
|
|
905
|
+
var registryFile = (home) => join5(home, "registry.json");
|
|
906
|
+
var lockDir = (home) => join5(home, "registry.lock");
|
|
653
907
|
|
|
654
|
-
//
|
|
908
|
+
// src/core/ports.ts
|
|
655
909
|
function portsForSlot(m, slot) {
|
|
656
910
|
return Object.fromEntries(
|
|
657
911
|
Object.entries(m.ports.services).map(([service, { base }]) => [service, base + slot])
|
|
@@ -687,25 +941,98 @@ function claimRanges(registry, m, repoRoot) {
|
|
|
687
941
|
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
688
942
|
}
|
|
689
943
|
|
|
690
|
-
//
|
|
944
|
+
// src/core/refresh.ts
|
|
691
945
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
946
|
+
import { createHash } from "crypto";
|
|
947
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
948
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
692
949
|
import picomatch2 from "picomatch";
|
|
693
950
|
function matchesWhen(glob) {
|
|
694
951
|
const isMatch = picomatch2(glob, { dot: true });
|
|
695
952
|
return (p) => isMatch(p);
|
|
696
953
|
}
|
|
697
|
-
function
|
|
954
|
+
function readStamps(stampFile) {
|
|
955
|
+
if (!stampFile || !existsSync5(stampFile)) return {};
|
|
956
|
+
try {
|
|
957
|
+
return JSON.parse(readFileSync3(stampFile, "utf8"));
|
|
958
|
+
} catch {
|
|
959
|
+
return {};
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
function writeStamps(stampFile, stamps) {
|
|
963
|
+
mkdirSync4(dirname2(stampFile), { recursive: true });
|
|
964
|
+
writeFileSync3(stampFile, `${JSON.stringify(stamps, null, 2)}
|
|
965
|
+
`);
|
|
966
|
+
}
|
|
967
|
+
function matchedFilesInWorktree(worktree, whenGlob) {
|
|
968
|
+
if (!/[*?[{]/.test(whenGlob)) {
|
|
969
|
+
return existsSync5(join6(worktree, whenGlob)) ? [whenGlob] : [];
|
|
970
|
+
}
|
|
971
|
+
const r = spawnSync2("git", ["ls-files"], { cwd: worktree, encoding: "utf8" });
|
|
972
|
+
if (r.status !== 0) return [];
|
|
973
|
+
const isMatch = matchesWhen(whenGlob);
|
|
974
|
+
return (r.stdout ?? "").split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && isMatch(l) && existsSync5(join6(worktree, l)));
|
|
975
|
+
}
|
|
976
|
+
function hashFiles(worktree, relPaths) {
|
|
977
|
+
if (relPaths.length === 0) return null;
|
|
978
|
+
const h = createHash("sha256");
|
|
979
|
+
for (const rel of [...relPaths].sort()) {
|
|
980
|
+
const abs = join6(worktree, rel);
|
|
981
|
+
if (!existsSync5(abs)) continue;
|
|
982
|
+
h.update(`${rel}\0`);
|
|
983
|
+
h.update(readFileSync3(abs));
|
|
984
|
+
h.update("\0");
|
|
985
|
+
}
|
|
986
|
+
return h.digest("hex");
|
|
987
|
+
}
|
|
988
|
+
function isFileCleanInGit(worktree, relPath) {
|
|
989
|
+
const r = spawnSync2("git", ["status", "--porcelain", "--", relPath], {
|
|
990
|
+
cwd: worktree,
|
|
991
|
+
encoding: "utf8"
|
|
992
|
+
});
|
|
993
|
+
return r.status === 0 && (r.stdout ?? "").trim().length === 0;
|
|
994
|
+
}
|
|
995
|
+
function selectRefresh(entries, changedPaths, opts) {
|
|
698
996
|
const runs = [];
|
|
699
997
|
const marks = [];
|
|
998
|
+
const stored = readStamps(opts?.stampFile);
|
|
999
|
+
const pendingStamps = { ...stored };
|
|
1000
|
+
const cleanWhenFiles = /* @__PURE__ */ new Set();
|
|
700
1001
|
for (const e of entries) {
|
|
701
|
-
|
|
1002
|
+
let hit = e.always === true;
|
|
1003
|
+
let matchedFiles = [];
|
|
1004
|
+
if (e.when !== void 0) {
|
|
1005
|
+
const byChanged = changedPaths.some(matchesWhen(e.when));
|
|
1006
|
+
if (opts?.worktree) {
|
|
1007
|
+
matchedFiles = matchedFilesInWorktree(opts.worktree, e.when);
|
|
1008
|
+
const digest = hashFiles(opts.worktree, matchedFiles);
|
|
1009
|
+
if (digest !== null) {
|
|
1010
|
+
if (stored[e.when] !== digest) hit = true;
|
|
1011
|
+
pendingStamps[e.when] = digest;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (byChanged) hit = true;
|
|
1015
|
+
}
|
|
702
1016
|
if (!hit) continue;
|
|
703
|
-
if (e.run)
|
|
1017
|
+
if (e.run) {
|
|
1018
|
+
runs.push(e.run);
|
|
1019
|
+
if (opts?.worktree && e.when && e.restoreWhenIfClean !== false) {
|
|
1020
|
+
for (const f of matchedFiles) {
|
|
1021
|
+
if (isFileCleanInGit(opts.worktree, f)) cleanWhenFiles.add(f);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
704
1025
|
if (e.mark) marks.push(e.mark);
|
|
705
1026
|
}
|
|
706
|
-
return { runs, marks };
|
|
1027
|
+
if (!opts?.worktree) return { runs, marks };
|
|
1028
|
+
return {
|
|
1029
|
+
runs,
|
|
1030
|
+
marks,
|
|
1031
|
+
pendingStamps,
|
|
1032
|
+
cleanWhenFiles: [...cleanWhenFiles]
|
|
1033
|
+
};
|
|
707
1034
|
}
|
|
708
|
-
function runRefresh(runs, cwd, log, env = process.env) {
|
|
1035
|
+
function runRefresh(runs, cwd, log, env = process.env, opts) {
|
|
709
1036
|
for (const cmd of runs) {
|
|
710
1037
|
log(`$ ${cmd}`);
|
|
711
1038
|
const r = spawnSync2("sh", ["-c", cmd], { cwd, env, encoding: "utf8" });
|
|
@@ -718,18 +1045,28 @@ function runRefresh(runs, cwd, log, env = process.env) {
|
|
|
718
1045
|
});
|
|
719
1046
|
}
|
|
720
1047
|
}
|
|
1048
|
+
if (opts?.restoreFiles && opts.restoreFiles.length > 0) {
|
|
1049
|
+
for (const rel of opts.restoreFiles) {
|
|
1050
|
+
if (!isFileCleanInGit(cwd, rel)) {
|
|
1051
|
+
spawnSync2("git", ["checkout", "--", rel], { cwd, encoding: "utf8" });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (opts?.stampFile && opts.pendingStamps) {
|
|
1056
|
+
writeStamps(opts.stampFile, opts.pendingStamps);
|
|
1057
|
+
}
|
|
721
1058
|
}
|
|
722
1059
|
|
|
723
|
-
//
|
|
1060
|
+
// src/core/registry.ts
|
|
724
1061
|
import { randomBytes } from "crypto";
|
|
725
1062
|
import {
|
|
726
|
-
existsSync as
|
|
727
|
-
mkdirSync as
|
|
728
|
-
readFileSync as
|
|
1063
|
+
existsSync as existsSync6,
|
|
1064
|
+
mkdirSync as mkdirSync5,
|
|
1065
|
+
readFileSync as readFileSync4,
|
|
729
1066
|
renameSync,
|
|
730
|
-
rmSync,
|
|
731
|
-
statSync,
|
|
732
|
-
writeFileSync as
|
|
1067
|
+
rmSync as rmSync2,
|
|
1068
|
+
statSync as statSync2,
|
|
1069
|
+
writeFileSync as writeFileSync4
|
|
733
1070
|
} from "fs";
|
|
734
1071
|
var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
|
|
735
1072
|
var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
|
|
@@ -746,16 +1083,16 @@ var Registry = class _Registry {
|
|
|
746
1083
|
home;
|
|
747
1084
|
now;
|
|
748
1085
|
static open(home, now2 = () => /* @__PURE__ */ new Date()) {
|
|
749
|
-
|
|
1086
|
+
mkdirSync5(home, { recursive: true });
|
|
750
1087
|
return new _Registry(home, now2);
|
|
751
1088
|
}
|
|
752
1089
|
/** The registry as it is on disk. A file that does not parse is refused, never replaced. */
|
|
753
1090
|
read() {
|
|
754
1091
|
const file = registryFile(this.home);
|
|
755
|
-
if (!
|
|
1092
|
+
if (!existsSync6(file)) return structuredClone(EMPTY);
|
|
756
1093
|
let parsed;
|
|
757
1094
|
try {
|
|
758
|
-
parsed = JSON.parse(
|
|
1095
|
+
parsed = JSON.parse(readFileSync4(file, "utf8"));
|
|
759
1096
|
} catch (err) {
|
|
760
1097
|
throw new WorkkitError(
|
|
761
1098
|
"INTERNAL",
|
|
@@ -785,12 +1122,12 @@ var Registry = class _Registry {
|
|
|
785
1122
|
const started = Date.now();
|
|
786
1123
|
for (; ; ) {
|
|
787
1124
|
try {
|
|
788
|
-
|
|
1125
|
+
mkdirSync5(lock);
|
|
789
1126
|
break;
|
|
790
1127
|
} catch {
|
|
791
1128
|
try {
|
|
792
|
-
if (Date.now() -
|
|
793
|
-
|
|
1129
|
+
if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
|
|
1130
|
+
rmSync2(lock, { recursive: true, force: true });
|
|
794
1131
|
continue;
|
|
795
1132
|
}
|
|
796
1133
|
} catch {
|
|
@@ -810,12 +1147,12 @@ var Registry = class _Registry {
|
|
|
810
1147
|
const out = fn(r);
|
|
811
1148
|
const file = registryFile(this.home);
|
|
812
1149
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
813
|
-
|
|
1150
|
+
writeFileSync4(tmp, `${JSON.stringify(r, null, 2)}
|
|
814
1151
|
`);
|
|
815
1152
|
renameSync(tmp, file);
|
|
816
1153
|
return out;
|
|
817
1154
|
} finally {
|
|
818
|
-
|
|
1155
|
+
rmSync2(lock, { recursive: true, force: true });
|
|
819
1156
|
}
|
|
820
1157
|
}
|
|
821
1158
|
project(name) {
|
|
@@ -836,18 +1173,29 @@ var Registry = class _Registry {
|
|
|
836
1173
|
(e) => Object.entries(filter).every(([k, v]) => e[k] === v)
|
|
837
1174
|
);
|
|
838
1175
|
}
|
|
839
|
-
activeEffortFor(project, area) {
|
|
840
|
-
return this.efforts({ project, area, state: "active" })
|
|
1176
|
+
activeEffortFor(project, area, task) {
|
|
1177
|
+
return this.efforts({ project, area, state: "active" }).find(
|
|
1178
|
+
(e) => (e.task ?? void 0) === (task ?? void 0)
|
|
1179
|
+
);
|
|
841
1180
|
}
|
|
842
|
-
/** An effort that is being started counts as open: nobody else may start on the area meanwhile. */
|
|
843
|
-
openEffortFor(project, area) {
|
|
1181
|
+
/** An effort that is being started counts as open: nobody else may start on the same (area, task) meanwhile. */
|
|
1182
|
+
openEffortFor(project, area, task) {
|
|
844
1183
|
return this.efforts({ project, area }).find(
|
|
845
|
-
(e) => e.state === "starting" || e.state === "active"
|
|
1184
|
+
(e) => (e.state === "starting" || e.state === "active") && (e.task ?? void 0) === (task ?? void 0)
|
|
846
1185
|
);
|
|
847
1186
|
}
|
|
848
1187
|
addNotice(project, area, notice) {
|
|
849
1188
|
this.update((r) => {
|
|
850
|
-
|
|
1189
|
+
const existing = r.notices ?? [];
|
|
1190
|
+
const dup = existing.findIndex(
|
|
1191
|
+
(n) => n.project === project && n.area === area && n.notice.from === notice.from && n.notice.text === notice.text
|
|
1192
|
+
);
|
|
1193
|
+
if (dup >= 0) {
|
|
1194
|
+
existing[dup] = { project, area, notice };
|
|
1195
|
+
r.notices = existing;
|
|
1196
|
+
} else {
|
|
1197
|
+
r.notices = [...existing, { project, area, notice }];
|
|
1198
|
+
}
|
|
851
1199
|
});
|
|
852
1200
|
}
|
|
853
1201
|
notices(project, area) {
|
|
@@ -858,6 +1206,21 @@ var Registry = class _Registry {
|
|
|
858
1206
|
r.notices = (r.notices ?? []).filter((n) => !(n.project === project && n.area === area));
|
|
859
1207
|
});
|
|
860
1208
|
}
|
|
1209
|
+
clearNoticesFrom(project, branches) {
|
|
1210
|
+
const set = new Set(branches);
|
|
1211
|
+
this.update((r) => {
|
|
1212
|
+
r.notices = (r.notices ?? []).filter(
|
|
1213
|
+
(n) => !(n.project === project && set.has(n.notice.from))
|
|
1214
|
+
);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
pruneNoticesWhere(project, area, remove) {
|
|
1218
|
+
this.update((r) => {
|
|
1219
|
+
r.notices = (r.notices ?? []).filter(
|
|
1220
|
+
(n) => !(n.project === project && n.area === area && remove(n.notice))
|
|
1221
|
+
);
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
861
1224
|
allocateSlot(project, m, owner, effort, exclude = /* @__PURE__ */ new Set()) {
|
|
862
1225
|
return this.update((r) => {
|
|
863
1226
|
const taken = new Set(
|
|
@@ -958,10 +1321,10 @@ var Registry = class _Registry {
|
|
|
958
1321
|
}
|
|
959
1322
|
};
|
|
960
1323
|
|
|
961
|
-
//
|
|
1324
|
+
// src/core/runner.ts
|
|
962
1325
|
import { spawn, spawnSync as spawnSync3 } from "child_process";
|
|
963
1326
|
import { closeSync, openSync } from "fs";
|
|
964
|
-
import { join as
|
|
1327
|
+
import { join as join7 } from "path";
|
|
965
1328
|
var DEFAULT_HEALTH_TIMEOUT_MS = 18e4;
|
|
966
1329
|
var HEALTH_POLL_MS = 1e3;
|
|
967
1330
|
var PROBE_CAP_MS = 1e4;
|
|
@@ -980,6 +1343,10 @@ function pidSignature(pid) {
|
|
|
980
1343
|
const r = spawnSync3("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
|
|
981
1344
|
return r.status === 0 ? (r.stdout ?? "").trim() : "";
|
|
982
1345
|
}
|
|
1346
|
+
function serviceModes(name, spec) {
|
|
1347
|
+
if (spec.modes && spec.modes.length > 0) return spec.modes;
|
|
1348
|
+
return name === "frontend" ? ["fullstack"] : ["backend", "fullstack"];
|
|
1349
|
+
}
|
|
983
1350
|
function probe(spec, cwd, env, timeoutMs = PROBE_CAP_MS) {
|
|
984
1351
|
const r = spawnSync3("sh", ["-c", spec.health], {
|
|
985
1352
|
cwd,
|
|
@@ -1013,7 +1380,7 @@ async function waitHealthy(o) {
|
|
|
1013
1380
|
);
|
|
1014
1381
|
}
|
|
1015
1382
|
function spawnService(name, spec, a) {
|
|
1016
|
-
const fd = openSync(
|
|
1383
|
+
const fd = openSync(join7(a.logDir, `${name}.log`), "a");
|
|
1017
1384
|
try {
|
|
1018
1385
|
const child = spawn("sh", ["-c", spec.start], {
|
|
1019
1386
|
cwd: a.cwd,
|
|
@@ -1035,7 +1402,12 @@ function spawnService(name, spec, a) {
|
|
|
1035
1402
|
async function startServices(a) {
|
|
1036
1403
|
const pids = {};
|
|
1037
1404
|
const signatures = {};
|
|
1038
|
-
|
|
1405
|
+
const targetMode = a.mode ?? "fullstack";
|
|
1406
|
+
const entries = Object.entries(a.m.services).filter(([name, spec]) => {
|
|
1407
|
+
if (a.onlyServices) return a.onlyServices.includes(name);
|
|
1408
|
+
return serviceModes(name, spec).includes(targetMode);
|
|
1409
|
+
});
|
|
1410
|
+
for (const [name, spec] of entries) {
|
|
1039
1411
|
a.onLog?.(`starting ${name}: ${spec.start}`);
|
|
1040
1412
|
pids[name] = spawnService(name, spec, a);
|
|
1041
1413
|
signatures[name] = pidSignature(pids[name]);
|
|
@@ -1099,7 +1471,7 @@ async function stopServices(o) {
|
|
|
1099
1471
|
while (alive().length > 0 && Date.now() < hardDeadline) await sleep2(100);
|
|
1100
1472
|
}
|
|
1101
1473
|
|
|
1102
|
-
//
|
|
1474
|
+
// src/core/session.ts
|
|
1103
1475
|
import { hostname, userInfo } from "os";
|
|
1104
1476
|
function currentOwner(env = process.env) {
|
|
1105
1477
|
const named = [env.WORK_SESSION, env.CLAUDE_SESSION_NAME, env.CLAUDE_CODE_SESSION_ID].find(
|
|
@@ -1108,11 +1480,19 @@ function currentOwner(env = process.env) {
|
|
|
1108
1480
|
return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
|
|
1109
1481
|
}
|
|
1110
1482
|
|
|
1111
|
-
// src/lib/context.ts
|
|
1112
|
-
import chalk from "chalk";
|
|
1113
|
-
|
|
1114
1483
|
// src/lib/storybrokr.ts
|
|
1115
1484
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
1485
|
+
var MIN_STORYBROKR_VERSION = "0.3.0";
|
|
1486
|
+
var versionChecked = false;
|
|
1487
|
+
function compareSemver(a, b) {
|
|
1488
|
+
const pa = a.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1489
|
+
const pb = b.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1490
|
+
for (let i = 0; i < 3; i++) {
|
|
1491
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1492
|
+
if (diff !== 0) return diff;
|
|
1493
|
+
}
|
|
1494
|
+
return 0;
|
|
1495
|
+
}
|
|
1116
1496
|
function run(args) {
|
|
1117
1497
|
const r = spawnSync4("storybrokr", args, { encoding: "utf8" });
|
|
1118
1498
|
if (r.error) {
|
|
@@ -1123,9 +1503,24 @@ function run(args) {
|
|
|
1123
1503
|
}
|
|
1124
1504
|
return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
|
|
1125
1505
|
}
|
|
1506
|
+
function assertStorybrokrVersion() {
|
|
1507
|
+
if (versionChecked) return;
|
|
1508
|
+
const r = run(["--version"]);
|
|
1509
|
+
const found = r.out.trim();
|
|
1510
|
+
if (r.code !== 0 || compareSemver(found, MIN_STORYBROKR_VERSION) < 0) {
|
|
1511
|
+
throw new WorkkitError(
|
|
1512
|
+
"STORYBROKR_FAILED",
|
|
1513
|
+
`storybrokr >= ${MIN_STORYBROKR_VERSION} required (found ${found || "unknown"}); upgrade @helmsmith/storybrokr`
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
versionChecked = true;
|
|
1517
|
+
}
|
|
1126
1518
|
var storybrokrCli = {
|
|
1127
|
-
async up(story, hostRoot) {
|
|
1128
|
-
|
|
1519
|
+
async up(story, hostRoot, opts) {
|
|
1520
|
+
assertStorybrokrVersion();
|
|
1521
|
+
const args = ["up", story, "--host", hostRoot, "--json"];
|
|
1522
|
+
if (opts?.ttlMinutes !== void 0) args.push("--ttl", String(opts.ttlMinutes));
|
|
1523
|
+
const r = run(args);
|
|
1129
1524
|
if (r.code !== 0) {
|
|
1130
1525
|
throw new WorkkitError(
|
|
1131
1526
|
"STORYBROKR_FAILED",
|
|
@@ -1139,6 +1534,14 @@ var storybrokrCli = {
|
|
|
1139
1534
|
const r = run(["down", id]);
|
|
1140
1535
|
if (r.code !== 0)
|
|
1141
1536
|
throw new WorkkitError("STORYBROKR_FAILED", `storybrokr down ${id} failed: ${r.err.trim()}`);
|
|
1537
|
+
},
|
|
1538
|
+
async downHost(hostRoot) {
|
|
1539
|
+
const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--json"], { encoding: "utf8" });
|
|
1540
|
+
void r;
|
|
1541
|
+
},
|
|
1542
|
+
async touch(id) {
|
|
1543
|
+
const r = spawnSync4("storybrokr", ["touch", id], { encoding: "utf8" });
|
|
1544
|
+
void r;
|
|
1142
1545
|
}
|
|
1143
1546
|
};
|
|
1144
1547
|
|
|
@@ -1170,26 +1573,32 @@ async function buildCtx(opts = {}) {
|
|
|
1170
1573
|
import {
|
|
1171
1574
|
appendFileSync as appendFileSync2,
|
|
1172
1575
|
cpSync,
|
|
1173
|
-
existsSync as
|
|
1174
|
-
mkdirSync as
|
|
1576
|
+
existsSync as existsSync7,
|
|
1577
|
+
mkdirSync as mkdirSync6,
|
|
1175
1578
|
readdirSync,
|
|
1176
1579
|
realpathSync as realpathSync4,
|
|
1177
|
-
rmSync as
|
|
1178
|
-
statSync as
|
|
1580
|
+
rmSync as rmSync3,
|
|
1581
|
+
statSync as statSync3
|
|
1179
1582
|
} from "fs";
|
|
1180
1583
|
import { connect, createServer } from "net";
|
|
1181
|
-
import { dirname as
|
|
1182
|
-
var worktreePathFor = (ctx, area) =>
|
|
1183
|
-
var branchFor = (type, area) => `${type}/${area}`;
|
|
1584
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
1585
|
+
var worktreePathFor = (ctx, area, task) => task ? join8(ctx.repoRoot, ctx.manifest.worktreeRoot, "_tasks", `${area}--${task}`) : join8(ctx.repoRoot, ctx.manifest.worktreeRoot, area);
|
|
1586
|
+
var branchFor = (type, area, task) => task ? `${type}/${area}--${task}` : `${type}/${area}`;
|
|
1587
|
+
var effortLabel2 = (area, task) => task ? `${area}--${task}` : area;
|
|
1184
1588
|
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1185
1589
|
var STARTING_GRACE_MS = 30 * 6e4;
|
|
1186
|
-
var
|
|
1590
|
+
var JOIN_WAIT_MS = 6e4;
|
|
1591
|
+
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1592
|
+
var realpathOr2 = (p) => {
|
|
1187
1593
|
try {
|
|
1188
1594
|
return realpathSync4(p);
|
|
1189
1595
|
} catch {
|
|
1190
1596
|
return p;
|
|
1191
1597
|
}
|
|
1192
1598
|
};
|
|
1599
|
+
function fetchBaseOnce(ctx) {
|
|
1600
|
+
new Git(ctx.repoRoot).tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
|
|
1601
|
+
}
|
|
1193
1602
|
function hoursFor(m, kind) {
|
|
1194
1603
|
return {
|
|
1195
1604
|
slot: m.expiry.slotHours,
|
|
@@ -1197,16 +1606,28 @@ function hoursFor(m, kind) {
|
|
|
1197
1606
|
commons: m.expiry.commonsHours
|
|
1198
1607
|
}[kind];
|
|
1199
1608
|
}
|
|
1609
|
+
function modeCovers(existing, requested) {
|
|
1610
|
+
if (existing === "fullstack") return true;
|
|
1611
|
+
return existing === requested;
|
|
1612
|
+
}
|
|
1613
|
+
var STORYBROKR_TOUCH_THROTTLE_MS = 6e4;
|
|
1200
1614
|
function touchEffort(ctx, effort) {
|
|
1201
|
-
|
|
1615
|
+
const prevMs = Date.parse(effort.lastActivity);
|
|
1616
|
+
const shouldTouchStorybrokr = Number.isNaN(prevMs) || Date.now() - prevMs >= STORYBROKR_TOUCH_THROTTLE_MS;
|
|
1617
|
+
for (const l of ctx.registry.leases({ effort: effort.id })) {
|
|
1202
1618
|
ctx.registry.touch(l.id, hoursFor(ctx.manifest, l.kind));
|
|
1619
|
+
if (l.kind === "storybook" && shouldTouchStorybrokr) {
|
|
1620
|
+
void Promise.resolve(ctx.storybrokr.touch?.(String(l.value))).catch(() => {
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1203
1624
|
ctx.registry.updateEffort(effort.id, { lastActivity: now() });
|
|
1204
1625
|
}
|
|
1205
1626
|
function portBusy(port) {
|
|
1206
|
-
return new Promise((
|
|
1627
|
+
return new Promise((resolve4) => {
|
|
1207
1628
|
const srv = createServer();
|
|
1208
|
-
srv.once("error", () =>
|
|
1209
|
-
srv.listen(port, "127.0.0.1", () => srv.close(() =>
|
|
1629
|
+
srv.once("error", () => resolve4(true));
|
|
1630
|
+
srv.listen(port, "127.0.0.1", () => srv.close(() => resolve4(false)));
|
|
1210
1631
|
});
|
|
1211
1632
|
}
|
|
1212
1633
|
async function busyPorts(ports) {
|
|
@@ -1216,11 +1637,11 @@ async function busyPorts(ports) {
|
|
|
1216
1637
|
return out;
|
|
1217
1638
|
}
|
|
1218
1639
|
function portAnswers(port) {
|
|
1219
|
-
return new Promise((
|
|
1640
|
+
return new Promise((resolve4) => {
|
|
1220
1641
|
const sock = connect({ port, host: "127.0.0.1" });
|
|
1221
1642
|
const done = (v) => {
|
|
1222
1643
|
sock.destroy();
|
|
1223
|
-
|
|
1644
|
+
resolve4(v);
|
|
1224
1645
|
};
|
|
1225
1646
|
sock.setTimeout(300, () => done(false));
|
|
1226
1647
|
sock.once("connect", () => done(true));
|
|
@@ -1231,6 +1652,23 @@ async function anyPortAnswers(ports) {
|
|
|
1231
1652
|
for (const port of Object.values(ports ?? {})) if (await portAnswers(port)) return true;
|
|
1232
1653
|
return false;
|
|
1233
1654
|
}
|
|
1655
|
+
async function claimFreeSlot(ctx, effortId, warnings) {
|
|
1656
|
+
const m = ctx.manifest;
|
|
1657
|
+
const exclude = /* @__PURE__ */ new Set();
|
|
1658
|
+
const busyNotes = [];
|
|
1659
|
+
for (; ; ) {
|
|
1660
|
+
const candidate = ctx.registry.allocateSlot(m.project, m, ctx.owner, effortId, exclude);
|
|
1661
|
+
const busy = await busyPorts(candidate.ports ?? {});
|
|
1662
|
+
if (busy.length === 0) {
|
|
1663
|
+
if (busyNotes.length) warnings.push(...busyNotes);
|
|
1664
|
+
return candidate;
|
|
1665
|
+
}
|
|
1666
|
+
ctx.registry.release(candidate.id);
|
|
1667
|
+
exclude.add(candidate.value);
|
|
1668
|
+
busyNotes.push(`slot ${candidate.value}: ${busy.join(", ")} already in use`);
|
|
1669
|
+
ctx.log(busyNotes[busyNotes.length - 1]);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1234
1672
|
function guardStart(ctx, o) {
|
|
1235
1673
|
const m = ctx.manifest;
|
|
1236
1674
|
const spec = m.areas[o.area];
|
|
@@ -1240,28 +1678,37 @@ function guardStart(ctx, o) {
|
|
|
1240
1678
|
`unknown area "${o.area}" (known: ${Object.keys(m.areas).join(", ")})`,
|
|
1241
1679
|
{ area: o.area }
|
|
1242
1680
|
);
|
|
1681
|
+
if (o.task !== void 0 && !TASK_SLUG.test(o.task))
|
|
1682
|
+
throw new WorkkitError(
|
|
1683
|
+
"TASK_INVALID",
|
|
1684
|
+
`invalid task slug "${o.task}" (must match /^[a-z0-9][a-z0-9_-]{0,47}$/ with no "--")`,
|
|
1685
|
+
{ task: o.task }
|
|
1686
|
+
);
|
|
1243
1687
|
if (!m.branch.types.includes(o.type))
|
|
1244
1688
|
throw new WorkkitError(
|
|
1245
1689
|
"TYPE_UNKNOWN",
|
|
1246
1690
|
`unknown type "${o.type}" (allowed: ${m.branch.types.join(", ")})`,
|
|
1247
1691
|
{ type: o.type }
|
|
1248
1692
|
);
|
|
1249
|
-
if (o.mode !== "frontend" && o.mode !== "fullstack")
|
|
1250
|
-
throw new WorkkitError(
|
|
1251
|
-
|
|
1693
|
+
if (o.mode !== "frontend" && o.mode !== "backend" && o.mode !== "fullstack")
|
|
1694
|
+
throw new WorkkitError(
|
|
1695
|
+
"MODE_UNKNOWN",
|
|
1696
|
+
`unknown mode "${o.mode}" (frontend | backend | fullstack)`
|
|
1697
|
+
);
|
|
1698
|
+
const branch = branchFor(o.type, o.area, o.task);
|
|
1252
1699
|
assertNotProtected(m, branch);
|
|
1253
|
-
const scopes = expandScopes(m, o.area, o.mode);
|
|
1700
|
+
const scopes = expandScopes(m, o.area, o.mode, o.scopes, Boolean(o.task));
|
|
1254
1701
|
const commons = spec.commons ?? [];
|
|
1255
1702
|
const warnings = [];
|
|
1256
1703
|
if (commons.length > 0) {
|
|
1257
1704
|
for (const e of ctx.registry.efforts({ project: m.project })) {
|
|
1258
|
-
if (e.area === o.area || e.state !== "active" && e.state !== "starting" || !
|
|
1705
|
+
if (e.area === o.area && (e.task ?? void 0) === (o.task ?? void 0) || e.state !== "active" && e.state !== "starting" || !existsSync7(e.worktree))
|
|
1259
1706
|
continue;
|
|
1260
1707
|
const changed = new Git(e.worktree).tryRun(["diff", "--name-only", "--no-renames", `origin/${m.baseBranch}...HEAD`]).out.split("\n").filter(Boolean);
|
|
1261
1708
|
const hit = commonsTouched(m, changed).filter((c) => commons.includes(c));
|
|
1262
1709
|
if (hit.length > 0)
|
|
1263
1710
|
warnings.push(
|
|
1264
|
-
`${e.area} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
|
|
1711
|
+
`${effortLabel2(e.area, e.task)} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
|
|
1265
1712
|
);
|
|
1266
1713
|
}
|
|
1267
1714
|
}
|
|
@@ -1273,10 +1720,11 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
|
|
|
1273
1720
|
id: newId("eff"),
|
|
1274
1721
|
project: m.project,
|
|
1275
1722
|
area: o.area,
|
|
1723
|
+
...o.task ? { task: o.task } : {},
|
|
1276
1724
|
type: o.type,
|
|
1277
1725
|
mode: o.mode,
|
|
1278
1726
|
branch,
|
|
1279
|
-
worktree: worktreePathFor(ctx, o.area),
|
|
1727
|
+
worktree: worktreePathFor(ctx, o.area, o.task),
|
|
1280
1728
|
owner: ctx.owner,
|
|
1281
1729
|
state: "starting",
|
|
1282
1730
|
scopes,
|
|
@@ -1290,21 +1738,25 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
|
|
|
1290
1738
|
const open = r.efforts.filter(
|
|
1291
1739
|
(e) => e.project === m.project && (e.state === "active" || e.state === "starting")
|
|
1292
1740
|
);
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1741
|
+
const onTarget = open.find(
|
|
1742
|
+
(e) => e.area === o.area && (e.task ?? void 0) === (o.task ?? void 0)
|
|
1743
|
+
);
|
|
1744
|
+
if (onTarget) {
|
|
1745
|
+
const label = effortLabel2(o.area, o.task);
|
|
1295
1746
|
throw new WorkkitError(
|
|
1296
1747
|
"EFFORT_OPEN",
|
|
1297
|
-
`an effort is open on ${
|
|
1298
|
-
{ owner:
|
|
1748
|
+
`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`,
|
|
1749
|
+
{ owner: onTarget.owner.session, effort: onTarget.id, branch: onTarget.branch }
|
|
1299
1750
|
);
|
|
1300
1751
|
}
|
|
1301
1752
|
for (const e of open) {
|
|
1302
1753
|
const pairs = globsOverlap(scopes, e.scopes);
|
|
1303
1754
|
if (pairs.length > 0) {
|
|
1755
|
+
const otherLabel = effortLabel2(e.area, e.task);
|
|
1304
1756
|
throw new WorkkitError(
|
|
1305
1757
|
"SCOPE_CONFLICT",
|
|
1306
|
-
`scope overlaps the open effort on ${
|
|
1307
|
-
{ with:
|
|
1758
|
+
`scope overlaps the open effort on ${otherLabel} (${e.branch}, owner ${e.owner.session}): ${pairs.join("; ")}`,
|
|
1759
|
+
{ with: otherLabel, owner: e.owner.session, effort: e.id, pairs }
|
|
1308
1760
|
);
|
|
1309
1761
|
}
|
|
1310
1762
|
}
|
|
@@ -1312,44 +1764,52 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
|
|
|
1312
1764
|
});
|
|
1313
1765
|
return effort;
|
|
1314
1766
|
}
|
|
1315
|
-
function ensureWorktree(ctx, area, branch) {
|
|
1767
|
+
function ensureWorktree(ctx, area, branch, task) {
|
|
1316
1768
|
const m = ctx.manifest;
|
|
1317
|
-
const path = worktreePathFor(ctx, area);
|
|
1769
|
+
const path = worktreePathFor(ctx, area, task);
|
|
1318
1770
|
const main = new Git(ctx.repoRoot);
|
|
1319
|
-
|
|
1320
|
-
if (!existsSync5(path)) {
|
|
1771
|
+
if (!existsSync7(path)) {
|
|
1321
1772
|
main.worktreeAdd(path, branch, `origin/${m.baseBranch}`);
|
|
1322
|
-
|
|
1773
|
+
const wg2 = new Git(path);
|
|
1774
|
+
wg2.repairSubmodules(ctx.repoRoot, ctx.log);
|
|
1775
|
+
return { wg: wg2, created: true };
|
|
1323
1776
|
}
|
|
1324
|
-
const registered = main.run(["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).map((l) =>
|
|
1325
|
-
const real =
|
|
1777
|
+
const registered = main.run(["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).map((l) => realpathOr2(l.slice(9)));
|
|
1778
|
+
const real = realpathOr2(path);
|
|
1326
1779
|
const wg = new Git(path);
|
|
1327
1780
|
const topLevel = wg.tryRun(["rev-parse", "--show-toplevel"]);
|
|
1328
|
-
if (!registered.includes(real) || topLevel.code !== 0 ||
|
|
1781
|
+
if (!registered.includes(real) || topLevel.code !== 0 || realpathOr2(topLevel.out.trim()) !== real || wg.isMainCheckout()) {
|
|
1329
1782
|
throw new WorkkitError(
|
|
1330
1783
|
"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`,
|
|
1784
|
+
`${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
1785
|
{ worktree: path }
|
|
1333
1786
|
);
|
|
1334
1787
|
}
|
|
1788
|
+
wg.repairSubmodules(ctx.repoRoot, ctx.log);
|
|
1335
1789
|
const current = wg.currentBranch();
|
|
1336
1790
|
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
1791
|
if (wg.isDirty())
|
|
1345
1792
|
throw new WorkkitError(
|
|
1346
1793
|
"DIRTY_WORKTREE",
|
|
1347
1794
|
`${path} has uncommitted changes on ${current}; commit or park them before switching to ${branch}`,
|
|
1348
1795
|
{ worktree: path }
|
|
1349
1796
|
);
|
|
1797
|
+
const unshipped = wg.commitsNotOn(m.baseBranch);
|
|
1798
|
+
if (unshipped.length > 0 && !wg.isEquivalentToBase(m.baseBranch)) {
|
|
1799
|
+
throw new WorkkitError(
|
|
1800
|
+
"EFFORT_OPEN",
|
|
1801
|
+
`${current} holds ${unshipped.length} commit(s) not yet on ${m.baseBranch}: ship or finish it first, or --join it`,
|
|
1802
|
+
{ branch: current, area }
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1350
1805
|
wg.checkout(branch, `origin/${m.baseBranch}`);
|
|
1806
|
+
if (!wg.isDirty() && wg.commitsNotOn(m.baseBranch).length > 0 && wg.isEquivalentToBase(m.baseBranch)) {
|
|
1807
|
+
wg.run(["reset", "--hard", `origin/${m.baseBranch}`]);
|
|
1808
|
+
}
|
|
1809
|
+
} else if (!wg.isDirty() && wg.commitsNotOn(m.baseBranch).length > 0 && wg.isEquivalentToBase(m.baseBranch)) {
|
|
1810
|
+
wg.run(["reset", "--hard", `origin/${m.baseBranch}`]);
|
|
1351
1811
|
}
|
|
1352
|
-
return wg;
|
|
1812
|
+
return { wg, created: false };
|
|
1353
1813
|
}
|
|
1354
1814
|
function appendLog(file, line) {
|
|
1355
1815
|
appendFileSync2(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
@@ -1357,10 +1817,57 @@ function appendLog(file, line) {
|
|
|
1357
1817
|
}
|
|
1358
1818
|
async function startEffort(ctx, o) {
|
|
1359
1819
|
const m = ctx.manifest;
|
|
1820
|
+
fetchBaseOnce(ctx);
|
|
1360
1821
|
const { branch, scopes, commons, warnings } = guardStart(ctx, o);
|
|
1361
1822
|
const notices = ctx.registry.notices(m.project, o.area);
|
|
1362
|
-
|
|
1823
|
+
let open = ctx.registry.openEffortFor(m.project, o.area, o.task);
|
|
1363
1824
|
if (open && o.join) {
|
|
1825
|
+
const deadline = Date.now() + JOIN_WAIT_MS;
|
|
1826
|
+
while (open && open.state === "starting" && Date.now() < deadline) {
|
|
1827
|
+
await sleep3(200);
|
|
1828
|
+
open = ctx.registry.openEffortFor(m.project, o.area, o.task);
|
|
1829
|
+
}
|
|
1830
|
+
if (!open || open.state !== "active") {
|
|
1831
|
+
throw new WorkkitError(
|
|
1832
|
+
"EFFORT_NOT_FOUND",
|
|
1833
|
+
`effort on ${effortLabel2(o.area, o.task)} did not become active in time to join`,
|
|
1834
|
+
{ area: o.area, task: o.task }
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
if (!modeCovers(open.mode, o.mode)) {
|
|
1838
|
+
const label = effortLabel2(o.area, o.task);
|
|
1839
|
+
throw new WorkkitError(
|
|
1840
|
+
"MODE_MISMATCH",
|
|
1841
|
+
`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`,
|
|
1842
|
+
{ area: o.area, task: o.task, openMode: open.mode, requestedMode: o.mode }
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
const currentLeases = ctx.registry.leases({ effort: open.id });
|
|
1846
|
+
if (o.story && !currentLeases.some((l) => l.kind === "storybook")) {
|
|
1847
|
+
const hostRoot = storybookHostFor(m, open.worktree);
|
|
1848
|
+
const inst = await ctx.storybrokr.up(o.story, hostRoot, {
|
|
1849
|
+
ttlMinutes: Math.ceil(m.expiry.storybookHours * 60)
|
|
1850
|
+
});
|
|
1851
|
+
const sbLease = ctx.registry.addLease(
|
|
1852
|
+
"storybook",
|
|
1853
|
+
m.project,
|
|
1854
|
+
inst.id,
|
|
1855
|
+
ctx.owner,
|
|
1856
|
+
open.id,
|
|
1857
|
+
m.expiry.storybookHours,
|
|
1858
|
+
{ ports: { storybook: inst.port } }
|
|
1859
|
+
);
|
|
1860
|
+
open = ctx.registry.updateEffort(open.id, {
|
|
1861
|
+
leases: [...open.leases, sbLease.id]
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
const existingParticipants = open.participants ?? [];
|
|
1865
|
+
const isAlreadyMember = open.owner.session === ctx.owner.session || existingParticipants.some((p) => p.session === ctx.owner.session);
|
|
1866
|
+
if (!isAlreadyMember) {
|
|
1867
|
+
open = ctx.registry.updateEffort(open.id, {
|
|
1868
|
+
participants: [...existingParticipants, ctx.owner]
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1364
1871
|
touchEffort(ctx, open);
|
|
1365
1872
|
return {
|
|
1366
1873
|
effort: open,
|
|
@@ -1386,6 +1893,8 @@ async function startEffort(ctx, o) {
|
|
|
1386
1893
|
signatures: l.signatures,
|
|
1387
1894
|
onLog: ctx.log
|
|
1388
1895
|
}).catch(() => void 0);
|
|
1896
|
+
} else if (stopPids && l.kind === "storybook") {
|
|
1897
|
+
await ctx.storybrokr.down(String(l.value)).catch(() => void 0);
|
|
1389
1898
|
}
|
|
1390
1899
|
ctx.registry.release(l.id);
|
|
1391
1900
|
}
|
|
@@ -1397,17 +1906,28 @@ async function startEffort(ctx, o) {
|
|
|
1397
1906
|
process.once("SIGINT", onSignal);
|
|
1398
1907
|
process.once("SIGTERM", onSignal);
|
|
1399
1908
|
try {
|
|
1400
|
-
const wg = ensureWorktree(ctx, o.area, branch);
|
|
1909
|
+
const { wg, created } = ensureWorktree(ctx, o.area, branch, o.task);
|
|
1401
1910
|
const worktree = wg.root();
|
|
1402
1911
|
ctx.registry.updateEffort(effort.id, { worktree });
|
|
1403
1912
|
effort.worktree = worktree;
|
|
1404
|
-
const logDir =
|
|
1405
|
-
|
|
1406
|
-
const refreshLog =
|
|
1913
|
+
const logDir = join8(worktree, m.logs.dir);
|
|
1914
|
+
mkdirSync6(logDir, { recursive: true });
|
|
1915
|
+
const refreshLog = join8(logDir, "refresh.log");
|
|
1916
|
+
const stampFile = join8(logDir, ".refresh-stamps.json");
|
|
1917
|
+
if (created && m.seed && m.seed.length > 0) {
|
|
1918
|
+
runRefresh(
|
|
1919
|
+
m.seed,
|
|
1920
|
+
worktree,
|
|
1921
|
+
(l) => {
|
|
1922
|
+
appendLog(refreshLog, l);
|
|
1923
|
+
ctx.log(l);
|
|
1924
|
+
},
|
|
1925
|
+
{ ...process.env, WORKTREE: worktree, MAIN_ROOT: ctx.repoRoot }
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1407
1928
|
let sync = "skipped";
|
|
1408
1929
|
let changed = [];
|
|
1409
1930
|
if (o.sync !== false) {
|
|
1410
|
-
wg.fetch(m.baseBranch);
|
|
1411
1931
|
const r = wg.syncFrom(m.baseBranch);
|
|
1412
1932
|
if (r.kind === "conflict") {
|
|
1413
1933
|
throw new WorkkitError(
|
|
@@ -1418,12 +1938,28 @@ async function startEffort(ctx, o) {
|
|
|
1418
1938
|
}
|
|
1419
1939
|
sync = r.kind;
|
|
1420
1940
|
if (r.kind === "ff" || r.kind === "merge") changed = wg.changedPaths(r.before, r.after);
|
|
1941
|
+
ctx.registry.pruneNoticesWhere(
|
|
1942
|
+
m.project,
|
|
1943
|
+
o.area,
|
|
1944
|
+
(n) => wg.isAncestor(`origin/${n.from}`, "HEAD") || wg.isAncestor(n.from, "HEAD")
|
|
1945
|
+
);
|
|
1421
1946
|
}
|
|
1422
|
-
const sel = selectRefresh(m.refresh, changed);
|
|
1423
|
-
runRefresh(
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1947
|
+
const sel = selectRefresh(m.refresh, changed, { worktree, stampFile });
|
|
1948
|
+
runRefresh(
|
|
1949
|
+
sel.runs,
|
|
1950
|
+
worktree,
|
|
1951
|
+
(l) => {
|
|
1952
|
+
appendLog(refreshLog, l);
|
|
1953
|
+
ctx.log(l);
|
|
1954
|
+
},
|
|
1955
|
+
process.env,
|
|
1956
|
+
{
|
|
1957
|
+
stampFile,
|
|
1958
|
+
pendingStamps: sel.pendingStamps,
|
|
1959
|
+
restoreFiles: sel.cleanWhenFiles
|
|
1960
|
+
}
|
|
1961
|
+
);
|
|
1962
|
+
const baseSha = wg.rev("HEAD");
|
|
1427
1963
|
for (const name of commons)
|
|
1428
1964
|
leases.push(
|
|
1429
1965
|
ctx.registry.addLease(
|
|
@@ -1435,25 +1971,9 @@ async function startEffort(ctx, o) {
|
|
|
1435
1971
|
m.expiry.commonsHours
|
|
1436
1972
|
)
|
|
1437
1973
|
);
|
|
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("; "));
|
|
1974
|
+
if (o.mode === "fullstack" || o.mode === "backend") {
|
|
1975
|
+
const slot = await claimFreeSlot(ctx, effort.id, warnings);
|
|
1455
1976
|
leases.push(slot);
|
|
1456
|
-
if (busyNotes.length) warnings.push(...busyNotes);
|
|
1457
1977
|
const cfg = emitWorktreeConfig({
|
|
1458
1978
|
m,
|
|
1459
1979
|
worktree,
|
|
@@ -1473,6 +1993,7 @@ async function startEffort(ctx, o) {
|
|
|
1473
1993
|
cwd: worktree,
|
|
1474
1994
|
env,
|
|
1475
1995
|
logDir,
|
|
1996
|
+
mode: o.mode,
|
|
1476
1997
|
onLog: ctx.log,
|
|
1477
1998
|
onSpawn: (service, pid, signature) => {
|
|
1478
1999
|
pids[service] = pid;
|
|
@@ -1487,8 +2008,10 @@ async function startEffort(ctx, o) {
|
|
|
1487
2008
|
} else {
|
|
1488
2009
|
let storybookPort;
|
|
1489
2010
|
if (o.story) {
|
|
1490
|
-
const hostRoot = m
|
|
1491
|
-
const inst = await ctx.storybrokr.up(o.story, hostRoot
|
|
2011
|
+
const hostRoot = storybookHostFor(m, worktree);
|
|
2012
|
+
const inst = await ctx.storybrokr.up(o.story, hostRoot, {
|
|
2013
|
+
ttlMinutes: Math.ceil(m.expiry.storybookHours * 60)
|
|
2014
|
+
});
|
|
1492
2015
|
storybookPort = inst.port;
|
|
1493
2016
|
leases.push(
|
|
1494
2017
|
ctx.registry.addLease(
|
|
@@ -1506,6 +2029,7 @@ async function startEffort(ctx, o) {
|
|
|
1506
2029
|
}
|
|
1507
2030
|
const active = ctx.registry.updateEffort(effort.id, {
|
|
1508
2031
|
state: "active",
|
|
2032
|
+
baseSha,
|
|
1509
2033
|
leases: leases.map((l) => l.id),
|
|
1510
2034
|
lastActivity: now()
|
|
1511
2035
|
});
|
|
@@ -1527,28 +2051,124 @@ async function startEffort(ctx, o) {
|
|
|
1527
2051
|
process.off("SIGTERM", onSignal);
|
|
1528
2052
|
}
|
|
1529
2053
|
}
|
|
2054
|
+
async function promoteEffort(ctx, o) {
|
|
2055
|
+
const m = ctx.manifest;
|
|
2056
|
+
if (o.mode !== "backend" && o.mode !== "fullstack") {
|
|
2057
|
+
throw new WorkkitError("MODE_UNKNOWN", `promote target mode must be backend or fullstack`);
|
|
2058
|
+
}
|
|
2059
|
+
const open = ctx.registry.openEffortFor(m.project, o.area, o.task);
|
|
2060
|
+
if (!open) {
|
|
2061
|
+
throw new WorkkitError(
|
|
2062
|
+
"EFFORT_NOT_FOUND",
|
|
2063
|
+
`no open effort on ${effortLabel2(o.area, o.task)} to promote`,
|
|
2064
|
+
{ area: o.area, task: o.task }
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
if (open.mode === "fullstack" && o.mode === "backend" || open.mode === "frontend" && o.mode === "backend") {
|
|
2068
|
+
throw new WorkkitError(
|
|
2069
|
+
"MODE_MISMATCH",
|
|
2070
|
+
`cannot transition effort on ${effortLabel2(o.area, o.task)} from ${open.mode} to ${o.mode}`,
|
|
2071
|
+
{ from: open.mode, to: o.mode }
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
const modeScopes = expandScopes(m, o.area, o.mode);
|
|
2075
|
+
const nextScopes = open.task ? [.../* @__PURE__ */ new Set([...open.scopes, ...modeScopes])] : modeScopes;
|
|
2076
|
+
ctx.registry.update((r) => {
|
|
2077
|
+
const others = r.efforts.filter(
|
|
2078
|
+
(e) => e.project === m.project && e.id !== open.id && (e.state === "active" || e.state === "starting")
|
|
2079
|
+
);
|
|
2080
|
+
for (const e of others) {
|
|
2081
|
+
const pairs = globsOverlap(nextScopes, e.scopes);
|
|
2082
|
+
if (pairs.length > 0) {
|
|
2083
|
+
const otherLabel = effortLabel2(e.area, e.task);
|
|
2084
|
+
throw new WorkkitError(
|
|
2085
|
+
"SCOPE_CONFLICT",
|
|
2086
|
+
`promoting ${effortLabel2(o.area, o.task)} to ${o.mode} overlaps ${otherLabel} (${e.branch}): ${pairs.join("; ")}`,
|
|
2087
|
+
{ with: otherLabel, owner: e.owner.session, effort: e.id, pairs }
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
const idx = r.efforts.findIndex((e) => e.id === open.id);
|
|
2092
|
+
if (idx >= 0) {
|
|
2093
|
+
r.efforts[idx] = { ...r.efforts[idx], mode: o.mode, scopes: nextScopes, lastActivity: now() };
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
const warnings = [];
|
|
2097
|
+
let slot = ctx.registry.leases({ effort: open.id, kind: "slot" })[0];
|
|
2098
|
+
const sb = ctx.registry.leases({ effort: open.id, kind: "storybook" })[0];
|
|
2099
|
+
if (!slot) {
|
|
2100
|
+
slot = await claimFreeSlot(ctx, open.id, warnings);
|
|
2101
|
+
ctx.registry.updateEffort(open.id, { leases: [...open.leases, slot.id] });
|
|
2102
|
+
}
|
|
2103
|
+
const cfg = emitWorktreeConfig({
|
|
2104
|
+
m,
|
|
2105
|
+
worktree: open.worktree,
|
|
2106
|
+
slot: slot.value,
|
|
2107
|
+
ports: slot.ports ?? {},
|
|
2108
|
+
storybookPort: sb?.ports?.storybook,
|
|
2109
|
+
area: o.area,
|
|
2110
|
+
branch: open.branch
|
|
2111
|
+
});
|
|
2112
|
+
const env = { ...process.env, ...cfg.vars };
|
|
2113
|
+
const logDir = join8(open.worktree, m.logs.dir);
|
|
2114
|
+
mkdirSync6(logDir, { recursive: true });
|
|
2115
|
+
const existingPids = { ...slot.pids ?? {} };
|
|
2116
|
+
const existingSigs = { ...slot.signatures ?? {} };
|
|
2117
|
+
const missingServices = Object.entries(m.services).filter(([name, spec]) => serviceModes(name, spec).includes(o.mode) && !existingPids[name]).map(([name]) => name);
|
|
2118
|
+
if (missingServices.length > 0) {
|
|
2119
|
+
const result = await ctx.runner.start({
|
|
2120
|
+
m,
|
|
2121
|
+
cwd: open.worktree,
|
|
2122
|
+
env,
|
|
2123
|
+
logDir,
|
|
2124
|
+
mode: o.mode,
|
|
2125
|
+
onlyServices: missingServices,
|
|
2126
|
+
onLog: ctx.log,
|
|
2127
|
+
onSpawn: (service, pid, signature) => {
|
|
2128
|
+
existingPids[service] = pid;
|
|
2129
|
+
existingSigs[service] = signature;
|
|
2130
|
+
ctx.registry.patchLease(slot.id, {
|
|
2131
|
+
pids: { ...existingPids },
|
|
2132
|
+
signatures: { ...existingSigs }
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
ctx.registry.patchLease(slot.id, {
|
|
2137
|
+
pids: { ...existingPids, ...result.pids },
|
|
2138
|
+
signatures: { ...existingSigs, ...result.signatures ?? {} }
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
const updated = ctx.registry.updateEffort(open.id, { lastActivity: now() });
|
|
2142
|
+
touchEffort(ctx, updated);
|
|
2143
|
+
return {
|
|
2144
|
+
effort: updated,
|
|
2145
|
+
leases: ctx.registry.leases({ effort: open.id }),
|
|
2146
|
+
startedServices: missingServices,
|
|
2147
|
+
warnings
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
1530
2150
|
function behindBase(ctx, worktree) {
|
|
1531
|
-
if (!
|
|
2151
|
+
if (!existsSync7(worktree)) return 0;
|
|
1532
2152
|
const g = new Git(worktree);
|
|
1533
|
-
g.tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
|
|
1534
2153
|
const r = g.tryRun(["rev-list", "--count", `HEAD..origin/${ctx.manifest.baseBranch}`]);
|
|
1535
2154
|
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
1536
2155
|
}
|
|
1537
|
-
function statusFor(ctx, area) {
|
|
2156
|
+
function statusFor(ctx, area, task) {
|
|
1538
2157
|
const m = ctx.manifest;
|
|
2158
|
+
fetchBaseOnce(ctx);
|
|
1539
2159
|
const names = (area ? [area] : Object.keys(m.areas)).sort();
|
|
1540
2160
|
const areas = names.map((name) => {
|
|
1541
|
-
const effort = ctx.registry.openEffortFor(m.project, name);
|
|
2161
|
+
const effort = ctx.registry.openEffortFor(m.project, name, area ? task : void 0);
|
|
1542
2162
|
if (effort && area) touchEffort(ctx, effort);
|
|
1543
|
-
const worktree = worktreePathFor(ctx, name);
|
|
2163
|
+
const worktree = worktreePathFor(ctx, name, area ? task : void 0);
|
|
1544
2164
|
const out = {
|
|
1545
|
-
area: name,
|
|
2165
|
+
area: area && task ? `${name}--${task}` : name,
|
|
1546
2166
|
leases: effort ? ctx.registry.leases({ effort: effort.id }) : [],
|
|
1547
2167
|
behindBase: behindBase(ctx, worktree),
|
|
1548
2168
|
notices: ctx.registry.notices(m.project, name)
|
|
1549
2169
|
};
|
|
1550
2170
|
if (effort) out.effort = effort;
|
|
1551
|
-
if (
|
|
2171
|
+
if (existsSync7(worktree)) out.worktree = worktree;
|
|
1552
2172
|
return out;
|
|
1553
2173
|
});
|
|
1554
2174
|
return { project: m.project, areas, leases: ctx.registry.leases({ project: m.project }) };
|
|
@@ -1556,14 +2176,14 @@ function statusFor(ctx, area) {
|
|
|
1556
2176
|
function slotEnv(m, slot) {
|
|
1557
2177
|
return slotVars(m, { slot, ports: portsForSlot(m, slot) });
|
|
1558
2178
|
}
|
|
1559
|
-
function effortEnv(ctx, area) {
|
|
2179
|
+
function effortEnv(ctx, area, task) {
|
|
1560
2180
|
const m = ctx.manifest;
|
|
1561
|
-
const effort = ctx.registry.openEffortFor(m.project, area);
|
|
2181
|
+
const effort = ctx.registry.openEffortFor(m.project, area, task);
|
|
1562
2182
|
if (!effort)
|
|
1563
2183
|
throw new WorkkitError(
|
|
1564
2184
|
"EFFORT_NOT_FOUND",
|
|
1565
|
-
`no open effort on ${area}; \`work start --area ${area} \u2026\` first`,
|
|
1566
|
-
{ area }
|
|
2185
|
+
`no open effort on ${effortLabel2(area, task)}; \`work start --area ${area} \u2026\` first`,
|
|
2186
|
+
{ area, task }
|
|
1567
2187
|
);
|
|
1568
2188
|
touchEffort(ctx, effort);
|
|
1569
2189
|
const slot = ctx.registry.leases({ effort: effort.id, kind: "slot" })[0];
|
|
@@ -1575,7 +2195,7 @@ function effortEnv(ctx, area) {
|
|
|
1575
2195
|
area,
|
|
1576
2196
|
branch: effort.branch,
|
|
1577
2197
|
worktree: effort.worktree,
|
|
1578
|
-
logDir:
|
|
2198
|
+
logDir: join8(effort.worktree, m.logs.dir)
|
|
1579
2199
|
});
|
|
1580
2200
|
return { ...vars, MODE: effort.mode };
|
|
1581
2201
|
}
|
|
@@ -1586,14 +2206,14 @@ function leaseEnv(ctx, effort, lease) {
|
|
|
1586
2206
|
area: effort.area,
|
|
1587
2207
|
branch: effort.branch,
|
|
1588
2208
|
worktree: effort.worktree,
|
|
1589
|
-
logDir:
|
|
2209
|
+
logDir: join8(effort.worktree, ctx.manifest.logs.dir)
|
|
1590
2210
|
});
|
|
1591
2211
|
return { ...process.env, ...vars };
|
|
1592
2212
|
}
|
|
1593
2213
|
async function releaseLease(ctx, effort, lease) {
|
|
1594
2214
|
try {
|
|
1595
2215
|
if (lease.kind === "slot" && lease.pids && Object.keys(lease.pids).length > 0) {
|
|
1596
|
-
const hasWorktree = effort !== void 0 &&
|
|
2216
|
+
const hasWorktree = effort !== void 0 && existsSync7(effort.worktree);
|
|
1597
2217
|
await ctx.runner.stop({
|
|
1598
2218
|
m: ctx.manifest,
|
|
1599
2219
|
cwd: hasWorktree && effort ? effort.worktree : ctx.repoRoot,
|
|
@@ -1630,29 +2250,109 @@ function leaveNotices(ctx, effort, commons) {
|
|
|
1630
2250
|
}
|
|
1631
2251
|
async function finishEffort(ctx, o) {
|
|
1632
2252
|
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];
|
|
2253
|
+
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
2254
|
if (!effort)
|
|
1635
|
-
throw new WorkkitError(
|
|
1636
|
-
|
|
1637
|
-
|
|
2255
|
+
throw new WorkkitError(
|
|
2256
|
+
"EFFORT_NOT_FOUND",
|
|
2257
|
+
`no open or parked effort on ${effortLabel2(o.area, o.task)}`,
|
|
2258
|
+
{ area: o.area, task: o.task }
|
|
2259
|
+
);
|
|
2260
|
+
if (!o.force && effort.participants && effort.participants.length > 0) {
|
|
2261
|
+
if (ctx.owner.session === effort.owner.session) {
|
|
2262
|
+
const [nextOwner, ...rest] = effort.participants;
|
|
2263
|
+
const updated = ctx.registry.updateEffort(effort.id, {
|
|
2264
|
+
owner: nextOwner,
|
|
2265
|
+
participants: rest,
|
|
2266
|
+
lastActivity: now()
|
|
2267
|
+
});
|
|
2268
|
+
touchEffort(ctx, updated);
|
|
2269
|
+
return { effort: updated, pushed: false, parked: false, left: true, notices: 0 };
|
|
2270
|
+
}
|
|
2271
|
+
if (effort.participants.some((p) => p.session === ctx.owner.session)) {
|
|
2272
|
+
const updated = ctx.registry.updateEffort(effort.id, {
|
|
2273
|
+
participants: effort.participants.filter((p) => p.session !== ctx.owner.session),
|
|
2274
|
+
lastActivity: now()
|
|
2275
|
+
});
|
|
2276
|
+
touchEffort(ctx, updated);
|
|
2277
|
+
return { effort: updated, pushed: false, parked: false, left: true, notices: 0 };
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
const label = effortLabel2(effort.area, effort.task);
|
|
1638
2281
|
const g = new Git(effort.worktree);
|
|
1639
2282
|
let parked = false;
|
|
1640
2283
|
if (g.isDirty()) {
|
|
1641
2284
|
if (!o.park) {
|
|
1642
2285
|
throw new WorkkitError(
|
|
1643
2286
|
"DIRTY_WORKTREE",
|
|
1644
|
-
`${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${
|
|
2287
|
+
`${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${label}) and push`,
|
|
1645
2288
|
{ worktree: effort.worktree }
|
|
1646
2289
|
);
|
|
1647
2290
|
}
|
|
1648
2291
|
g.run(["add", "-A"]);
|
|
1649
|
-
g.run(["commit", "-q", "-m", `wip(${
|
|
2292
|
+
g.run(["commit", "-q", "-m", `wip(${label}): parked ${now()}`]);
|
|
1650
2293
|
parked = true;
|
|
1651
2294
|
}
|
|
2295
|
+
if (o.intoArea) {
|
|
2296
|
+
if (!effort.task) {
|
|
2297
|
+
throw new WorkkitError(
|
|
2298
|
+
"MANIFEST_INVALID",
|
|
2299
|
+
"--into-area requires a task sub-effort (--task <slug>)",
|
|
2300
|
+
{ area: effort.area }
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
fetchBaseOnce(ctx);
|
|
2304
|
+
const parentBranch = branchFor(effort.type, effort.area);
|
|
2305
|
+
const { wg: areaGit } = ensureWorktree(ctx, effort.area, parentBranch);
|
|
2306
|
+
if (areaGit.isDirty()) {
|
|
2307
|
+
throw new WorkkitError(
|
|
2308
|
+
"DIRTY_WORKTREE",
|
|
2309
|
+
`parent area worktree ${areaGit.cwd} has uncommitted changes; commit or clean it before merging task ${effort.branch} --into-area`,
|
|
2310
|
+
{ worktree: areaGit.cwd }
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
const merged = areaGit.tryRun(["merge", "--no-edit", "-q", effort.branch]);
|
|
2314
|
+
if (merged.code !== 0) {
|
|
2315
|
+
const conflicted = areaGit.tryRun(["diff", "--name-only", "--diff-filter=U"]).out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
2316
|
+
areaGit.tryRun(["merge", "--abort"]);
|
|
2317
|
+
throw new WorkkitError(
|
|
2318
|
+
"SYNC_CONFLICT",
|
|
2319
|
+
`merging ${effort.branch} into ${parentBranch} conflicts in ${conflicted.join(", ")}; resolve in ${effort.worktree} before retrying \`work finish --into-area\``,
|
|
2320
|
+
{ conflicted, worktree: areaGit.cwd }
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
1652
2324
|
for (const lease of ctx.registry.leases({ effort: effort.id }))
|
|
1653
2325
|
await releaseLease(ctx, effort, lease);
|
|
1654
|
-
|
|
1655
|
-
|
|
2326
|
+
await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, effort.worktree))).catch(
|
|
2327
|
+
() => void 0
|
|
2328
|
+
);
|
|
2329
|
+
let notices = 0;
|
|
2330
|
+
if (!o.intoArea) {
|
|
2331
|
+
g.push(effort.branch);
|
|
2332
|
+
if (g.commitsNotOn(m.baseBranch).length > 0 && !g.isEquivalentToBase(m.baseBranch)) {
|
|
2333
|
+
const since = g.changedSince(m.baseBranch);
|
|
2334
|
+
const sinceSet = new Set(since);
|
|
2335
|
+
const changed = effort.baseSha ? g.changedPaths(effort.baseSha, "HEAD").filter((p) => sinceSet.has(p)) : since;
|
|
2336
|
+
notices = leaveNotices(ctx, effort, commonsTouched(m, changed));
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
if (effort.task) {
|
|
2340
|
+
archiveWorktree(ctx, label, effort.worktree);
|
|
2341
|
+
const main = new Git(ctx.repoRoot);
|
|
2342
|
+
main.worktreeRemove(effort.worktree);
|
|
2343
|
+
if (o.intoArea) {
|
|
2344
|
+
main.deleteLocalBranch(effort.branch);
|
|
2345
|
+
}
|
|
2346
|
+
const finished2 = { ...effort, state: "finished", lastActivity: now() };
|
|
2347
|
+
ctx.registry.removeEfforts((e) => e.id === effort.id);
|
|
2348
|
+
return {
|
|
2349
|
+
effort: finished2,
|
|
2350
|
+
pushed: !o.intoArea,
|
|
2351
|
+
parked,
|
|
2352
|
+
mergedIntoArea: Boolean(o.intoArea),
|
|
2353
|
+
notices
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
1656
2356
|
const finished = ctx.registry.updateEffort(effort.id, { state: "finished", lastActivity: now() });
|
|
1657
2357
|
return { effort: finished, pushed: true, parked, notices };
|
|
1658
2358
|
}
|
|
@@ -1667,7 +2367,7 @@ async function reapLeases(ctx, o = {}) {
|
|
|
1667
2367
|
if (effort?.state === "starting" && Date.now() - Date.parse(effort.started) < STARTING_GRACE_MS)
|
|
1668
2368
|
continue;
|
|
1669
2369
|
const expired = Date.parse(l.expires) < at.getTime();
|
|
1670
|
-
const orphaned = !effort || !
|
|
2370
|
+
const orphaned = !effort || !existsSync7(effort.worktree);
|
|
1671
2371
|
const garbage = orphaned && !await anyPortAnswers(l.ports);
|
|
1672
2372
|
if (expired || garbage) doomed.add(l.effort);
|
|
1673
2373
|
}
|
|
@@ -1676,48 +2376,53 @@ async function reapLeases(ctx, o = {}) {
|
|
|
1676
2376
|
for (const lease of reaped) await releaseLease(ctx, efforts.get(lease.effort), lease);
|
|
1677
2377
|
for (const id of doomed) {
|
|
1678
2378
|
const effort = efforts.get(id);
|
|
1679
|
-
if (effort
|
|
1680
|
-
ctx.
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
2379
|
+
if (effort) {
|
|
2380
|
+
await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, effort.worktree))).catch(
|
|
2381
|
+
() => void 0
|
|
2382
|
+
);
|
|
2383
|
+
if (effort.state === "active" || effort.state === "starting") {
|
|
2384
|
+
ctx.registry.updateEffort(effort.id, {
|
|
2385
|
+
state: "parked",
|
|
2386
|
+
parkedReason: `idle: a lease expired by ${at.toISOString()}`,
|
|
2387
|
+
lastActivity: now()
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
1685
2390
|
}
|
|
1686
2391
|
}
|
|
1687
2392
|
return { reaped };
|
|
1688
2393
|
}
|
|
1689
2394
|
var ARCHIVE_DIR = ".slot-archive";
|
|
1690
2395
|
function dirSize(path) {
|
|
1691
|
-
if (!
|
|
2396
|
+
if (!existsSync7(path)) return 0;
|
|
1692
2397
|
let total = 0;
|
|
1693
2398
|
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
1694
|
-
const p =
|
|
1695
|
-
total += entry.isDirectory() ? dirSize(p) :
|
|
2399
|
+
const p = join8(path, entry.name);
|
|
2400
|
+
total += entry.isDirectory() ? dirSize(p) : statSync3(p).size;
|
|
1696
2401
|
}
|
|
1697
2402
|
return total;
|
|
1698
2403
|
}
|
|
1699
2404
|
function archiveWorktree(ctx, area, worktree) {
|
|
1700
2405
|
const m = ctx.manifest;
|
|
1701
|
-
const areaRoot =
|
|
2406
|
+
const areaRoot = join8(ctx.repoRoot, ARCHIVE_DIR, area);
|
|
1702
2407
|
const stamp = now().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
1703
|
-
const target =
|
|
2408
|
+
const target = join8(areaRoot, stamp);
|
|
1704
2409
|
const archived = [];
|
|
1705
2410
|
for (const rel of m.archive.paths) {
|
|
1706
|
-
const src =
|
|
1707
|
-
if (!
|
|
1708
|
-
const dst =
|
|
1709
|
-
|
|
2411
|
+
const src = join8(worktree, rel);
|
|
2412
|
+
if (!existsSync7(src)) continue;
|
|
2413
|
+
const dst = join8(target, rel);
|
|
2414
|
+
mkdirSync6(dirname3(dst), { recursive: true });
|
|
1710
2415
|
cpSync(src, dst, { recursive: true });
|
|
1711
2416
|
archived.push(dst);
|
|
1712
2417
|
}
|
|
1713
2418
|
if (archived.length > 0) ensureExcluded(ctx.repoRoot, [`${ARCHIVE_DIR}/`]);
|
|
1714
|
-
if (
|
|
2419
|
+
if (existsSync7(areaRoot)) {
|
|
1715
2420
|
const stamps = readdirSync(areaRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse();
|
|
1716
2421
|
const cutoff = Date.now() - m.archive.keepDays * 864e5;
|
|
1717
2422
|
stamps.forEach((d, i) => {
|
|
1718
|
-
const t =
|
|
2423
|
+
const t = statSync3(join8(areaRoot, d)).mtimeMs;
|
|
1719
2424
|
if (i >= m.archive.keepPerArea || t < cutoff)
|
|
1720
|
-
|
|
2425
|
+
rmSync3(join8(areaRoot, d), { recursive: true, force: true });
|
|
1721
2426
|
});
|
|
1722
2427
|
}
|
|
1723
2428
|
return archived;
|
|
@@ -1726,17 +2431,20 @@ async function retireArea(ctx, o) {
|
|
|
1726
2431
|
const m = ctx.manifest;
|
|
1727
2432
|
if (!m.areas[o.area])
|
|
1728
2433
|
throw new WorkkitError("AREA_UNKNOWN", `unknown area "${o.area}"`, { area: o.area });
|
|
1729
|
-
const open = ctx.registry.
|
|
2434
|
+
const open = ctx.registry.efforts({ project: m.project, area: o.area }).find((e) => e.state === "starting" || e.state === "active");
|
|
1730
2435
|
if (open)
|
|
1731
2436
|
throw new WorkkitError(
|
|
1732
2437
|
"EFFORT_OPEN",
|
|
1733
|
-
`an effort is open on ${
|
|
2438
|
+
`an effort is open on ${effortLabel2(open.area, open.task)} (${open.owner.session}); \`work finish\` it first`,
|
|
1734
2439
|
{ owner: open.owner.session }
|
|
1735
2440
|
);
|
|
1736
|
-
const worktree = worktreePathFor(ctx, o.area);
|
|
2441
|
+
const worktree = realpathOr2(worktreePathFor(ctx, o.area));
|
|
1737
2442
|
const main = new Git(ctx.repoRoot);
|
|
1738
2443
|
let archived = [];
|
|
1739
|
-
|
|
2444
|
+
await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, worktree))).catch(
|
|
2445
|
+
() => void 0
|
|
2446
|
+
);
|
|
2447
|
+
if (existsSync7(worktree)) {
|
|
1740
2448
|
const g = new Git(worktree);
|
|
1741
2449
|
if (g.isDirty())
|
|
1742
2450
|
throw new WorkkitError(
|
|
@@ -1756,8 +2464,8 @@ async function retireArea(ctx, o) {
|
|
|
1756
2464
|
}
|
|
1757
2465
|
const branchesDeleted = [];
|
|
1758
2466
|
const branchesKept = [];
|
|
1759
|
-
|
|
1760
|
-
|
|
2467
|
+
const allAreaBranches = m.branch.types.map((type) => `${type}/${o.area}`);
|
|
2468
|
+
for (const branch of allAreaBranches) {
|
|
1761
2469
|
if (!main.branchExists(branch)) continue;
|
|
1762
2470
|
const onOrigin = main.remoteBranchExists(branch) && main.tryRun(["rev-list", "--count", `origin/${branch}..${branch}`]).out.trim() === "0";
|
|
1763
2471
|
if (onOrigin) {
|
|
@@ -1767,6 +2475,7 @@ async function retireArea(ctx, o) {
|
|
|
1767
2475
|
}
|
|
1768
2476
|
ctx.registry.removeEfforts((e) => e.project === m.project && e.area === o.area);
|
|
1769
2477
|
ctx.registry.clearNotices(m.project, o.area);
|
|
2478
|
+
ctx.registry.clearNoticesFrom(m.project, allAreaBranches);
|
|
1770
2479
|
return { archived, removed: worktree, branchesDeleted, branchesKept };
|
|
1771
2480
|
}
|
|
1772
2481
|
var BEHIND_LIMIT = 20;
|
|
@@ -1786,15 +2495,16 @@ function registeredWorktrees(ctx) {
|
|
|
1786
2495
|
}
|
|
1787
2496
|
}
|
|
1788
2497
|
if (current) out.push(current);
|
|
1789
|
-
const wtRoot =
|
|
1790
|
-
return out.filter((w) =>
|
|
2498
|
+
const wtRoot = join8(realpathOr2(ctx.repoRoot), ctx.manifest.worktreeRoot);
|
|
2499
|
+
return out.filter((w) => existsSync7(w.path) && realpathOr2(w.path).startsWith(wtRoot));
|
|
1791
2500
|
}
|
|
1792
2501
|
async function auditProject(ctx) {
|
|
1793
2502
|
const m = ctx.manifest;
|
|
2503
|
+
fetchBaseOnce(ctx);
|
|
1794
2504
|
const findings = [];
|
|
1795
2505
|
const types = m.branch.types.join("|");
|
|
1796
2506
|
const areaOf = (branch) => {
|
|
1797
|
-
const match = new RegExp(`^(${types})/(
|
|
2507
|
+
const match = new RegExp(`^(${types})/(.+?)(?:--([a-z0-9][a-z0-9_-]*))?$`).exec(branch);
|
|
1798
2508
|
return match && m.areas[match[2]] ? match[2] : null;
|
|
1799
2509
|
};
|
|
1800
2510
|
for (const w of registeredWorktrees(ctx)) {
|
|
@@ -1843,7 +2553,7 @@ async function auditProject(ctx) {
|
|
|
1843
2553
|
findings.push({ kind: "notice-stale", area, detail: `${n.text} (since ${n.at})` });
|
|
1844
2554
|
}
|
|
1845
2555
|
}
|
|
1846
|
-
const bytes = dirSize(
|
|
2556
|
+
const bytes = dirSize(join8(ctx.repoRoot, ARCHIVE_DIR));
|
|
1847
2557
|
if (bytes > ARCHIVE_LIMIT_BYTES)
|
|
1848
2558
|
findings.push({
|
|
1849
2559
|
kind: "archive-size",
|
|
@@ -1893,51 +2603,70 @@ function registerAudit(program2) {
|
|
|
1893
2603
|
|
|
1894
2604
|
// src/commands/env.ts
|
|
1895
2605
|
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(
|
|
2606
|
+
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
2607
|
"--slot <n>",
|
|
1898
2608
|
"compute the variables for this slot from the manifest alone",
|
|
1899
2609
|
(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
|
-
|
|
2610
|
+
).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(
|
|
2611
|
+
async (o) => {
|
|
2612
|
+
try {
|
|
2613
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
|
|
2614
|
+
let vars;
|
|
2615
|
+
if (o.area) vars = effortEnv(ctx, o.area, o.task);
|
|
2616
|
+
else if (o.slot !== void 0 && Number.isInteger(o.slot))
|
|
2617
|
+
vars = slotEnv(ctx.manifest, o.slot);
|
|
2618
|
+
else throw new WorkkitError("EFFORT_NOT_FOUND", "pass --area <area> or --slot <n>");
|
|
2619
|
+
if (o.json) printJson(vars);
|
|
2620
|
+
else for (const [k, v] of Object.entries(vars)) console.log(`${k}=${v}`);
|
|
2621
|
+
} catch (err) {
|
|
2622
|
+
fail(err, Boolean(o.json));
|
|
2623
|
+
}
|
|
1912
2624
|
}
|
|
1913
|
-
|
|
2625
|
+
);
|
|
1914
2626
|
}
|
|
1915
2627
|
|
|
1916
2628
|
// src/commands/finish.ts
|
|
1917
2629
|
import chalk4 from "chalk";
|
|
1918
2630
|
function registerFinish(program2) {
|
|
1919
2631
|
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
|
-
|
|
2632
|
+
"Stop the effort's runner, release its leases, push its branch (or merge --into-area); area worktrees stay, task worktrees are cleaned up"
|
|
2633
|
+
).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(
|
|
2634
|
+
async (o) => {
|
|
2635
|
+
try {
|
|
2636
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2637
|
+
const r = await finishEffort(ctx, {
|
|
2638
|
+
area: o.area,
|
|
2639
|
+
task: o.task,
|
|
2640
|
+
park: o.park,
|
|
2641
|
+
intoArea: o.intoArea,
|
|
2642
|
+
force: o.force
|
|
2643
|
+
});
|
|
2644
|
+
if (o.json) printJson(r);
|
|
2645
|
+
else {
|
|
2646
|
+
const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
|
|
2647
|
+
if (r.left) {
|
|
2648
|
+
console.log(`${chalk4.bold(label)}: left shared effort (other participants remain)`);
|
|
2649
|
+
} else if (r.mergedIntoArea) {
|
|
2650
|
+
console.log(
|
|
2651
|
+
`${chalk4.bold(label)} finished: ${r.effort.branch} merged into ${r.effort.type}/${r.effort.area}`
|
|
2652
|
+
);
|
|
2653
|
+
} else {
|
|
2654
|
+
console.log(
|
|
2655
|
+
`${chalk4.bold(label)} finished: ${r.effort.branch} pushed${r.parked ? " (uncommitted changes parked as wip)" : ""}`
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
if (r.notices) console.log(` ${r.notices} area(s) told about commons changes`);
|
|
2659
|
+
}
|
|
2660
|
+
} catch (err) {
|
|
2661
|
+
fail(err, Boolean(o.json));
|
|
1931
2662
|
}
|
|
1932
|
-
} catch (err) {
|
|
1933
|
-
fail(err, Boolean(o.json));
|
|
1934
2663
|
}
|
|
1935
|
-
|
|
2664
|
+
);
|
|
1936
2665
|
}
|
|
1937
2666
|
|
|
1938
2667
|
// src/commands/hook.ts
|
|
1939
|
-
import { readFileSync as
|
|
1940
|
-
import { resolve } from "path";
|
|
2668
|
+
import { readFileSync as readFileSync5, realpathSync as realpathSync5 } from "fs";
|
|
2669
|
+
import { resolve as resolve2 } from "path";
|
|
1941
2670
|
import chalk5 from "chalk";
|
|
1942
2671
|
var ZEROS = /^0+$/;
|
|
1943
2672
|
async function hookContext(cwd) {
|
|
@@ -1956,7 +2685,7 @@ async function runPreCommit(cwd) {
|
|
|
1956
2685
|
if (!c) return 0;
|
|
1957
2686
|
const root = c.git.root();
|
|
1958
2687
|
touchByWorktree(c.registry, c.manifest, root);
|
|
1959
|
-
const { refusals } = checkCommit({
|
|
2688
|
+
const { refusals, warnings } = checkCommit({
|
|
1960
2689
|
m: c.manifest,
|
|
1961
2690
|
registry: c.registry,
|
|
1962
2691
|
git: c.git,
|
|
@@ -1966,6 +2695,7 @@ async function runPreCommit(cwd) {
|
|
|
1966
2695
|
ownerSession: currentOwner().session,
|
|
1967
2696
|
checkoutRoot: root
|
|
1968
2697
|
});
|
|
2698
|
+
for (const w of warnings) console.error(chalk5.yellow(`work: ${w}`));
|
|
1969
2699
|
for (const r of refusals) console.error(chalk5.red(`work: ${r}`));
|
|
1970
2700
|
return refusals.length ? 2 : 0;
|
|
1971
2701
|
}
|
|
@@ -2033,7 +2763,7 @@ function registerHook(program2) {
|
|
|
2033
2763
|
"Refuse pushes to protected branches; warn when paths leave the effort scope or touch commons"
|
|
2034
2764
|
).argument("[remote]", "passed by git, unused").argument("[url]", "passed by git, unused").action(async () => {
|
|
2035
2765
|
try {
|
|
2036
|
-
const stdin = process.stdin.isTTY ? "" :
|
|
2766
|
+
const stdin = process.stdin.isTTY ? "" : readFileSync5(0, "utf8");
|
|
2037
2767
|
process.exit(await runPrePush(process.cwd(), stdin));
|
|
2038
2768
|
} catch (err) {
|
|
2039
2769
|
fail(err, false);
|
|
@@ -2044,7 +2774,7 @@ function registerHook(program2) {
|
|
|
2044
2774
|
"Write .helmsmith/hooks and point this clone's core.hooksPath at them (for a repo whose manifest already exists)"
|
|
2045
2775
|
).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
|
|
2046
2776
|
try {
|
|
2047
|
-
const repoRoot = o.projectRoot ?
|
|
2777
|
+
const repoRoot = o.projectRoot ? resolve2(o.projectRoot) : new Git(process.cwd()).mainRoot();
|
|
2048
2778
|
const manifest = await loadManifest(repoRoot);
|
|
2049
2779
|
const out = installHooks(repoRoot, { protectedBranches: manifest.protectedBranches });
|
|
2050
2780
|
if (o.json) printJson(out);
|
|
@@ -2056,8 +2786,8 @@ function registerHook(program2) {
|
|
|
2056
2786
|
}
|
|
2057
2787
|
|
|
2058
2788
|
// src/commands/init.ts
|
|
2059
|
-
import { existsSync as
|
|
2060
|
-
import { join as
|
|
2789
|
+
import { existsSync as existsSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
2790
|
+
import { join as join9, resolve as resolve3 } from "path";
|
|
2061
2791
|
var STORYBOOK_BASE = 6100;
|
|
2062
2792
|
function defaultService(name) {
|
|
2063
2793
|
const PORT = `\${PORT_${name.toUpperCase()}}`;
|
|
@@ -2121,9 +2851,9 @@ var HEADER = `// biome-ignore-all lint/suspicious/noTemplateCurlyInString: \${PO
|
|
|
2121
2851
|
// each with its backend module) and the commons. Nothing here is read by the application.
|
|
2122
2852
|
`;
|
|
2123
2853
|
function runInit(o) {
|
|
2124
|
-
const repoRoot =
|
|
2854
|
+
const repoRoot = resolve3(o.repoRoot);
|
|
2125
2855
|
for (const name of MANIFEST_FILES) {
|
|
2126
|
-
if (
|
|
2856
|
+
if (existsSync8(join9(repoRoot, name)) && !o.force) {
|
|
2127
2857
|
throw new WorkkitError(
|
|
2128
2858
|
"MANIFEST_INVALID",
|
|
2129
2859
|
`${name} already exists in ${repoRoot}; pass --force to overwrite`
|
|
@@ -2131,8 +2861,8 @@ function runInit(o) {
|
|
|
2131
2861
|
}
|
|
2132
2862
|
}
|
|
2133
2863
|
const manifest = buildStarterManifest(o);
|
|
2134
|
-
const manifestPath =
|
|
2135
|
-
|
|
2864
|
+
const manifestPath = join9(repoRoot, "helmsmith.config.mjs");
|
|
2865
|
+
writeFileSync5(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
|
|
2136
2866
|
`);
|
|
2137
2867
|
claimRanges(Registry.open(o.home), validateManifest(manifest), repoRoot);
|
|
2138
2868
|
const hooks = o.hooks ? installHooks(repoRoot, { protectedBranches: [o.baseBranch] }).files : [];
|
|
@@ -2162,7 +2892,7 @@ function registerInit(program2) {
|
|
|
2162
2892
|
).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
2893
|
(o) => {
|
|
2164
2894
|
try {
|
|
2165
|
-
const repoRoot = o.repoRoot ?
|
|
2895
|
+
const repoRoot = o.repoRoot ? resolve3(o.repoRoot) : defaultRepoRoot();
|
|
2166
2896
|
if (o.service.length === 0)
|
|
2167
2897
|
throw new WorkkitError(
|
|
2168
2898
|
"MANIFEST_INVALID",
|
|
@@ -2200,21 +2930,21 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
2200
2930
|
import { z as z2 } from "zod";
|
|
2201
2931
|
|
|
2202
2932
|
// src/version.ts
|
|
2203
|
-
import { existsSync as
|
|
2204
|
-
import { dirname as
|
|
2933
|
+
import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
|
|
2934
|
+
import { dirname as dirname4, join as join10 } from "path";
|
|
2205
2935
|
import { fileURLToPath } from "url";
|
|
2206
2936
|
function findPackageJson(startDir) {
|
|
2207
2937
|
let dir = startDir;
|
|
2208
2938
|
for (; ; ) {
|
|
2209
|
-
const candidate =
|
|
2210
|
-
if (
|
|
2211
|
-
const parent =
|
|
2939
|
+
const candidate = join10(dir, "package.json");
|
|
2940
|
+
if (existsSync9(candidate)) return candidate;
|
|
2941
|
+
const parent = dirname4(dir);
|
|
2212
2942
|
if (parent === dir) throw new Error(`package.json not found above ${startDir}`);
|
|
2213
2943
|
dir = parent;
|
|
2214
2944
|
}
|
|
2215
2945
|
}
|
|
2216
|
-
var pkgFile = findPackageJson(
|
|
2217
|
-
var VERSION = JSON.parse(
|
|
2946
|
+
var pkgFile = findPackageJson(dirname4(fileURLToPath(import.meta.url)));
|
|
2947
|
+
var VERSION = JSON.parse(readFileSync6(pkgFile, "utf8")).version;
|
|
2218
2948
|
|
|
2219
2949
|
// src/mcp/server.ts
|
|
2220
2950
|
var ok = (value) => ({
|
|
@@ -2236,64 +2966,114 @@ function buildMcpServer(ctxFactory) {
|
|
|
2236
2966
|
server.registerTool(
|
|
2237
2967
|
"work_start",
|
|
2238
2968
|
{
|
|
2239
|
-
description: "Start an effort on an area: its worktree on <type>/<area
|
|
2969
|
+
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
2970
|
inputSchema: {
|
|
2241
2971
|
area: z2.string().describe("a leaf area from helmsmith.config (e.g. org/applications)"),
|
|
2972
|
+
task: z2.string().optional().describe("optional intra-area task slug for parallel sub-efforts (<type>/<area>--<task>)"),
|
|
2242
2973
|
type: z2.string().describe("feature | chore | fix"),
|
|
2243
|
-
mode: z2.enum(["frontend", "fullstack"]),
|
|
2974
|
+
mode: z2.enum(["frontend", "backend", "fullstack"]),
|
|
2975
|
+
scopes: z2.array(z2.string()).optional().describe("optional narrowed scope globs for a task sub-effort"),
|
|
2244
2976
|
story: z2.string().optional().describe(
|
|
2245
2977
|
"frontend: story file or component folder for storybrokr, relative to its host"
|
|
2246
2978
|
),
|
|
2247
2979
|
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)")
|
|
2980
|
+
sync: z2.boolean().optional().describe("fetch and merge the base branch first (default true)"),
|
|
2981
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
2982
|
+
}
|
|
2983
|
+
},
|
|
2984
|
+
async ({ area, task, type, mode, scopes, story, join: join11, sync, projectRoot }) => guard(
|
|
2985
|
+
async () => startEffort(await ctxFactory(projectRoot), {
|
|
2986
|
+
area,
|
|
2987
|
+
task,
|
|
2988
|
+
type,
|
|
2989
|
+
mode,
|
|
2990
|
+
scopes,
|
|
2991
|
+
story,
|
|
2992
|
+
join: join11,
|
|
2993
|
+
sync
|
|
2994
|
+
})
|
|
2995
|
+
)
|
|
2996
|
+
);
|
|
2997
|
+
server.registerTool(
|
|
2998
|
+
"work_promote",
|
|
2999
|
+
{
|
|
3000
|
+
description: "Promote an open effort in place to backend or fullstack mode, claiming a slot if needed and starting only newly required services.",
|
|
3001
|
+
inputSchema: {
|
|
3002
|
+
area: z2.string().describe("the area of the open effort"),
|
|
3003
|
+
task: z2.string().optional().describe("optional intra-area task slug"),
|
|
3004
|
+
mode: z2.enum(["backend", "fullstack"]).describe("target mode to promote into"),
|
|
3005
|
+
story: z2.string().optional().describe("optional story path"),
|
|
3006
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
2249
3007
|
}
|
|
2250
3008
|
},
|
|
2251
|
-
async ({ area,
|
|
3009
|
+
async ({ area, task, mode, story, projectRoot }) => guard(async () => promoteEffort(await ctxFactory(projectRoot), { area, task, mode, story }))
|
|
2252
3010
|
);
|
|
2253
3011
|
server.registerTool(
|
|
2254
3012
|
"work_status",
|
|
2255
3013
|
{
|
|
2256
3014
|
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: {
|
|
3015
|
+
inputSchema: {
|
|
3016
|
+
area: z2.string().optional(),
|
|
3017
|
+
task: z2.string().optional(),
|
|
3018
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3019
|
+
}
|
|
2258
3020
|
},
|
|
2259
|
-
async ({ area }) => guard(async () => statusFor(await ctxFactory(), area))
|
|
3021
|
+
async ({ area, task, projectRoot }) => guard(async () => statusFor(await ctxFactory(projectRoot), area, task))
|
|
2260
3022
|
);
|
|
2261
3023
|
server.registerTool(
|
|
2262
3024
|
"work_finish",
|
|
2263
3025
|
{
|
|
2264
|
-
description: "Stop the effort's runner, release its leases, push its branch
|
|
2265
|
-
inputSchema: {
|
|
3026
|
+
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).",
|
|
3027
|
+
inputSchema: {
|
|
3028
|
+
area: z2.string(),
|
|
3029
|
+
task: z2.string().optional().describe("optional intra-area task slug"),
|
|
3030
|
+
park: z2.boolean().optional(),
|
|
3031
|
+
intoArea: z2.boolean().optional().describe("merge task branch into <type>/<area> instead of pushing to origin"),
|
|
3032
|
+
force: z2.boolean().optional().describe("stop and finish even if other joined participants remain"),
|
|
3033
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3034
|
+
}
|
|
2266
3035
|
},
|
|
2267
|
-
async ({ area, park
|
|
3036
|
+
async ({ area, task, park, intoArea, force, projectRoot }) => guard(
|
|
3037
|
+
async () => finishEffort(await ctxFactory(projectRoot), { area, task, park, intoArea, force })
|
|
3038
|
+
)
|
|
2268
3039
|
);
|
|
2269
3040
|
server.registerTool(
|
|
2270
3041
|
"work_reap",
|
|
2271
3042
|
{
|
|
2272
3043
|
description: "Expire idle leases: stop only what they started, release them, park their efforts. dryRun lists without touching.",
|
|
2273
|
-
inputSchema: {
|
|
3044
|
+
inputSchema: {
|
|
3045
|
+
dryRun: z2.boolean().optional(),
|
|
3046
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3047
|
+
}
|
|
2274
3048
|
},
|
|
2275
|
-
async ({ dryRun }) => guard(async () => reapLeases(await ctxFactory(), { dryRun }))
|
|
3049
|
+
async ({ dryRun, projectRoot }) => guard(async () => reapLeases(await ctxFactory(projectRoot), { dryRun }))
|
|
2276
3050
|
);
|
|
2277
3051
|
server.registerTool(
|
|
2278
3052
|
"work_audit",
|
|
2279
3053
|
{
|
|
2280
3054
|
description: "Findings: dirty or unpushed area worktrees, foreign worktrees, aging leases, drift from the base branch, stale notices, archive size.",
|
|
2281
|
-
inputSchema: {
|
|
3055
|
+
inputSchema: {
|
|
3056
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3057
|
+
}
|
|
2282
3058
|
},
|
|
2283
|
-
async () => guard(async () => auditProject(await ctxFactory()))
|
|
3059
|
+
async ({ projectRoot }) => guard(async () => auditProject(await ctxFactory(projectRoot)))
|
|
2284
3060
|
);
|
|
2285
3061
|
server.registerTool(
|
|
2286
3062
|
"work_env",
|
|
2287
3063
|
{
|
|
2288
3064
|
description: "The slot-derived variables of an area's open effort (PORT_*, SLOT, LOG_DIR, WORKTREE, BRANCH, MODE).",
|
|
2289
|
-
inputSchema: {
|
|
3065
|
+
inputSchema: {
|
|
3066
|
+
area: z2.string(),
|
|
3067
|
+
task: z2.string().optional(),
|
|
3068
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3069
|
+
}
|
|
2290
3070
|
},
|
|
2291
|
-
async ({ area }) => guard(async () => effortEnv(await ctxFactory(), area))
|
|
3071
|
+
async ({ area, task, projectRoot }) => guard(async () => effortEnv(await ctxFactory(projectRoot), area, task))
|
|
2292
3072
|
);
|
|
2293
3073
|
return server;
|
|
2294
3074
|
}
|
|
2295
3075
|
async function runStdio() {
|
|
2296
|
-
const server = buildMcpServer(() => buildCtx({ quiet: true }));
|
|
3076
|
+
const server = buildMcpServer((projectRoot) => buildCtx({ projectRoot, quiet: true }));
|
|
2297
3077
|
await server.connect(new StdioServerTransport());
|
|
2298
3078
|
}
|
|
2299
3079
|
|
|
@@ -2304,8 +3084,36 @@ function registerMcp(program2) {
|
|
|
2304
3084
|
});
|
|
2305
3085
|
}
|
|
2306
3086
|
|
|
2307
|
-
// src/commands/
|
|
3087
|
+
// src/commands/promote.ts
|
|
2308
3088
|
import chalk6 from "chalk";
|
|
3089
|
+
function registerPromote(program2) {
|
|
3090
|
+
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("--story <path>", "optional story path to attach via storybrokr").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
3091
|
+
async (o) => {
|
|
3092
|
+
try {
|
|
3093
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
3094
|
+
const r = await promoteEffort(ctx, {
|
|
3095
|
+
area: o.area,
|
|
3096
|
+
task: o.task,
|
|
3097
|
+
mode: o.mode,
|
|
3098
|
+
story: o.story
|
|
3099
|
+
});
|
|
3100
|
+
if (o.json) printJson(r);
|
|
3101
|
+
else {
|
|
3102
|
+
const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
|
|
3103
|
+
console.log(
|
|
3104
|
+
`${chalk6.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
|
|
3105
|
+
);
|
|
3106
|
+
for (const w of r.warnings) warn(w);
|
|
3107
|
+
}
|
|
3108
|
+
} catch (err) {
|
|
3109
|
+
fail(err, Boolean(o.json));
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
// src/commands/reap.ts
|
|
3116
|
+
import chalk7 from "chalk";
|
|
2309
3117
|
function registerReap(program2) {
|
|
2310
3118
|
program2.command("reap").description(
|
|
2311
3119
|
"Expire idle leases: stop only what they started, release them, park their efforts"
|
|
@@ -2314,7 +3122,7 @@ function registerReap(program2) {
|
|
|
2314
3122
|
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2315
3123
|
const r = await reapLeases(ctx, { dryRun: o.dryRun });
|
|
2316
3124
|
if (o.json) printJson(r);
|
|
2317
|
-
else if (r.reaped.length === 0) console.log(
|
|
3125
|
+
else if (r.reaped.length === 0) console.log(chalk7.gray("nothing to reap"));
|
|
2318
3126
|
else
|
|
2319
3127
|
for (const l of r.reaped)
|
|
2320
3128
|
console.log(
|
|
@@ -2327,7 +3135,7 @@ function registerReap(program2) {
|
|
|
2327
3135
|
}
|
|
2328
3136
|
|
|
2329
3137
|
// src/commands/retire.ts
|
|
2330
|
-
import
|
|
3138
|
+
import chalk8 from "chalk";
|
|
2331
3139
|
function registerRetire(program2) {
|
|
2332
3140
|
program2.command("retire").description(
|
|
2333
3141
|
"Archive an area's evidence, remove its worktree and its local branches that are on origin (the only destructive verb)"
|
|
@@ -2337,11 +3145,11 @@ function registerRetire(program2) {
|
|
|
2337
3145
|
const r = await retireArea(ctx, { area: o.area });
|
|
2338
3146
|
if (o.json) printJson(r);
|
|
2339
3147
|
else {
|
|
2340
|
-
console.log(`${
|
|
3148
|
+
console.log(`${chalk8.bold(o.area)} retired: removed ${r.removed}`);
|
|
2341
3149
|
for (const a of r.archived) console.log(` archived ${a}`);
|
|
2342
3150
|
for (const b of r.branchesDeleted) console.log(` deleted local branch ${b}`);
|
|
2343
3151
|
for (const b of r.branchesKept)
|
|
2344
|
-
console.log(
|
|
3152
|
+
console.log(chalk8.yellow(` kept ${b}: not fully on origin`));
|
|
2345
3153
|
}
|
|
2346
3154
|
} catch (err) {
|
|
2347
3155
|
fail(err, Boolean(o.json));
|
|
@@ -2350,11 +3158,12 @@ function registerRetire(program2) {
|
|
|
2350
3158
|
}
|
|
2351
3159
|
|
|
2352
3160
|
// src/commands/start.ts
|
|
2353
|
-
import
|
|
3161
|
+
import chalk9 from "chalk";
|
|
2354
3162
|
function printStart(r) {
|
|
2355
3163
|
const e = r.effort;
|
|
3164
|
+
const label = e.task ? `${e.area}--${e.task}` : e.area;
|
|
2356
3165
|
console.log(
|
|
2357
|
-
`${
|
|
3166
|
+
`${chalk9.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk9.yellow(" (joined)") : ""}`
|
|
2358
3167
|
);
|
|
2359
3168
|
console.log(` worktree ${e.worktree}`);
|
|
2360
3169
|
console.log(
|
|
@@ -2365,12 +3174,12 @@ function printStart(r) {
|
|
|
2365
3174
|
console.log(` lease ${l.kind} ${l.value}${ports} expires ${l.expires}`);
|
|
2366
3175
|
}
|
|
2367
3176
|
for (const w of r.warnings) warn(w);
|
|
2368
|
-
for (const n of r.notices) console.log(
|
|
3177
|
+
for (const n of r.notices) console.log(chalk9.yellow(` notice ${n.text} (${n.at})`));
|
|
2369
3178
|
}
|
|
2370
3179
|
function registerStart(program2) {
|
|
2371
3180
|
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(
|
|
3181
|
+
"Start an effort: the area's worktree on <type>/<area> (or <type>/<area>--<task>), synced from the base branch, leased and running"
|
|
3182
|
+
).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
3183
|
"--story <path>",
|
|
2375
3184
|
"frontend: the story file or component folder storybrokr boots, relative to the Storybook host"
|
|
2376
3185
|
).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 +3188,10 @@ function registerStart(program2) {
|
|
|
2379
3188
|
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2380
3189
|
const r = await startEffort(ctx, {
|
|
2381
3190
|
area: o.area,
|
|
3191
|
+
task: o.task,
|
|
2382
3192
|
type: o.type,
|
|
2383
3193
|
mode: o.mode,
|
|
3194
|
+
scopes: o.scope,
|
|
2384
3195
|
story: o.story,
|
|
2385
3196
|
join: o.join,
|
|
2386
3197
|
sync: o.sync
|
|
@@ -2395,36 +3206,36 @@ function registerStart(program2) {
|
|
|
2395
3206
|
}
|
|
2396
3207
|
|
|
2397
3208
|
// src/commands/status.ts
|
|
2398
|
-
import
|
|
3209
|
+
import chalk10 from "chalk";
|
|
2399
3210
|
var age = (iso) => {
|
|
2400
3211
|
const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 6e4));
|
|
2401
3212
|
return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
|
|
2402
3213
|
};
|
|
2403
3214
|
var expiresIn = (iso) => {
|
|
2404
3215
|
const mins = Math.round((Date.parse(iso) - Date.now()) / 6e4);
|
|
2405
|
-
return mins <= 0 ?
|
|
3216
|
+
return mins <= 0 ? chalk10.red("expired") : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
|
|
2406
3217
|
};
|
|
2407
3218
|
function printStatus(r) {
|
|
2408
|
-
console.log(
|
|
3219
|
+
console.log(chalk10.bold(`${r.project}`));
|
|
2409
3220
|
const cols = ["AREA", "TYPE/MODE", "BRANCH", "OWNER", "STARTED", "LEASES", "BEHIND", "NOTICES"];
|
|
2410
3221
|
const rows = r.areas.map((a) => [
|
|
2411
3222
|
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.behindBase > 0 ?
|
|
2418
|
-
a.notices.length ?
|
|
3223
|
+
a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk10.gray("-"),
|
|
3224
|
+
a.effort?.branch ?? chalk10.gray("-"),
|
|
3225
|
+
a.effort?.owner.session ?? chalk10.gray("-"),
|
|
3226
|
+
a.effort ? age(a.effort.started) : chalk10.gray("-"),
|
|
3227
|
+
a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk10.gray("-"),
|
|
3228
|
+
a.behindBase > 0 ? chalk10.yellow(String(a.behindBase)) : "0",
|
|
3229
|
+
a.notices.length ? chalk10.yellow(String(a.notices.length)) : "0"
|
|
2419
3230
|
]);
|
|
2420
3231
|
const plain = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
|
|
2421
3232
|
const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((row) => plain(row[i]).length)));
|
|
2422
3233
|
const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - plain(c).length)).join(" ");
|
|
2423
|
-
console.log(
|
|
3234
|
+
console.log(chalk10.bold(line(cols)));
|
|
2424
3235
|
for (const row of rows) console.log(line(row));
|
|
2425
3236
|
if (r.leases.length) {
|
|
2426
3237
|
console.log("");
|
|
2427
|
-
console.log(
|
|
3238
|
+
console.log(chalk10.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
|
|
2428
3239
|
for (const l of r.leases) {
|
|
2429
3240
|
console.log(
|
|
2430
3241
|
`${l.kind.padEnd(10)} ${String(l.value).padEnd(12)} ${l.owner.session.padEnd(21)} ${age(l.created).padEnd(5)} ${expiresIn(l.expires)}`
|
|
@@ -2433,10 +3244,10 @@ function printStatus(r) {
|
|
|
2433
3244
|
}
|
|
2434
3245
|
}
|
|
2435
3246
|
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) => {
|
|
3247
|
+
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
3248
|
try {
|
|
2438
3249
|
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
|
|
2439
|
-
const r = statusFor(ctx, o.area);
|
|
3250
|
+
const r = statusFor(ctx, o.area, o.task);
|
|
2440
3251
|
if (o.json) printJson(r);
|
|
2441
3252
|
else printStatus(r);
|
|
2442
3253
|
} catch (err) {
|
|
@@ -2449,10 +3260,11 @@ function registerStatus(program2) {
|
|
|
2449
3260
|
var { program } = createCli({
|
|
2450
3261
|
name: "work",
|
|
2451
3262
|
version: VERSION,
|
|
2452
|
-
description: "Area-based work: start, status, finish, reap, retire, audit."
|
|
3263
|
+
description: "Area-based work: start, promote, status, finish, reap, retire, audit."
|
|
2453
3264
|
});
|
|
2454
3265
|
registerInit(program);
|
|
2455
3266
|
registerStart(program);
|
|
3267
|
+
registerPromote(program);
|
|
2456
3268
|
registerStatus(program);
|
|
2457
3269
|
registerEnv(program);
|
|
2458
3270
|
registerFinish(program);
|