@helmsmith/workbrokr 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +10 -0
- package/dist/cli.js +505 -91
- package/dist/cli.js.map +1 -1
- package/dist/core/index.js +82 -21
- package/dist/core/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -61,7 +61,8 @@ var REFUSALS = /* @__PURE__ */ new Set([
|
|
|
61
61
|
"PORT_RANGE_TAKEN",
|
|
62
62
|
"SYNC_CONFLICT",
|
|
63
63
|
"DIRTY_WORKTREE",
|
|
64
|
-
"UNPUSHED"
|
|
64
|
+
"UNPUSHED",
|
|
65
|
+
"PR_CLOSED"
|
|
65
66
|
]);
|
|
66
67
|
function exitCodeFor(code) {
|
|
67
68
|
return REFUSALS.has(code) ? 2 : 1;
|
|
@@ -164,12 +165,27 @@ var Git = class _Git {
|
|
|
164
165
|
else if (this.remoteBranchExists(this.currentBranch())) ref = `origin/${this.currentBranch()}`;
|
|
165
166
|
return this.lines(["rev-list", `${ref}..HEAD`]).length;
|
|
166
167
|
}
|
|
168
|
+
/** Commits on origin/<base> that HEAD does not have (0 when the ref is unknown). */
|
|
169
|
+
behindCount(base) {
|
|
170
|
+
const r = this.tryRun(["rev-list", "--count", `HEAD..origin/${base}`]);
|
|
171
|
+
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
172
|
+
}
|
|
167
173
|
isDirty() {
|
|
168
174
|
return this.run(["status", "--porcelain"]).trim().length > 0;
|
|
169
175
|
}
|
|
170
176
|
changedPathsStaged() {
|
|
171
177
|
return this.lines(["diff", "--cached", "--name-only"]);
|
|
172
178
|
}
|
|
179
|
+
/** The other parent of a merge in progress, or undefined when none is. */
|
|
180
|
+
mergeHead() {
|
|
181
|
+
const r = this.tryRun(["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
|
182
|
+
return r.code === 0 ? r.out.trim() : void 0;
|
|
183
|
+
}
|
|
184
|
+
/** Staged paths whose index content differs from `ref` (so, authored here rather than taken from `ref`). */
|
|
185
|
+
stagedDifferingFrom(ref, paths) {
|
|
186
|
+
if (paths.length === 0) return [];
|
|
187
|
+
return this.lines(["diff", "--cached", "--name-only", "--no-renames", ref, "--", ...paths]);
|
|
188
|
+
}
|
|
173
189
|
changedPaths(from, to) {
|
|
174
190
|
return this.lines(["diff", "--name-only", "--no-renames", from, to]);
|
|
175
191
|
}
|
|
@@ -202,18 +218,45 @@ var Git = class _Git {
|
|
|
202
218
|
const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
|
|
203
219
|
return { kind: "conflict", before, after: before, conflicted };
|
|
204
220
|
}
|
|
221
|
+
/** `origin/<branch>` after a fetch attempt, when the remote has it; the local copy may have been retired. */
|
|
222
|
+
remoteRef(branch) {
|
|
223
|
+
this.tryRun(["fetch", "-q", "origin", branch]);
|
|
224
|
+
const ref = `refs/remotes/origin/${branch}`;
|
|
225
|
+
return this.tryRun(["rev-parse", "--verify", "-q", ref]).code === 0 ? `origin/${branch}` : void 0;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Add a worktree on `branch`: the local branch if it exists, else a tracking
|
|
229
|
+
* branch from origin/<branch> if the remote has it (so retiring the local
|
|
230
|
+
* branch while its PR was open never recreates it from the base), else a
|
|
231
|
+
* new branch from `startPoint`.
|
|
232
|
+
*/
|
|
205
233
|
worktreeAdd(path, branch, startPoint) {
|
|
206
234
|
mkdirSync(dirname(path), { recursive: true });
|
|
207
|
-
if (this.branchExists(branch))
|
|
208
|
-
|
|
209
|
-
|
|
235
|
+
if (this.branchExists(branch)) {
|
|
236
|
+
this.run(["worktree", "add", "-q", path, branch]);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const remote = this.remoteRef(branch);
|
|
240
|
+
if (remote) {
|
|
241
|
+
this.run(["worktree", "add", "-q", "--track", "-b", branch, path, remote]);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
|
|
210
245
|
}
|
|
211
246
|
worktreeRemove(path) {
|
|
212
247
|
this.run(["worktree", "remove", "--force", path]);
|
|
213
248
|
}
|
|
214
249
|
checkout(branch, createFrom) {
|
|
215
|
-
if (this.branchExists(branch))
|
|
216
|
-
|
|
250
|
+
if (this.branchExists(branch)) {
|
|
251
|
+
this.run(["checkout", "-q", branch]);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const remote = this.remoteRef(branch);
|
|
255
|
+
if (remote) {
|
|
256
|
+
this.run(["checkout", "-q", "--track", "-b", branch, remote]);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
|
|
217
260
|
}
|
|
218
261
|
push(branch) {
|
|
219
262
|
this.run(["push", "-q", "-u", "origin", branch]);
|
|
@@ -327,13 +370,14 @@ function slotVars(m, o) {
|
|
|
327
370
|
BRANCH: o.branch ?? "",
|
|
328
371
|
WORKTREE: o.worktree ?? "",
|
|
329
372
|
LOG_DIR: o.logDir ?? "",
|
|
330
|
-
PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort),
|
|
331
373
|
STORYBROKR_HOST: storybookHostFor(m, o.worktree)
|
|
332
374
|
};
|
|
333
375
|
for (const service of Object.keys(m.ports.services)) {
|
|
334
376
|
const p = o.ports[service];
|
|
335
377
|
vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
|
|
336
378
|
}
|
|
379
|
+
if (o.storybookPort !== void 0) vars.PORT_STORYBOOK = String(o.storybookPort);
|
|
380
|
+
else if (vars.PORT_STORYBOOK === void 0) vars.PORT_STORYBOOK = "";
|
|
337
381
|
return vars;
|
|
338
382
|
}
|
|
339
383
|
function ensureExcluded(worktree, patterns) {
|
|
@@ -558,8 +602,15 @@ function checkCommit(c) {
|
|
|
558
602
|
`branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
|
|
559
603
|
);
|
|
560
604
|
}
|
|
605
|
+
const mergeHead = c.git.mergeHead();
|
|
606
|
+
const authored = mergeHead ? new Set(c.git.stagedDifferingFrom(mergeHead, c.stagedPaths)) : new Set(c.stagedPaths);
|
|
607
|
+
const ownPaths = c.stagedPaths.filter((p) => authored.has(p));
|
|
561
608
|
const allEfforts = c.registry.efforts({ project: c.project });
|
|
562
609
|
const efforts = new Map(allEfforts.map((e) => [e.id, e]));
|
|
610
|
+
const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
|
|
611
|
+
const mine = openEfforts.find(
|
|
612
|
+
(e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
|
|
613
|
+
);
|
|
563
614
|
for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
|
|
564
615
|
const holder = efforts.get(lease.effort);
|
|
565
616
|
const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
|
|
@@ -568,34 +619,31 @@ function checkCommit(c) {
|
|
|
568
619
|
} else if (lease.owner.session === c.ownerSession) {
|
|
569
620
|
continue;
|
|
570
621
|
}
|
|
622
|
+
if (holder && mine && holder.area === mine.area) continue;
|
|
571
623
|
const globs = c.m.commons[String(lease.value)] ?? [];
|
|
572
|
-
const hit = pathsMatching(
|
|
624
|
+
const hit = pathsMatching(ownPaths, globs);
|
|
573
625
|
if (hit.length > 0) {
|
|
574
626
|
refusals.push(
|
|
575
627
|
`commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
|
|
576
628
|
);
|
|
577
629
|
}
|
|
578
630
|
}
|
|
579
|
-
const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
|
|
580
631
|
for (const other of openEfforts) {
|
|
581
632
|
if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
|
|
582
633
|
continue;
|
|
583
634
|
}
|
|
584
635
|
if (!c.checkoutRoot && other.branch === c.branch) continue;
|
|
585
|
-
const hit = pathsMatching(
|
|
636
|
+
const hit = pathsMatching(ownPaths, other.scopes);
|
|
586
637
|
if (hit.length > 0) {
|
|
587
638
|
refusals.push(
|
|
588
639
|
`path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
|
|
589
640
|
);
|
|
590
641
|
}
|
|
591
642
|
}
|
|
592
|
-
|
|
593
|
-
(e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
|
|
594
|
-
);
|
|
595
|
-
if (mine && c.stagedPaths.length > 0) {
|
|
643
|
+
if (mine && ownPaths.length > 0) {
|
|
596
644
|
const allowed = allowedScopesForEffort(c.m, mine);
|
|
597
|
-
const inScope = new Set(pathsMatching(
|
|
598
|
-
const outside =
|
|
645
|
+
const inScope = new Set(pathsMatching(ownPaths, allowed));
|
|
646
|
+
const outside = ownPaths.filter((p) => !inScope.has(p));
|
|
599
647
|
if (outside.length > 0) {
|
|
600
648
|
warnings.push(
|
|
601
649
|
`${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
|
|
@@ -922,8 +970,19 @@ function rangesFor(m) {
|
|
|
922
970
|
function rangesOverlap(a, b) {
|
|
923
971
|
return a.base < b.base + b.count && b.base < a.base + a.count;
|
|
924
972
|
}
|
|
973
|
+
function ensureRanges(registry, m, repoRoot) {
|
|
974
|
+
const mine = rangesFor(m);
|
|
975
|
+
assertNoOverlap(registry, m, mine);
|
|
976
|
+
const current = registry.project(m.project);
|
|
977
|
+
if (current && JSON.stringify(current.ranges) === JSON.stringify(mine)) return;
|
|
978
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
979
|
+
}
|
|
925
980
|
function claimRanges(registry, m, repoRoot) {
|
|
926
981
|
const mine = rangesFor(m);
|
|
982
|
+
assertNoOverlap(registry, m, mine);
|
|
983
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
984
|
+
}
|
|
985
|
+
function assertNoOverlap(registry, m, mine) {
|
|
927
986
|
const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
|
|
928
987
|
for (const [service, range] of Object.entries(mine)) {
|
|
929
988
|
for (const [name, entry] of others) {
|
|
@@ -938,7 +997,6 @@ function claimRanges(registry, m, repoRoot) {
|
|
|
938
997
|
}
|
|
939
998
|
}
|
|
940
999
|
}
|
|
941
|
-
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
942
1000
|
}
|
|
943
1001
|
|
|
944
1002
|
// src/core/refresh.ts
|
|
@@ -1070,8 +1128,8 @@ import {
|
|
|
1070
1128
|
} from "fs";
|
|
1071
1129
|
var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
|
|
1072
1130
|
var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
|
|
1073
|
-
var LOCK_STALE_MS =
|
|
1074
|
-
var LOCK_WAIT_MS =
|
|
1131
|
+
var LOCK_STALE_MS = 1e4;
|
|
1132
|
+
var LOCK_WAIT_MS = 15e3;
|
|
1075
1133
|
function sleep(ms) {
|
|
1076
1134
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1077
1135
|
}
|
|
@@ -1127,7 +1185,9 @@ var Registry = class _Registry {
|
|
|
1127
1185
|
} catch {
|
|
1128
1186
|
try {
|
|
1129
1187
|
if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
|
|
1130
|
-
|
|
1188
|
+
const claimed = `${lock}.stale.${process.pid}.${Date.now()}`;
|
|
1189
|
+
renameSync(lock, claimed);
|
|
1190
|
+
rmSync2(claimed, { recursive: true, force: true });
|
|
1131
1191
|
continue;
|
|
1132
1192
|
}
|
|
1133
1193
|
} catch {
|
|
@@ -1237,7 +1297,7 @@ var Registry = class _Registry {
|
|
|
1237
1297
|
if (slot < 0) {
|
|
1238
1298
|
throw new WorkkitError(
|
|
1239
1299
|
"NO_FREE_SLOT",
|
|
1240
|
-
`no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")})`
|
|
1300
|
+
`no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")}): \`work status\` lists the efforts holding them; \`work finish\` or \`work reap\` frees one`
|
|
1241
1301
|
);
|
|
1242
1302
|
}
|
|
1243
1303
|
const lease = this.lease(
|
|
@@ -1480,10 +1540,14 @@ function currentOwner(env = process.env) {
|
|
|
1480
1540
|
return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
|
|
1481
1541
|
}
|
|
1482
1542
|
|
|
1543
|
+
// src/lib/pritty.ts
|
|
1544
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
1545
|
+
|
|
1483
1546
|
// src/lib/storybrokr.ts
|
|
1484
1547
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
1485
1548
|
var MIN_STORYBROKR_VERSION = "0.3.0";
|
|
1486
1549
|
var versionChecked = false;
|
|
1550
|
+
var versionFound = "";
|
|
1487
1551
|
function compareSemver(a, b) {
|
|
1488
1552
|
const pa = a.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1489
1553
|
const pb = b.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
@@ -1514,12 +1578,14 @@ function assertStorybrokrVersion() {
|
|
|
1514
1578
|
);
|
|
1515
1579
|
}
|
|
1516
1580
|
versionChecked = true;
|
|
1581
|
+
versionFound = found;
|
|
1517
1582
|
}
|
|
1518
1583
|
var storybrokrCli = {
|
|
1519
1584
|
async up(story, hostRoot, opts) {
|
|
1520
1585
|
assertStorybrokrVersion();
|
|
1521
1586
|
const args = ["up", story, "--host", hostRoot, "--json"];
|
|
1522
1587
|
if (opts?.ttlMinutes !== void 0) args.push("--ttl", String(opts.ttlMinutes));
|
|
1588
|
+
if (opts?.includeChildren) args.push("--include-children");
|
|
1523
1589
|
const r = run(args);
|
|
1524
1590
|
if (r.code !== 0) {
|
|
1525
1591
|
throw new WorkkitError(
|
|
@@ -1536,8 +1602,26 @@ var storybrokrCli = {
|
|
|
1536
1602
|
throw new WorkkitError("STORYBROKR_FAILED", `storybrokr down ${id} failed: ${r.err.trim()}`);
|
|
1537
1603
|
},
|
|
1538
1604
|
async downHost(hostRoot) {
|
|
1539
|
-
const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--json"], {
|
|
1540
|
-
|
|
1605
|
+
const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--if-running", "--json"], {
|
|
1606
|
+
encoding: "utf8"
|
|
1607
|
+
});
|
|
1608
|
+
if (r.error) return [];
|
|
1609
|
+
if (r.status !== 0) {
|
|
1610
|
+
throw new WorkkitError(
|
|
1611
|
+
"STORYBROKR_FAILED",
|
|
1612
|
+
`storybrokr down --host ${hostRoot} failed: ${(r.stderr || r.stdout || "").trim()}`
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
try {
|
|
1616
|
+
const parsed = JSON.parse(r.stdout);
|
|
1617
|
+
return parsed.stopped ?? [];
|
|
1618
|
+
} catch {
|
|
1619
|
+
return [];
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
version() {
|
|
1623
|
+
assertStorybrokrVersion();
|
|
1624
|
+
return versionFound;
|
|
1541
1625
|
},
|
|
1542
1626
|
async touch(id) {
|
|
1543
1627
|
const r = spawnSync4("storybrokr", ["touch", id], { encoding: "utf8" });
|
|
@@ -1545,6 +1629,81 @@ var storybrokrCli = {
|
|
|
1545
1629
|
}
|
|
1546
1630
|
};
|
|
1547
1631
|
|
|
1632
|
+
// src/lib/pritty.ts
|
|
1633
|
+
var MIN_PRITTY_VERSION = "0.1.1";
|
|
1634
|
+
function assertPrittyVersion(client) {
|
|
1635
|
+
const found = client.version();
|
|
1636
|
+
if (compareSemver(found, MIN_PRITTY_VERSION) < 0) {
|
|
1637
|
+
throw new WorkkitError(
|
|
1638
|
+
"PRITTY_FAILED",
|
|
1639
|
+
`pritty >= ${MIN_PRITTY_VERSION} required (found ${found || "unknown"}); npm i -g @helmsmith/pritty`
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
function run2(args, cwd) {
|
|
1644
|
+
const r = spawnSync5("pritty", args, { cwd, encoding: "utf8", env: process.env });
|
|
1645
|
+
if (r.error) {
|
|
1646
|
+
throw new WorkkitError(
|
|
1647
|
+
"PRITTY_FAILED",
|
|
1648
|
+
`pritty is not installed (npm i -g @helmsmith/pritty): ${r.error.message}`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
|
|
1652
|
+
}
|
|
1653
|
+
function lastJsonLine(out, what) {
|
|
1654
|
+
const line = out.trim().split("\n").filter(Boolean).pop() ?? "";
|
|
1655
|
+
try {
|
|
1656
|
+
return JSON.parse(line);
|
|
1657
|
+
} catch {
|
|
1658
|
+
throw new WorkkitError("PRITTY_FAILED", `pritty ${what} printed no JSON: ${out.trim()}`);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
var versionFound2;
|
|
1662
|
+
var prittyCli = {
|
|
1663
|
+
version() {
|
|
1664
|
+
if (versionFound2 === void 0) {
|
|
1665
|
+
const r = run2(["--version"]);
|
|
1666
|
+
versionFound2 = r.code === 0 ? r.out.trim() : "";
|
|
1667
|
+
}
|
|
1668
|
+
return versionFound2;
|
|
1669
|
+
},
|
|
1670
|
+
async pr(o) {
|
|
1671
|
+
const args = ["pr", "--base", o.base, "--no-push", "--auto-approve", "--json"];
|
|
1672
|
+
if (o.draft) args.push("--draft");
|
|
1673
|
+
if (o.title !== void 0) args.push("--title", o.title);
|
|
1674
|
+
if (o.body !== void 0) args.push("--body", o.body);
|
|
1675
|
+
if (o.bodyFile !== void 0) args.push("--body-file", o.bodyFile);
|
|
1676
|
+
const r = run2(args, o.cwd);
|
|
1677
|
+
if (r.code !== 0) {
|
|
1678
|
+
throw new WorkkitError("PRITTY_FAILED", `pritty pr failed: ${(r.err || r.out).trim()}`, {
|
|
1679
|
+
stderr: r.err
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
const j = lastJsonLine(
|
|
1683
|
+
r.out,
|
|
1684
|
+
"pr"
|
|
1685
|
+
);
|
|
1686
|
+
return { number: j.number, url: j.url, draft: Boolean(j.draft), created: Boolean(j.created) };
|
|
1687
|
+
},
|
|
1688
|
+
async check(o) {
|
|
1689
|
+
const r = run2(["pr", "--check", "--base", o.base], o.cwd);
|
|
1690
|
+
if (r.code !== 0) {
|
|
1691
|
+
throw new WorkkitError(
|
|
1692
|
+
"PRITTY_FAILED",
|
|
1693
|
+
`pritty pr --check failed: ${(r.err || r.out).trim()}`,
|
|
1694
|
+
{
|
|
1695
|
+
stderr: r.err
|
|
1696
|
+
}
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
const j = lastJsonLine(
|
|
1700
|
+
r.out,
|
|
1701
|
+
"pr --check"
|
|
1702
|
+
);
|
|
1703
|
+
return j.open ? { number: j.open.number, url: j.open.url, draft: Boolean(j.open.draft) } : null;
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1548
1707
|
// src/lib/context.ts
|
|
1549
1708
|
function resolveRepoRoot(cwd, override) {
|
|
1550
1709
|
if (override) return override;
|
|
@@ -1555,7 +1714,7 @@ async function buildCtx(opts = {}) {
|
|
|
1555
1714
|
const manifest = await loadManifest(repoRoot);
|
|
1556
1715
|
const home = homeDir();
|
|
1557
1716
|
const registry = Registry.open(home);
|
|
1558
|
-
|
|
1717
|
+
ensureRanges(registry, manifest, repoRoot);
|
|
1559
1718
|
return {
|
|
1560
1719
|
home,
|
|
1561
1720
|
repoRoot,
|
|
@@ -1564,6 +1723,7 @@ async function buildCtx(opts = {}) {
|
|
|
1564
1723
|
owner: currentOwner(),
|
|
1565
1724
|
runner: { start: startServices, stop: stopServices },
|
|
1566
1725
|
storybrokr: storybrokrCli,
|
|
1726
|
+
pritty: prittyCli,
|
|
1567
1727
|
log: opts.quiet ? () => {
|
|
1568
1728
|
} : (line) => console.error(chalk.gray(line))
|
|
1569
1729
|
};
|
|
@@ -1576,9 +1736,11 @@ import {
|
|
|
1576
1736
|
existsSync as existsSync7,
|
|
1577
1737
|
mkdirSync as mkdirSync6,
|
|
1578
1738
|
readdirSync,
|
|
1739
|
+
readFileSync as readFileSync5,
|
|
1579
1740
|
realpathSync as realpathSync4,
|
|
1580
1741
|
rmSync as rmSync3,
|
|
1581
|
-
statSync as statSync3
|
|
1742
|
+
statSync as statSync3,
|
|
1743
|
+
writeFileSync as writeFileSync5
|
|
1582
1744
|
} from "fs";
|
|
1583
1745
|
import { connect, createServer } from "net";
|
|
1584
1746
|
import { dirname as dirname3, join as join8 } from "path";
|
|
@@ -1623,22 +1785,26 @@ function touchEffort(ctx, effort) {
|
|
|
1623
1785
|
}
|
|
1624
1786
|
ctx.registry.updateEffort(effort.id, { lastActivity: now() });
|
|
1625
1787
|
}
|
|
1626
|
-
function
|
|
1788
|
+
function bindFails(port, host) {
|
|
1627
1789
|
return new Promise((resolve4) => {
|
|
1628
1790
|
const srv = createServer();
|
|
1629
|
-
srv.once("error", () => resolve4(
|
|
1630
|
-
srv.listen(port,
|
|
1791
|
+
srv.once("error", (err) => resolve4(err.code === "EADDRINUSE"));
|
|
1792
|
+
srv.listen(port, host, () => srv.close(() => resolve4(false)));
|
|
1631
1793
|
});
|
|
1632
1794
|
}
|
|
1795
|
+
async function portBusy(port) {
|
|
1796
|
+
for (const host of ["127.0.0.1", "0.0.0.0", "::"]) if (await bindFails(port, host)) return true;
|
|
1797
|
+
return false;
|
|
1798
|
+
}
|
|
1633
1799
|
async function busyPorts(ports) {
|
|
1634
1800
|
const out = [];
|
|
1635
1801
|
for (const [service, port] of Object.entries(ports))
|
|
1636
1802
|
if (await portBusy(port)) out.push(`${service}=${port}`);
|
|
1637
1803
|
return out;
|
|
1638
1804
|
}
|
|
1639
|
-
function
|
|
1805
|
+
function portAnswersOn(port, host) {
|
|
1640
1806
|
return new Promise((resolve4) => {
|
|
1641
|
-
const sock = connect({ port, host
|
|
1807
|
+
const sock = connect({ port, host });
|
|
1642
1808
|
const done = (v) => {
|
|
1643
1809
|
sock.destroy();
|
|
1644
1810
|
resolve4(v);
|
|
@@ -1648,6 +1814,9 @@ function portAnswers(port) {
|
|
|
1648
1814
|
sock.once("error", () => done(false));
|
|
1649
1815
|
});
|
|
1650
1816
|
}
|
|
1817
|
+
async function portAnswers(port) {
|
|
1818
|
+
return await portAnswersOn(port, "127.0.0.1") || await portAnswersOn(port, "::1");
|
|
1819
|
+
}
|
|
1651
1820
|
async function anyPortAnswers(ports) {
|
|
1652
1821
|
for (const port of Object.values(ports ?? {})) if (await portAnswers(port)) return true;
|
|
1653
1822
|
return false;
|
|
@@ -1764,6 +1933,15 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
|
|
|
1764
1933
|
});
|
|
1765
1934
|
return effort;
|
|
1766
1935
|
}
|
|
1936
|
+
function areaBranchFor(ctx, area, preferredType) {
|
|
1937
|
+
const main = new Git(ctx.repoRoot);
|
|
1938
|
+
const types = [preferredType, ...ctx.manifest.branch.types.filter((t) => t !== preferredType)];
|
|
1939
|
+
for (const type of types) {
|
|
1940
|
+
const b = branchFor(type, area);
|
|
1941
|
+
if (main.branchExists(b) || main.remoteRef(b)) return b;
|
|
1942
|
+
}
|
|
1943
|
+
return branchFor(preferredType, area);
|
|
1944
|
+
}
|
|
1767
1945
|
function ensureWorktree(ctx, area, branch, task) {
|
|
1768
1946
|
const m = ctx.manifest;
|
|
1769
1947
|
const path = worktreePathFor(ctx, area, task);
|
|
@@ -1815,8 +1993,25 @@ function appendLog(file, line) {
|
|
|
1815
1993
|
appendFileSync2(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
1816
1994
|
`);
|
|
1817
1995
|
}
|
|
1996
|
+
var PENDING_MARKS = ".pending-marks.json";
|
|
1997
|
+
function readPendingMarks(logDir) {
|
|
1998
|
+
try {
|
|
1999
|
+
const raw = JSON.parse(readFileSync5(join8(logDir, PENDING_MARKS), "utf8"));
|
|
2000
|
+
return Array.isArray(raw) ? raw.filter((x) => typeof x === "string") : [];
|
|
2001
|
+
} catch {
|
|
2002
|
+
return [];
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
function writePendingMarks(logDir, marks) {
|
|
2006
|
+
mkdirSync6(logDir, { recursive: true });
|
|
2007
|
+
writeFileSync5(join8(logDir, PENDING_MARKS), `${JSON.stringify(marks)}
|
|
2008
|
+
`);
|
|
2009
|
+
}
|
|
1818
2010
|
async function startEffort(ctx, o) {
|
|
1819
2011
|
const m = ctx.manifest;
|
|
2012
|
+
await reapLeases(ctx).catch(
|
|
2013
|
+
(err) => ctx.log(`pre-flight reap: ${err instanceof Error ? err.message : String(err)}`)
|
|
2014
|
+
);
|
|
1820
2015
|
fetchBaseOnce(ctx);
|
|
1821
2016
|
const { branch, scopes, commons, warnings } = guardStart(ctx, o);
|
|
1822
2017
|
const notices = ctx.registry.notices(m.project, o.area);
|
|
@@ -1960,6 +2155,9 @@ async function startEffort(ctx, o) {
|
|
|
1960
2155
|
}
|
|
1961
2156
|
);
|
|
1962
2157
|
const baseSha = wg.rev("HEAD");
|
|
2158
|
+
if (o.mode === "frontend" && sel.marks.length > 0) {
|
|
2159
|
+
writePendingMarks(logDir, [.../* @__PURE__ */ new Set([...readPendingMarks(logDir), ...sel.marks])]);
|
|
2160
|
+
}
|
|
1963
2161
|
for (const name of commons)
|
|
1964
2162
|
leases.push(
|
|
1965
2163
|
ctx.registry.addLease(
|
|
@@ -1983,8 +2181,13 @@ async function startEffort(ctx, o) {
|
|
|
1983
2181
|
branch
|
|
1984
2182
|
});
|
|
1985
2183
|
const env = { ...process.env, ...cfg.vars };
|
|
1986
|
-
|
|
2184
|
+
const owedMarks = [.../* @__PURE__ */ new Set([...sel.marks, ...readPendingMarks(logDir)])];
|
|
2185
|
+
if (owedMarks.includes("db-reset") && m.services.postgres?.reset) {
|
|
1987
2186
|
runRefresh([m.services.postgres.reset], worktree, (l) => appendLog(refreshLog, l), env);
|
|
2187
|
+
writePendingMarks(
|
|
2188
|
+
logDir,
|
|
2189
|
+
owedMarks.filter((x) => x !== "db-reset")
|
|
2190
|
+
);
|
|
1988
2191
|
}
|
|
1989
2192
|
const pids = {};
|
|
1990
2193
|
const signatures = {};
|
|
@@ -2073,7 +2276,7 @@ async function promoteEffort(ctx, o) {
|
|
|
2073
2276
|
}
|
|
2074
2277
|
const modeScopes = expandScopes(m, o.area, o.mode);
|
|
2075
2278
|
const nextScopes = open.task ? [.../* @__PURE__ */ new Set([...open.scopes, ...modeScopes])] : modeScopes;
|
|
2076
|
-
|
|
2279
|
+
const refuseOverlap = (r) => {
|
|
2077
2280
|
const others = r.efforts.filter(
|
|
2078
2281
|
(e) => e.project === m.project && e.id !== open.id && (e.state === "active" || e.state === "starting")
|
|
2079
2282
|
);
|
|
@@ -2088,17 +2291,49 @@ async function promoteEffort(ctx, o) {
|
|
|
2088
2291
|
);
|
|
2089
2292
|
}
|
|
2090
2293
|
}
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
r.efforts[idx] = { ...r.efforts[idx], mode: o.mode, scopes: nextScopes, lastActivity: now() };
|
|
2094
|
-
}
|
|
2095
|
-
});
|
|
2294
|
+
};
|
|
2295
|
+
ctx.registry.update(refuseOverlap);
|
|
2096
2296
|
const warnings = [];
|
|
2097
2297
|
let slot = ctx.registry.leases({ effort: open.id, kind: "slot" })[0];
|
|
2098
2298
|
const sb = ctx.registry.leases({ effort: open.id, kind: "storybook" })[0];
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2299
|
+
const claimed = !slot;
|
|
2300
|
+
if (!slot) slot = await claimFreeSlot(ctx, open.id, warnings);
|
|
2301
|
+
try {
|
|
2302
|
+
ctx.registry.update((r) => {
|
|
2303
|
+
refuseOverlap(r);
|
|
2304
|
+
const idx = r.efforts.findIndex((e) => e.id === open.id);
|
|
2305
|
+
if (idx >= 0) {
|
|
2306
|
+
r.efforts[idx] = {
|
|
2307
|
+
...r.efforts[idx],
|
|
2308
|
+
mode: o.mode,
|
|
2309
|
+
scopes: nextScopes,
|
|
2310
|
+
leases: claimed ? [...open.leases, slot.id] : r.efforts[idx].leases,
|
|
2311
|
+
lastActivity: now()
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
});
|
|
2315
|
+
} catch (err) {
|
|
2316
|
+
if (claimed) ctx.registry.release(slot.id);
|
|
2317
|
+
throw err;
|
|
2318
|
+
}
|
|
2319
|
+
const owed = readPendingMarks(join8(open.worktree, m.logs.dir));
|
|
2320
|
+
if (owed.includes("db-reset") && m.services.postgres?.reset) {
|
|
2321
|
+
const cfgForReset = emitWorktreeConfig({
|
|
2322
|
+
m,
|
|
2323
|
+
worktree: open.worktree,
|
|
2324
|
+
slot: slot.value,
|
|
2325
|
+
ports: slot.ports ?? {},
|
|
2326
|
+
area: o.area,
|
|
2327
|
+
branch: open.branch
|
|
2328
|
+
});
|
|
2329
|
+
runRefresh([m.services.postgres.reset], open.worktree, ctx.log, {
|
|
2330
|
+
...process.env,
|
|
2331
|
+
...cfgForReset.vars
|
|
2332
|
+
});
|
|
2333
|
+
writePendingMarks(
|
|
2334
|
+
join8(open.worktree, m.logs.dir),
|
|
2335
|
+
owed.filter((x) => x !== "db-reset")
|
|
2336
|
+
);
|
|
2102
2337
|
}
|
|
2103
2338
|
const cfg = emitWorktreeConfig({
|
|
2104
2339
|
m,
|
|
@@ -2149,9 +2384,7 @@ async function promoteEffort(ctx, o) {
|
|
|
2149
2384
|
}
|
|
2150
2385
|
function behindBase(ctx, worktree) {
|
|
2151
2386
|
if (!existsSync7(worktree)) return 0;
|
|
2152
|
-
|
|
2153
|
-
const r = g.tryRun(["rev-list", "--count", `HEAD..origin/${ctx.manifest.baseBranch}`]);
|
|
2154
|
-
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
2387
|
+
return new Git(worktree).behindCount(ctx.manifest.baseBranch);
|
|
2155
2388
|
}
|
|
2156
2389
|
function statusFor(ctx, area, task) {
|
|
2157
2390
|
const m = ctx.manifest;
|
|
@@ -2233,6 +2466,15 @@ async function releaseLease(ctx, effort, lease) {
|
|
|
2233
2466
|
}
|
|
2234
2467
|
ctx.registry.release(lease.id);
|
|
2235
2468
|
}
|
|
2469
|
+
async function sweepStorybook(ctx, effort) {
|
|
2470
|
+
if (effort.mode === "backend" || !ctx.storybrokr.downHost) return;
|
|
2471
|
+
try {
|
|
2472
|
+
const stopped = await ctx.storybrokr.downHost(storybookHostFor(ctx.manifest, effort.worktree));
|
|
2473
|
+
if (stopped.length > 0) ctx.log(`storybrokr: stopped ${stopped.join(", ")}`);
|
|
2474
|
+
} catch (err) {
|
|
2475
|
+
ctx.log(`storybrokr sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2236
2478
|
function leaveNotices(ctx, effort, commons) {
|
|
2237
2479
|
if (commons.length === 0) return 0;
|
|
2238
2480
|
const notice = {
|
|
@@ -2301,7 +2543,7 @@ async function finishEffort(ctx, o) {
|
|
|
2301
2543
|
);
|
|
2302
2544
|
}
|
|
2303
2545
|
fetchBaseOnce(ctx);
|
|
2304
|
-
const parentBranch =
|
|
2546
|
+
const parentBranch = areaBranchFor(ctx, effort.area, effort.type);
|
|
2305
2547
|
const { wg: areaGit } = ensureWorktree(ctx, effort.area, parentBranch);
|
|
2306
2548
|
if (areaGit.isDirty()) {
|
|
2307
2549
|
throw new WorkkitError(
|
|
@@ -2321,14 +2563,22 @@ async function finishEffort(ctx, o) {
|
|
|
2321
2563
|
);
|
|
2322
2564
|
}
|
|
2323
2565
|
}
|
|
2566
|
+
if (!o.intoArea) {
|
|
2567
|
+
try {
|
|
2568
|
+
g.push(effort.branch);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
throw new WorkkitError(
|
|
2571
|
+
"PUSH_REJECTED",
|
|
2572
|
+
`push of ${effort.branch} was rejected (${err instanceof Error ? err.message : String(err)}); nothing was stopped or released. Merge origin/${effort.branch} into the worktree and run \`work finish\` again.`,
|
|
2573
|
+
{ branch: effort.branch, worktree: effort.worktree }
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2324
2577
|
for (const lease of ctx.registry.leases({ effort: effort.id }))
|
|
2325
2578
|
await releaseLease(ctx, effort, lease);
|
|
2326
|
-
await
|
|
2327
|
-
() => void 0
|
|
2328
|
-
);
|
|
2579
|
+
await sweepStorybook(ctx, effort);
|
|
2329
2580
|
let notices = 0;
|
|
2330
2581
|
if (!o.intoArea) {
|
|
2331
|
-
g.push(effort.branch);
|
|
2332
2582
|
if (g.commitsNotOn(m.baseBranch).length > 0 && !g.isEquivalentToBase(m.baseBranch)) {
|
|
2333
2583
|
const since = g.changedSince(m.baseBranch);
|
|
2334
2584
|
const sinceSet = new Set(since);
|
|
@@ -2371,19 +2621,24 @@ async function reapLeases(ctx, o = {}) {
|
|
|
2371
2621
|
const garbage = orphaned && !await anyPortAnswers(l.ports);
|
|
2372
2622
|
if (expired || garbage) doomed.add(l.effort);
|
|
2373
2623
|
}
|
|
2624
|
+
for (const e of efforts.values()) {
|
|
2625
|
+
if (e.state !== "starting" || doomed.has(e.id)) continue;
|
|
2626
|
+
if (leases.some((l) => l.effort === e.id)) continue;
|
|
2627
|
+
if (Date.now() - Date.parse(e.started) < STARTING_GRACE_MS) continue;
|
|
2628
|
+
doomed.add(e.id);
|
|
2629
|
+
}
|
|
2374
2630
|
const reaped = leases.filter((l) => doomed.has(l.effort));
|
|
2375
2631
|
if (o.dryRun) return { reaped };
|
|
2376
2632
|
for (const lease of reaped) await releaseLease(ctx, efforts.get(lease.effort), lease);
|
|
2377
2633
|
for (const id of doomed) {
|
|
2378
2634
|
const effort = efforts.get(id);
|
|
2379
2635
|
if (effort) {
|
|
2380
|
-
await
|
|
2381
|
-
() => void 0
|
|
2382
|
-
);
|
|
2636
|
+
await sweepStorybook(ctx, effort);
|
|
2383
2637
|
if (effort.state === "active" || effort.state === "starting") {
|
|
2638
|
+
const hadLease = leases.some((l) => l.effort === effort.id);
|
|
2384
2639
|
ctx.registry.updateEffort(effort.id, {
|
|
2385
2640
|
state: "parked",
|
|
2386
|
-
parkedReason: `idle: a lease expired by ${at.toISOString()}`,
|
|
2641
|
+
parkedReason: hadLease ? `idle: a lease expired by ${at.toISOString()}` : `start died before its first lease (started ${effort.started})`,
|
|
2387
2642
|
lastActivity: now()
|
|
2388
2643
|
});
|
|
2389
2644
|
}
|
|
@@ -2441,9 +2696,6 @@ async function retireArea(ctx, o) {
|
|
|
2441
2696
|
const worktree = realpathOr2(worktreePathFor(ctx, o.area));
|
|
2442
2697
|
const main = new Git(ctx.repoRoot);
|
|
2443
2698
|
let archived = [];
|
|
2444
|
-
await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, worktree))).catch(
|
|
2445
|
-
() => void 0
|
|
2446
|
-
);
|
|
2447
2699
|
if (existsSync7(worktree)) {
|
|
2448
2700
|
const g = new Git(worktree);
|
|
2449
2701
|
if (g.isDirty())
|
|
@@ -2459,6 +2711,8 @@ async function retireArea(ctx, o) {
|
|
|
2459
2711
|
`${g.currentBranch()} has ${unpushed} commit(s) not on origin; push before retiring`,
|
|
2460
2712
|
{ worktree, unpushed }
|
|
2461
2713
|
);
|
|
2714
|
+
const hadStorybook = ctx.registry.efforts({ project: m.project, area: o.area }).some((e) => e.mode !== "backend");
|
|
2715
|
+
if (hadStorybook) await sweepStorybook(ctx, { worktree, mode: "frontend" });
|
|
2462
2716
|
archived = archiveWorktree(ctx, o.area, worktree);
|
|
2463
2717
|
main.worktreeRemove(worktree);
|
|
2464
2718
|
}
|
|
@@ -2478,6 +2732,93 @@ async function retireArea(ctx, o) {
|
|
|
2478
2732
|
ctx.registry.clearNoticesFrom(m.project, allAreaBranches);
|
|
2479
2733
|
return { archived, removed: worktree, branchesDeleted, branchesKept };
|
|
2480
2734
|
}
|
|
2735
|
+
function effortAt(ctx, cwd) {
|
|
2736
|
+
const top = new Git(cwd).tryRun(["rev-parse", "--show-toplevel"]);
|
|
2737
|
+
if (top.code !== 0) return void 0;
|
|
2738
|
+
const root = realpathOr2(top.out.trim());
|
|
2739
|
+
return ctx.registry.efforts({ project: ctx.manifest.project }).filter((e) => e.state === "active" || e.state === "starting" || e.state === "parked").sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)).find((e) => realpathOr2(e.worktree) === root);
|
|
2740
|
+
}
|
|
2741
|
+
async function prEffort(ctx, o) {
|
|
2742
|
+
const m = ctx.manifest;
|
|
2743
|
+
assertPrittyVersion(ctx.pritty);
|
|
2744
|
+
const effort = o.area ? ctx.registry.openEffortFor(m.project, o.area, o.task) ?? ctx.registry.efforts({ project: m.project, area: o.area, state: "parked" }).filter((e) => (e.task ?? void 0) === (o.task ?? void 0)).sort((a, b) => b.started.localeCompare(a.started))[0] : effortAt(ctx, o.cwd ?? process.cwd());
|
|
2745
|
+
if (!effort) {
|
|
2746
|
+
throw new WorkkitError(
|
|
2747
|
+
"EFFORT_NOT_FOUND",
|
|
2748
|
+
o.area ? `no open or parked effort on ${effortLabel2(o.area, o.task)}` : `${o.cwd ?? process.cwd()} is not inside an effort's worktree; pass the area`,
|
|
2749
|
+
{ area: o.area, task: o.task }
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
const label = effortLabel2(effort.area, effort.task);
|
|
2753
|
+
const g = new Git(effort.worktree);
|
|
2754
|
+
if (g.isDirty()) {
|
|
2755
|
+
throw new WorkkitError(
|
|
2756
|
+
"DIRTY_WORKTREE",
|
|
2757
|
+
`${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${label}) and push`,
|
|
2758
|
+
{ worktree: effort.worktree }
|
|
2759
|
+
);
|
|
2760
|
+
}
|
|
2761
|
+
fetchBaseOnce(ctx);
|
|
2762
|
+
const warnings = [];
|
|
2763
|
+
if (!g.hasUpstream() || g.unpushedCount(m.baseBranch) > 0) {
|
|
2764
|
+
try {
|
|
2765
|
+
g.push(effort.branch);
|
|
2766
|
+
} catch (err) {
|
|
2767
|
+
throw new WorkkitError(
|
|
2768
|
+
"PUSH_REJECTED",
|
|
2769
|
+
`push of ${effort.branch} was rejected (${err instanceof Error ? err.message : String(err)}); merge origin/${effort.branch} into the worktree and run \`work pr\` again`,
|
|
2770
|
+
{ branch: effort.branch, worktree: effort.worktree }
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
const behind = g.behindCount(m.baseBranch);
|
|
2775
|
+
if (behind > BEHIND_LIMIT) {
|
|
2776
|
+
warnings.push(
|
|
2777
|
+
`${effort.branch} is ${behind} commits behind ${m.baseBranch}; consider merging origin/${m.baseBranch} before review`
|
|
2778
|
+
);
|
|
2779
|
+
}
|
|
2780
|
+
const given = o.title !== void 0 || o.body !== void 0 || o.bodyFile !== void 0;
|
|
2781
|
+
const store = (pr2) => {
|
|
2782
|
+
const updated = ctx.registry.updateEffort(effort.id, { pr: pr2, lastActivity: now() });
|
|
2783
|
+
touchEffort(ctx, updated);
|
|
2784
|
+
return updated;
|
|
2785
|
+
};
|
|
2786
|
+
if (effort.pr) {
|
|
2787
|
+
const open = await ctx.pritty.check({ cwd: effort.worktree, base: m.baseBranch });
|
|
2788
|
+
if (open) {
|
|
2789
|
+
if (!given) {
|
|
2790
|
+
return { effort: store(open), pr: open, created: false, behindBase: behind, warnings };
|
|
2791
|
+
}
|
|
2792
|
+
const updated = await ctx.pritty.pr({
|
|
2793
|
+
cwd: effort.worktree,
|
|
2794
|
+
base: m.baseBranch,
|
|
2795
|
+
draft: o.draft,
|
|
2796
|
+
title: o.title,
|
|
2797
|
+
body: o.body,
|
|
2798
|
+
bodyFile: o.bodyFile
|
|
2799
|
+
});
|
|
2800
|
+
const pr2 = { number: updated.number, url: updated.url, draft: updated.draft };
|
|
2801
|
+
return { effort: store(pr2), pr: pr2, created: false, behindBase: behind, warnings };
|
|
2802
|
+
}
|
|
2803
|
+
if (!o.force) {
|
|
2804
|
+
throw new WorkkitError(
|
|
2805
|
+
"PR_CLOSED",
|
|
2806
|
+
`PR #${effort.pr.number} for ${label} is no longer open (${effort.pr.url}); pass --force to open a new one`,
|
|
2807
|
+
{ pr: effort.pr }
|
|
2808
|
+
);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
const res = await ctx.pritty.pr({
|
|
2812
|
+
cwd: effort.worktree,
|
|
2813
|
+
base: m.baseBranch,
|
|
2814
|
+
draft: o.draft,
|
|
2815
|
+
title: o.title,
|
|
2816
|
+
body: o.body,
|
|
2817
|
+
bodyFile: o.bodyFile
|
|
2818
|
+
});
|
|
2819
|
+
const pr = { number: res.number, url: res.url, draft: res.draft };
|
|
2820
|
+
return { effort: store(pr), pr, created: res.created, behindBase: behind, warnings };
|
|
2821
|
+
}
|
|
2481
2822
|
var BEHIND_LIMIT = 20;
|
|
2482
2823
|
var NOTICE_STALE_MS = 7 * 864e5;
|
|
2483
2824
|
var ARCHIVE_LIMIT_BYTES = 5 * 1024 ** 3;
|
|
@@ -2665,7 +3006,7 @@ function registerFinish(program2) {
|
|
|
2665
3006
|
}
|
|
2666
3007
|
|
|
2667
3008
|
// src/commands/hook.ts
|
|
2668
|
-
import { readFileSync as
|
|
3009
|
+
import { readFileSync as readFileSync6, realpathSync as realpathSync5 } from "fs";
|
|
2669
3010
|
import { resolve as resolve2 } from "path";
|
|
2670
3011
|
import chalk5 from "chalk";
|
|
2671
3012
|
var ZEROS = /^0+$/;
|
|
@@ -2763,7 +3104,7 @@ function registerHook(program2) {
|
|
|
2763
3104
|
"Refuse pushes to protected branches; warn when paths leave the effort scope or touch commons"
|
|
2764
3105
|
).argument("[remote]", "passed by git, unused").argument("[url]", "passed by git, unused").action(async () => {
|
|
2765
3106
|
try {
|
|
2766
|
-
const stdin = process.stdin.isTTY ? "" :
|
|
3107
|
+
const stdin = process.stdin.isTTY ? "" : readFileSync6(0, "utf8");
|
|
2767
3108
|
process.exit(await runPrePush(process.cwd(), stdin));
|
|
2768
3109
|
} catch (err) {
|
|
2769
3110
|
fail(err, false);
|
|
@@ -2786,7 +3127,7 @@ function registerHook(program2) {
|
|
|
2786
3127
|
}
|
|
2787
3128
|
|
|
2788
3129
|
// src/commands/init.ts
|
|
2789
|
-
import { existsSync as existsSync8, writeFileSync as
|
|
3130
|
+
import { existsSync as existsSync8, writeFileSync as writeFileSync6 } from "fs";
|
|
2790
3131
|
import { join as join9, resolve as resolve3 } from "path";
|
|
2791
3132
|
var STORYBOOK_BASE = 6100;
|
|
2792
3133
|
function defaultService(name) {
|
|
@@ -2862,7 +3203,7 @@ function runInit(o) {
|
|
|
2862
3203
|
}
|
|
2863
3204
|
const manifest = buildStarterManifest(o);
|
|
2864
3205
|
const manifestPath = join9(repoRoot, "helmsmith.config.mjs");
|
|
2865
|
-
|
|
3206
|
+
writeFileSync6(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
|
|
2866
3207
|
`);
|
|
2867
3208
|
claimRanges(Registry.open(o.home), validateManifest(manifest), repoRoot);
|
|
2868
3209
|
const hooks = o.hooks ? installHooks(repoRoot, { protectedBranches: [o.baseBranch] }).files : [];
|
|
@@ -2930,7 +3271,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
2930
3271
|
import { z as z2 } from "zod";
|
|
2931
3272
|
|
|
2932
3273
|
// src/version.ts
|
|
2933
|
-
import { existsSync as existsSync9, readFileSync as
|
|
3274
|
+
import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
|
|
2934
3275
|
import { dirname as dirname4, join as join10 } from "path";
|
|
2935
3276
|
import { fileURLToPath } from "url";
|
|
2936
3277
|
function findPackageJson(startDir) {
|
|
@@ -2944,7 +3285,7 @@ function findPackageJson(startDir) {
|
|
|
2944
3285
|
}
|
|
2945
3286
|
}
|
|
2946
3287
|
var pkgFile = findPackageJson(dirname4(fileURLToPath(import.meta.url)));
|
|
2947
|
-
var VERSION = JSON.parse(
|
|
3288
|
+
var VERSION = JSON.parse(readFileSync7(pkgFile, "utf8")).version;
|
|
2948
3289
|
|
|
2949
3290
|
// src/mcp/server.ts
|
|
2950
3291
|
var ok = (value) => ({
|
|
@@ -2969,7 +3310,9 @@ function buildMcpServer(ctxFactory) {
|
|
|
2969
3310
|
description: "Start an effort on an area (or intra-area task): its worktree on <type>/<area> (or <type>/<area>--<task>), synced from the base branch, refreshed, leased (a slot for fullstack/backend, a storybrokr instance for frontend with a story) and running. Refuses with EFFORT_OPEN (another effort is open; join=true to share it), SCOPE_CONFLICT, SYNC_CONFLICT (resolve in the worktree, rerun with sync=false).",
|
|
2970
3311
|
inputSchema: {
|
|
2971
3312
|
area: z2.string().describe("a leaf area from helmsmith.config (e.g. org/applications)"),
|
|
2972
|
-
task: z2.string().optional().describe(
|
|
3313
|
+
task: z2.string().optional().describe(
|
|
3314
|
+
"optional intra-area task slug for parallel sub-efforts (<type>/<area>--<task>)"
|
|
3315
|
+
),
|
|
2973
3316
|
type: z2.string().describe("feature | chore | fix"),
|
|
2974
3317
|
mode: z2.enum(["frontend", "backend", "fullstack"]),
|
|
2975
3318
|
scopes: z2.array(z2.string()).optional().describe("optional narrowed scope globs for a task sub-effort"),
|
|
@@ -3002,11 +3345,38 @@ function buildMcpServer(ctxFactory) {
|
|
|
3002
3345
|
area: z2.string().describe("the area of the open effort"),
|
|
3003
3346
|
task: z2.string().optional().describe("optional intra-area task slug"),
|
|
3004
3347
|
mode: z2.enum(["backend", "fullstack"]).describe("target mode to promote into"),
|
|
3005
|
-
story: z2.string().optional().describe("optional story path"),
|
|
3006
3348
|
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3007
3349
|
}
|
|
3008
3350
|
},
|
|
3009
|
-
async ({ area, task, mode,
|
|
3351
|
+
async ({ area, task, mode, projectRoot }) => guard(async () => promoteEffort(await ctxFactory(projectRoot), { area, task, mode }))
|
|
3352
|
+
);
|
|
3353
|
+
server.registerTool(
|
|
3354
|
+
"work_pr",
|
|
3355
|
+
{
|
|
3356
|
+
description: "Open the effort's pull request through pritty: pushes the branch first, reuses a stored PR that is still open (updating its title/body when given), refuses with PR_CLOSED when it was merged or closed unless force, and stores { number, url, draft } on the effort. Pass title and body: the agent writes the words, pritty's AI path is only the fallback.",
|
|
3357
|
+
inputSchema: {
|
|
3358
|
+
area: z2.string().optional().describe("the area of the effort (default: the effort of the cwd worktree)"),
|
|
3359
|
+
task: z2.string().optional().describe("optional intra-area task slug"),
|
|
3360
|
+
draft: z2.boolean().optional().describe("open a draft PR"),
|
|
3361
|
+
title: z2.string().optional().describe("PR title"),
|
|
3362
|
+
// body text only: a file path here would let any MCP caller have pritty read an
|
|
3363
|
+
// arbitrary file and publish it as a PR body; read the file yourself and pass the text
|
|
3364
|
+
body: z2.string().optional().describe("PR body text (read your body file and pass its text)"),
|
|
3365
|
+
force: z2.boolean().optional().describe("open a new PR when the stored one was closed"),
|
|
3366
|
+
projectRoot: z2.string().optional().describe("optional repository root path override")
|
|
3367
|
+
}
|
|
3368
|
+
},
|
|
3369
|
+
async ({ area, task, draft, title, body, force, projectRoot }) => guard(
|
|
3370
|
+
async () => prEffort(await ctxFactory(projectRoot), {
|
|
3371
|
+
area,
|
|
3372
|
+
task,
|
|
3373
|
+
cwd: process.cwd(),
|
|
3374
|
+
draft,
|
|
3375
|
+
title,
|
|
3376
|
+
body,
|
|
3377
|
+
force
|
|
3378
|
+
})
|
|
3379
|
+
)
|
|
3010
3380
|
);
|
|
3011
3381
|
server.registerTool(
|
|
3012
3382
|
"work_status",
|
|
@@ -3084,24 +3454,56 @@ function registerMcp(program2) {
|
|
|
3084
3454
|
});
|
|
3085
3455
|
}
|
|
3086
3456
|
|
|
3087
|
-
// src/commands/
|
|
3457
|
+
// src/commands/pr.ts
|
|
3088
3458
|
import chalk6 from "chalk";
|
|
3459
|
+
function registerPr(program2) {
|
|
3460
|
+
program2.command("pr [area]").description(
|
|
3461
|
+
"Open the effort's pull request through pritty (push first, reuse an open PR, store it on the effort). No area: the effort of the cwd's worktree."
|
|
3462
|
+
).option("--task <slug>", "intra-area task slug").option("--draft", "open a draft PR").option("--title <title>", "PR title (with --body or --body-file: no AI involved)").option("--body <text>", "PR body text").option("--body-file <path>", "PR body from a file").option("--force", "open a new PR when the stored one was merged or closed").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
3463
|
+
async (area, o) => {
|
|
3464
|
+
try {
|
|
3465
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
3466
|
+
const r = await prEffort(ctx, {
|
|
3467
|
+
area,
|
|
3468
|
+
task: o.task,
|
|
3469
|
+
cwd: process.cwd(),
|
|
3470
|
+
draft: o.draft,
|
|
3471
|
+
title: o.title,
|
|
3472
|
+
body: o.body,
|
|
3473
|
+
bodyFile: o.bodyFile,
|
|
3474
|
+
force: o.force
|
|
3475
|
+
});
|
|
3476
|
+
if (o.json) printJson(r);
|
|
3477
|
+
else {
|
|
3478
|
+
for (const w of r.warnings) console.error(chalk6.yellow(w));
|
|
3479
|
+
console.log(
|
|
3480
|
+
`${r.created ? "opened" : "open"} PR #${r.pr.number}${r.pr.draft ? " (draft)" : ""} for ${r.effort.branch}: ${r.pr.url}`
|
|
3481
|
+
);
|
|
3482
|
+
}
|
|
3483
|
+
} catch (err) {
|
|
3484
|
+
fail(err, Boolean(o.json));
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
);
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
// src/commands/promote.ts
|
|
3491
|
+
import chalk7 from "chalk";
|
|
3089
3492
|
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("--
|
|
3493
|
+
program2.command("promote").description("Promote an open effort in place to backend or fullstack mode").requiredOption("--area <area>", "the area of the open effort").option("--task <slug>", "intra-area task slug if promoting a task sub-effort").requiredOption("--mode <mode>", "target mode: backend | fullstack").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
3091
3494
|
async (o) => {
|
|
3092
3495
|
try {
|
|
3093
3496
|
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
3094
3497
|
const r = await promoteEffort(ctx, {
|
|
3095
3498
|
area: o.area,
|
|
3096
3499
|
task: o.task,
|
|
3097
|
-
mode: o.mode
|
|
3098
|
-
story: o.story
|
|
3500
|
+
mode: o.mode
|
|
3099
3501
|
});
|
|
3100
3502
|
if (o.json) printJson(r);
|
|
3101
3503
|
else {
|
|
3102
3504
|
const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
|
|
3103
3505
|
console.log(
|
|
3104
|
-
`${
|
|
3506
|
+
`${chalk7.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
|
|
3105
3507
|
);
|
|
3106
3508
|
for (const w of r.warnings) warn(w);
|
|
3107
3509
|
}
|
|
@@ -3113,7 +3515,7 @@ function registerPromote(program2) {
|
|
|
3113
3515
|
}
|
|
3114
3516
|
|
|
3115
3517
|
// src/commands/reap.ts
|
|
3116
|
-
import
|
|
3518
|
+
import chalk8 from "chalk";
|
|
3117
3519
|
function registerReap(program2) {
|
|
3118
3520
|
program2.command("reap").description(
|
|
3119
3521
|
"Expire idle leases: stop only what they started, release them, park their efforts"
|
|
@@ -3122,7 +3524,7 @@ function registerReap(program2) {
|
|
|
3122
3524
|
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
3123
3525
|
const r = await reapLeases(ctx, { dryRun: o.dryRun });
|
|
3124
3526
|
if (o.json) printJson(r);
|
|
3125
|
-
else if (r.reaped.length === 0) console.log(
|
|
3527
|
+
else if (r.reaped.length === 0) console.log(chalk8.gray("nothing to reap"));
|
|
3126
3528
|
else
|
|
3127
3529
|
for (const l of r.reaped)
|
|
3128
3530
|
console.log(
|
|
@@ -3135,7 +3537,7 @@ function registerReap(program2) {
|
|
|
3135
3537
|
}
|
|
3136
3538
|
|
|
3137
3539
|
// src/commands/retire.ts
|
|
3138
|
-
import
|
|
3540
|
+
import chalk9 from "chalk";
|
|
3139
3541
|
function registerRetire(program2) {
|
|
3140
3542
|
program2.command("retire").description(
|
|
3141
3543
|
"Archive an area's evidence, remove its worktree and its local branches that are on origin (the only destructive verb)"
|
|
@@ -3145,11 +3547,11 @@ function registerRetire(program2) {
|
|
|
3145
3547
|
const r = await retireArea(ctx, { area: o.area });
|
|
3146
3548
|
if (o.json) printJson(r);
|
|
3147
3549
|
else {
|
|
3148
|
-
console.log(`${
|
|
3550
|
+
console.log(`${chalk9.bold(o.area)} retired: removed ${r.removed}`);
|
|
3149
3551
|
for (const a of r.archived) console.log(` archived ${a}`);
|
|
3150
3552
|
for (const b of r.branchesDeleted) console.log(` deleted local branch ${b}`);
|
|
3151
3553
|
for (const b of r.branchesKept)
|
|
3152
|
-
console.log(
|
|
3554
|
+
console.log(chalk9.yellow(` kept ${b}: not fully on origin`));
|
|
3153
3555
|
}
|
|
3154
3556
|
} catch (err) {
|
|
3155
3557
|
fail(err, Boolean(o.json));
|
|
@@ -3158,12 +3560,12 @@ function registerRetire(program2) {
|
|
|
3158
3560
|
}
|
|
3159
3561
|
|
|
3160
3562
|
// src/commands/start.ts
|
|
3161
|
-
import
|
|
3563
|
+
import chalk10 from "chalk";
|
|
3162
3564
|
function printStart(r) {
|
|
3163
3565
|
const e = r.effort;
|
|
3164
3566
|
const label = e.task ? `${e.area}--${e.task}` : e.area;
|
|
3165
3567
|
console.log(
|
|
3166
|
-
`${
|
|
3568
|
+
`${chalk10.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk10.yellow(" (joined)") : ""}`
|
|
3167
3569
|
);
|
|
3168
3570
|
console.log(` worktree ${e.worktree}`);
|
|
3169
3571
|
console.log(
|
|
@@ -3174,7 +3576,7 @@ function printStart(r) {
|
|
|
3174
3576
|
console.log(` lease ${l.kind} ${l.value}${ports} expires ${l.expires}`);
|
|
3175
3577
|
}
|
|
3176
3578
|
for (const w of r.warnings) warn(w);
|
|
3177
|
-
for (const n of r.notices) console.log(
|
|
3579
|
+
for (const n of r.notices) console.log(chalk10.yellow(` notice ${n.text} (${n.at})`));
|
|
3178
3580
|
}
|
|
3179
3581
|
function registerStart(program2) {
|
|
3180
3582
|
program2.command("start").description(
|
|
@@ -3206,36 +3608,47 @@ function registerStart(program2) {
|
|
|
3206
3608
|
}
|
|
3207
3609
|
|
|
3208
3610
|
// src/commands/status.ts
|
|
3209
|
-
import
|
|
3611
|
+
import chalk11 from "chalk";
|
|
3210
3612
|
var age = (iso) => {
|
|
3211
3613
|
const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 6e4));
|
|
3212
3614
|
return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
|
|
3213
3615
|
};
|
|
3214
3616
|
var expiresIn = (iso) => {
|
|
3215
3617
|
const mins = Math.round((Date.parse(iso) - Date.now()) / 6e4);
|
|
3216
|
-
return mins <= 0 ?
|
|
3618
|
+
return mins <= 0 ? chalk11.red("expired") : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
|
|
3217
3619
|
};
|
|
3218
3620
|
function printStatus(r) {
|
|
3219
|
-
console.log(
|
|
3220
|
-
const cols = [
|
|
3621
|
+
console.log(chalk11.bold(`${r.project}`));
|
|
3622
|
+
const cols = [
|
|
3623
|
+
"AREA",
|
|
3624
|
+
"TYPE/MODE",
|
|
3625
|
+
"BRANCH",
|
|
3626
|
+
"OWNER",
|
|
3627
|
+
"STARTED",
|
|
3628
|
+
"LEASES",
|
|
3629
|
+
"PR",
|
|
3630
|
+
"BEHIND",
|
|
3631
|
+
"NOTICES"
|
|
3632
|
+
];
|
|
3221
3633
|
const rows = r.areas.map((a) => [
|
|
3222
3634
|
a.area,
|
|
3223
|
-
a.effort ? `${a.effort.type}/${a.effort.mode}` :
|
|
3224
|
-
a.effort?.branch ??
|
|
3225
|
-
a.effort?.owner.session ??
|
|
3226
|
-
a.effort ? age(a.effort.started) :
|
|
3227
|
-
a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") :
|
|
3228
|
-
a.
|
|
3229
|
-
a.
|
|
3635
|
+
a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk11.gray("-"),
|
|
3636
|
+
a.effort?.branch ?? chalk11.gray("-"),
|
|
3637
|
+
a.effort?.owner.session ?? chalk11.gray("-"),
|
|
3638
|
+
a.effort ? age(a.effort.started) : chalk11.gray("-"),
|
|
3639
|
+
a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk11.gray("-"),
|
|
3640
|
+
a.effort?.pr ? `#${a.effort.pr.number}${a.effort.pr.draft ? " draft" : ""}` : chalk11.gray("-"),
|
|
3641
|
+
a.behindBase > 0 ? chalk11.yellow(String(a.behindBase)) : "0",
|
|
3642
|
+
a.notices.length ? chalk11.yellow(String(a.notices.length)) : "0"
|
|
3230
3643
|
]);
|
|
3231
3644
|
const plain = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
|
|
3232
3645
|
const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((row) => plain(row[i]).length)));
|
|
3233
3646
|
const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - plain(c).length)).join(" ");
|
|
3234
|
-
console.log(
|
|
3647
|
+
console.log(chalk11.bold(line(cols)));
|
|
3235
3648
|
for (const row of rows) console.log(line(row));
|
|
3236
3649
|
if (r.leases.length) {
|
|
3237
3650
|
console.log("");
|
|
3238
|
-
console.log(
|
|
3651
|
+
console.log(chalk11.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
|
|
3239
3652
|
for (const l of r.leases) {
|
|
3240
3653
|
console.log(
|
|
3241
3654
|
`${l.kind.padEnd(10)} ${String(l.value).padEnd(12)} ${l.owner.session.padEnd(21)} ${age(l.created).padEnd(5)} ${expiresIn(l.expires)}`
|
|
@@ -3260,13 +3673,14 @@ function registerStatus(program2) {
|
|
|
3260
3673
|
var { program } = createCli({
|
|
3261
3674
|
name: "work",
|
|
3262
3675
|
version: VERSION,
|
|
3263
|
-
description: "Area-based work: start, promote, status, finish, reap, retire, audit."
|
|
3676
|
+
description: "Area-based work: start, promote, status, pr, finish, reap, retire, audit."
|
|
3264
3677
|
});
|
|
3265
3678
|
registerInit(program);
|
|
3266
3679
|
registerStart(program);
|
|
3267
3680
|
registerPromote(program);
|
|
3268
3681
|
registerStatus(program);
|
|
3269
3682
|
registerEnv(program);
|
|
3683
|
+
registerPr(program);
|
|
3270
3684
|
registerFinish(program);
|
|
3271
3685
|
registerReap(program);
|
|
3272
3686
|
registerRetire(program);
|