@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/core/index.js
CHANGED
|
@@ -31,7 +31,8 @@ var REFUSALS = /* @__PURE__ */ new Set([
|
|
|
31
31
|
"PORT_RANGE_TAKEN",
|
|
32
32
|
"SYNC_CONFLICT",
|
|
33
33
|
"DIRTY_WORKTREE",
|
|
34
|
-
"UNPUSHED"
|
|
34
|
+
"UNPUSHED",
|
|
35
|
+
"PR_CLOSED"
|
|
35
36
|
]);
|
|
36
37
|
function exitCodeFor(code) {
|
|
37
38
|
return REFUSALS.has(code) ? 2 : 1;
|
|
@@ -134,12 +135,27 @@ var Git = class _Git {
|
|
|
134
135
|
else if (this.remoteBranchExists(this.currentBranch())) ref = `origin/${this.currentBranch()}`;
|
|
135
136
|
return this.lines(["rev-list", `${ref}..HEAD`]).length;
|
|
136
137
|
}
|
|
138
|
+
/** Commits on origin/<base> that HEAD does not have (0 when the ref is unknown). */
|
|
139
|
+
behindCount(base) {
|
|
140
|
+
const r = this.tryRun(["rev-list", "--count", `HEAD..origin/${base}`]);
|
|
141
|
+
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
142
|
+
}
|
|
137
143
|
isDirty() {
|
|
138
144
|
return this.run(["status", "--porcelain"]).trim().length > 0;
|
|
139
145
|
}
|
|
140
146
|
changedPathsStaged() {
|
|
141
147
|
return this.lines(["diff", "--cached", "--name-only"]);
|
|
142
148
|
}
|
|
149
|
+
/** The other parent of a merge in progress, or undefined when none is. */
|
|
150
|
+
mergeHead() {
|
|
151
|
+
const r = this.tryRun(["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
|
152
|
+
return r.code === 0 ? r.out.trim() : void 0;
|
|
153
|
+
}
|
|
154
|
+
/** Staged paths whose index content differs from `ref` (so, authored here rather than taken from `ref`). */
|
|
155
|
+
stagedDifferingFrom(ref, paths) {
|
|
156
|
+
if (paths.length === 0) return [];
|
|
157
|
+
return this.lines(["diff", "--cached", "--name-only", "--no-renames", ref, "--", ...paths]);
|
|
158
|
+
}
|
|
143
159
|
changedPaths(from, to) {
|
|
144
160
|
return this.lines(["diff", "--name-only", "--no-renames", from, to]);
|
|
145
161
|
}
|
|
@@ -172,18 +188,45 @@ var Git = class _Git {
|
|
|
172
188
|
const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
|
|
173
189
|
return { kind: "conflict", before, after: before, conflicted };
|
|
174
190
|
}
|
|
191
|
+
/** `origin/<branch>` after a fetch attempt, when the remote has it; the local copy may have been retired. */
|
|
192
|
+
remoteRef(branch) {
|
|
193
|
+
this.tryRun(["fetch", "-q", "origin", branch]);
|
|
194
|
+
const ref = `refs/remotes/origin/${branch}`;
|
|
195
|
+
return this.tryRun(["rev-parse", "--verify", "-q", ref]).code === 0 ? `origin/${branch}` : void 0;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Add a worktree on `branch`: the local branch if it exists, else a tracking
|
|
199
|
+
* branch from origin/<branch> if the remote has it (so retiring the local
|
|
200
|
+
* branch while its PR was open never recreates it from the base), else a
|
|
201
|
+
* new branch from `startPoint`.
|
|
202
|
+
*/
|
|
175
203
|
worktreeAdd(path, branch, startPoint) {
|
|
176
204
|
mkdirSync(dirname(path), { recursive: true });
|
|
177
|
-
if (this.branchExists(branch))
|
|
178
|
-
|
|
179
|
-
|
|
205
|
+
if (this.branchExists(branch)) {
|
|
206
|
+
this.run(["worktree", "add", "-q", path, branch]);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const remote = this.remoteRef(branch);
|
|
210
|
+
if (remote) {
|
|
211
|
+
this.run(["worktree", "add", "-q", "--track", "-b", branch, path, remote]);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
|
|
180
215
|
}
|
|
181
216
|
worktreeRemove(path) {
|
|
182
217
|
this.run(["worktree", "remove", "--force", path]);
|
|
183
218
|
}
|
|
184
219
|
checkout(branch, createFrom) {
|
|
185
|
-
if (this.branchExists(branch))
|
|
186
|
-
|
|
220
|
+
if (this.branchExists(branch)) {
|
|
221
|
+
this.run(["checkout", "-q", branch]);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const remote = this.remoteRef(branch);
|
|
225
|
+
if (remote) {
|
|
226
|
+
this.run(["checkout", "-q", "--track", "-b", branch, remote]);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
|
|
187
230
|
}
|
|
188
231
|
push(branch) {
|
|
189
232
|
this.run(["push", "-q", "-u", "origin", branch]);
|
|
@@ -297,13 +340,14 @@ function slotVars(m, o) {
|
|
|
297
340
|
BRANCH: o.branch ?? "",
|
|
298
341
|
WORKTREE: o.worktree ?? "",
|
|
299
342
|
LOG_DIR: o.logDir ?? "",
|
|
300
|
-
PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort),
|
|
301
343
|
STORYBROKR_HOST: storybookHostFor(m, o.worktree)
|
|
302
344
|
};
|
|
303
345
|
for (const service of Object.keys(m.ports.services)) {
|
|
304
346
|
const p = o.ports[service];
|
|
305
347
|
vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
|
|
306
348
|
}
|
|
349
|
+
if (o.storybookPort !== void 0) vars.PORT_STORYBOOK = String(o.storybookPort);
|
|
350
|
+
else if (vars.PORT_STORYBOOK === void 0) vars.PORT_STORYBOOK = "";
|
|
307
351
|
return vars;
|
|
308
352
|
}
|
|
309
353
|
function ensureExcluded(worktree, patterns) {
|
|
@@ -541,8 +585,15 @@ function checkCommit(c) {
|
|
|
541
585
|
`branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
|
|
542
586
|
);
|
|
543
587
|
}
|
|
588
|
+
const mergeHead = c.git.mergeHead();
|
|
589
|
+
const authored = mergeHead ? new Set(c.git.stagedDifferingFrom(mergeHead, c.stagedPaths)) : new Set(c.stagedPaths);
|
|
590
|
+
const ownPaths = c.stagedPaths.filter((p) => authored.has(p));
|
|
544
591
|
const allEfforts = c.registry.efforts({ project: c.project });
|
|
545
592
|
const efforts = new Map(allEfforts.map((e) => [e.id, e]));
|
|
593
|
+
const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
|
|
594
|
+
const mine = openEfforts.find(
|
|
595
|
+
(e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
|
|
596
|
+
);
|
|
546
597
|
for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
|
|
547
598
|
const holder = efforts.get(lease.effort);
|
|
548
599
|
const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
|
|
@@ -551,34 +602,31 @@ function checkCommit(c) {
|
|
|
551
602
|
} else if (lease.owner.session === c.ownerSession) {
|
|
552
603
|
continue;
|
|
553
604
|
}
|
|
605
|
+
if (holder && mine && holder.area === mine.area) continue;
|
|
554
606
|
const globs = c.m.commons[String(lease.value)] ?? [];
|
|
555
|
-
const hit = pathsMatching(
|
|
607
|
+
const hit = pathsMatching(ownPaths, globs);
|
|
556
608
|
if (hit.length > 0) {
|
|
557
609
|
refusals.push(
|
|
558
610
|
`commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
|
|
559
611
|
);
|
|
560
612
|
}
|
|
561
613
|
}
|
|
562
|
-
const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
|
|
563
614
|
for (const other of openEfforts) {
|
|
564
615
|
if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
|
|
565
616
|
continue;
|
|
566
617
|
}
|
|
567
618
|
if (!c.checkoutRoot && other.branch === c.branch) continue;
|
|
568
|
-
const hit = pathsMatching(
|
|
619
|
+
const hit = pathsMatching(ownPaths, other.scopes);
|
|
569
620
|
if (hit.length > 0) {
|
|
570
621
|
refusals.push(
|
|
571
622
|
`path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
|
|
572
623
|
);
|
|
573
624
|
}
|
|
574
625
|
}
|
|
575
|
-
|
|
576
|
-
(e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
|
|
577
|
-
);
|
|
578
|
-
if (mine && c.stagedPaths.length > 0) {
|
|
626
|
+
if (mine && ownPaths.length > 0) {
|
|
579
627
|
const allowed = allowedScopesForEffort(c.m, mine);
|
|
580
|
-
const inScope = new Set(pathsMatching(
|
|
581
|
-
const outside =
|
|
628
|
+
const inScope = new Set(pathsMatching(ownPaths, allowed));
|
|
629
|
+
const outside = ownPaths.filter((p) => !inScope.has(p));
|
|
582
630
|
if (outside.length > 0) {
|
|
583
631
|
warnings.push(
|
|
584
632
|
`${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
|
|
@@ -905,8 +953,19 @@ function rangesFor(m) {
|
|
|
905
953
|
function rangesOverlap(a, b) {
|
|
906
954
|
return a.base < b.base + b.count && b.base < a.base + a.count;
|
|
907
955
|
}
|
|
956
|
+
function ensureRanges(registry, m, repoRoot) {
|
|
957
|
+
const mine = rangesFor(m);
|
|
958
|
+
assertNoOverlap(registry, m, mine);
|
|
959
|
+
const current = registry.project(m.project);
|
|
960
|
+
if (current && JSON.stringify(current.ranges) === JSON.stringify(mine)) return;
|
|
961
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
962
|
+
}
|
|
908
963
|
function claimRanges(registry, m, repoRoot) {
|
|
909
964
|
const mine = rangesFor(m);
|
|
965
|
+
assertNoOverlap(registry, m, mine);
|
|
966
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
967
|
+
}
|
|
968
|
+
function assertNoOverlap(registry, m, mine) {
|
|
910
969
|
const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
|
|
911
970
|
for (const [service, range] of Object.entries(mine)) {
|
|
912
971
|
for (const [name, entry] of others) {
|
|
@@ -921,7 +980,6 @@ function claimRanges(registry, m, repoRoot) {
|
|
|
921
980
|
}
|
|
922
981
|
}
|
|
923
982
|
}
|
|
924
|
-
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
925
983
|
}
|
|
926
984
|
|
|
927
985
|
// src/core/refresh.ts
|
|
@@ -1053,8 +1111,8 @@ import {
|
|
|
1053
1111
|
} from "fs";
|
|
1054
1112
|
var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
|
|
1055
1113
|
var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
|
|
1056
|
-
var LOCK_STALE_MS =
|
|
1057
|
-
var LOCK_WAIT_MS =
|
|
1114
|
+
var LOCK_STALE_MS = 1e4;
|
|
1115
|
+
var LOCK_WAIT_MS = 15e3;
|
|
1058
1116
|
function sleep(ms) {
|
|
1059
1117
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1060
1118
|
}
|
|
@@ -1110,7 +1168,9 @@ var Registry = class _Registry {
|
|
|
1110
1168
|
} catch {
|
|
1111
1169
|
try {
|
|
1112
1170
|
if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
|
|
1113
|
-
|
|
1171
|
+
const claimed = `${lock}.stale.${process.pid}.${Date.now()}`;
|
|
1172
|
+
renameSync(lock, claimed);
|
|
1173
|
+
rmSync2(claimed, { recursive: true, force: true });
|
|
1114
1174
|
continue;
|
|
1115
1175
|
}
|
|
1116
1176
|
} catch {
|
|
@@ -1220,7 +1280,7 @@ var Registry = class _Registry {
|
|
|
1220
1280
|
if (slot < 0) {
|
|
1221
1281
|
throw new WorkkitError(
|
|
1222
1282
|
"NO_FREE_SLOT",
|
|
1223
|
-
`no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")})`
|
|
1283
|
+
`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`
|
|
1224
1284
|
);
|
|
1225
1285
|
}
|
|
1226
1286
|
const lease = this.lease(
|
|
@@ -1481,6 +1541,7 @@ export {
|
|
|
1481
1541
|
currentOwner,
|
|
1482
1542
|
emitWorktreeConfig,
|
|
1483
1543
|
ensureExcluded,
|
|
1544
|
+
ensureRanges,
|
|
1484
1545
|
exitCodeFor,
|
|
1485
1546
|
expandScopes,
|
|
1486
1547
|
globsOverlap,
|