@helmsmith/workbrokr 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +12 -0
- package/dist/cli.js +529 -94
- package/dist/cli.js.map +1 -1
- package/dist/core/index.js +98 -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,43 @@ 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
|
+
/** Non-merge commits on HEAD that origin/<base> does not have: the branch's own work (sync merges do not count). */
|
|
139
|
+
ownCommitCount(base) {
|
|
140
|
+
const r = this.tryRun(["rev-list", "--count", "--no-merges", `origin/${base}..HEAD`]);
|
|
141
|
+
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
142
|
+
}
|
|
143
|
+
/** `branch` is already in origin/<base>: an ancestor of it, patch-equivalent (squash-merged), or without own commits. */
|
|
144
|
+
branchMergedInto(branch, base) {
|
|
145
|
+
const target = `origin/${base}`;
|
|
146
|
+
if (this.isAncestor(branch, target)) return true;
|
|
147
|
+
const own = this.tryRun(["rev-list", "--count", "--no-merges", `${target}..${branch}`]);
|
|
148
|
+
if (own.code === 0 && Number(own.out.trim()) === 0) return true;
|
|
149
|
+
const cherry = this.tryRun(["cherry", target, branch]);
|
|
150
|
+
if (cherry.code !== 0) return false;
|
|
151
|
+
const lines = cherry.out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
152
|
+
return lines.length > 0 && lines.every((l) => l.startsWith("- "));
|
|
153
|
+
}
|
|
154
|
+
/** Commits on origin/<base> that HEAD does not have (0 when the ref is unknown). */
|
|
155
|
+
behindCount(base) {
|
|
156
|
+
const r = this.tryRun(["rev-list", "--count", `HEAD..origin/${base}`]);
|
|
157
|
+
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
158
|
+
}
|
|
137
159
|
isDirty() {
|
|
138
160
|
return this.run(["status", "--porcelain"]).trim().length > 0;
|
|
139
161
|
}
|
|
140
162
|
changedPathsStaged() {
|
|
141
163
|
return this.lines(["diff", "--cached", "--name-only"]);
|
|
142
164
|
}
|
|
165
|
+
/** The other parent of a merge in progress, or undefined when none is. */
|
|
166
|
+
mergeHead() {
|
|
167
|
+
const r = this.tryRun(["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
|
168
|
+
return r.code === 0 ? r.out.trim() : void 0;
|
|
169
|
+
}
|
|
170
|
+
/** Staged paths whose index content differs from `ref` (so, authored here rather than taken from `ref`). */
|
|
171
|
+
stagedDifferingFrom(ref, paths) {
|
|
172
|
+
if (paths.length === 0) return [];
|
|
173
|
+
return this.lines(["diff", "--cached", "--name-only", "--no-renames", ref, "--", ...paths]);
|
|
174
|
+
}
|
|
143
175
|
changedPaths(from, to) {
|
|
144
176
|
return this.lines(["diff", "--name-only", "--no-renames", from, to]);
|
|
145
177
|
}
|
|
@@ -172,18 +204,45 @@ var Git = class _Git {
|
|
|
172
204
|
const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
|
|
173
205
|
return { kind: "conflict", before, after: before, conflicted };
|
|
174
206
|
}
|
|
207
|
+
/** `origin/<branch>` after a fetch attempt, when the remote has it; the local copy may have been retired. */
|
|
208
|
+
remoteRef(branch) {
|
|
209
|
+
this.tryRun(["fetch", "-q", "origin", branch]);
|
|
210
|
+
const ref = `refs/remotes/origin/${branch}`;
|
|
211
|
+
return this.tryRun(["rev-parse", "--verify", "-q", ref]).code === 0 ? `origin/${branch}` : void 0;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Add a worktree on `branch`: the local branch if it exists, else a tracking
|
|
215
|
+
* branch from origin/<branch> if the remote has it (so retiring the local
|
|
216
|
+
* branch while its PR was open never recreates it from the base), else a
|
|
217
|
+
* new branch from `startPoint`.
|
|
218
|
+
*/
|
|
175
219
|
worktreeAdd(path, branch, startPoint) {
|
|
176
220
|
mkdirSync(dirname(path), { recursive: true });
|
|
177
|
-
if (this.branchExists(branch))
|
|
178
|
-
|
|
179
|
-
|
|
221
|
+
if (this.branchExists(branch)) {
|
|
222
|
+
this.run(["worktree", "add", "-q", path, branch]);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const remote = this.remoteRef(branch);
|
|
226
|
+
if (remote) {
|
|
227
|
+
this.run(["worktree", "add", "-q", "--track", "-b", branch, path, remote]);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
|
|
180
231
|
}
|
|
181
232
|
worktreeRemove(path) {
|
|
182
233
|
this.run(["worktree", "remove", "--force", path]);
|
|
183
234
|
}
|
|
184
235
|
checkout(branch, createFrom) {
|
|
185
|
-
if (this.branchExists(branch))
|
|
186
|
-
|
|
236
|
+
if (this.branchExists(branch)) {
|
|
237
|
+
this.run(["checkout", "-q", branch]);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const remote = this.remoteRef(branch);
|
|
241
|
+
if (remote) {
|
|
242
|
+
this.run(["checkout", "-q", "--track", "-b", branch, remote]);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
|
|
187
246
|
}
|
|
188
247
|
push(branch) {
|
|
189
248
|
this.run(["push", "-q", "-u", "origin", branch]);
|
|
@@ -297,13 +356,14 @@ function slotVars(m, o) {
|
|
|
297
356
|
BRANCH: o.branch ?? "",
|
|
298
357
|
WORKTREE: o.worktree ?? "",
|
|
299
358
|
LOG_DIR: o.logDir ?? "",
|
|
300
|
-
PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort),
|
|
301
359
|
STORYBROKR_HOST: storybookHostFor(m, o.worktree)
|
|
302
360
|
};
|
|
303
361
|
for (const service of Object.keys(m.ports.services)) {
|
|
304
362
|
const p = o.ports[service];
|
|
305
363
|
vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
|
|
306
364
|
}
|
|
365
|
+
if (o.storybookPort !== void 0) vars.PORT_STORYBOOK = String(o.storybookPort);
|
|
366
|
+
else if (vars.PORT_STORYBOOK === void 0) vars.PORT_STORYBOOK = "";
|
|
307
367
|
return vars;
|
|
308
368
|
}
|
|
309
369
|
function ensureExcluded(worktree, patterns) {
|
|
@@ -541,8 +601,15 @@ function checkCommit(c) {
|
|
|
541
601
|
`branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
|
|
542
602
|
);
|
|
543
603
|
}
|
|
604
|
+
const mergeHead = c.git.mergeHead();
|
|
605
|
+
const authored = mergeHead ? new Set(c.git.stagedDifferingFrom(mergeHead, c.stagedPaths)) : new Set(c.stagedPaths);
|
|
606
|
+
const ownPaths = c.stagedPaths.filter((p) => authored.has(p));
|
|
544
607
|
const allEfforts = c.registry.efforts({ project: c.project });
|
|
545
608
|
const efforts = new Map(allEfforts.map((e) => [e.id, e]));
|
|
609
|
+
const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
|
|
610
|
+
const mine = openEfforts.find(
|
|
611
|
+
(e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
|
|
612
|
+
);
|
|
546
613
|
for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
|
|
547
614
|
const holder = efforts.get(lease.effort);
|
|
548
615
|
const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
|
|
@@ -551,34 +618,31 @@ function checkCommit(c) {
|
|
|
551
618
|
} else if (lease.owner.session === c.ownerSession) {
|
|
552
619
|
continue;
|
|
553
620
|
}
|
|
621
|
+
if (holder && mine && holder.area === mine.area) continue;
|
|
554
622
|
const globs = c.m.commons[String(lease.value)] ?? [];
|
|
555
|
-
const hit = pathsMatching(
|
|
623
|
+
const hit = pathsMatching(ownPaths, globs);
|
|
556
624
|
if (hit.length > 0) {
|
|
557
625
|
refusals.push(
|
|
558
626
|
`commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
|
|
559
627
|
);
|
|
560
628
|
}
|
|
561
629
|
}
|
|
562
|
-
const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
|
|
563
630
|
for (const other of openEfforts) {
|
|
564
631
|
if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
|
|
565
632
|
continue;
|
|
566
633
|
}
|
|
567
634
|
if (!c.checkoutRoot && other.branch === c.branch) continue;
|
|
568
|
-
const hit = pathsMatching(
|
|
635
|
+
const hit = pathsMatching(ownPaths, other.scopes);
|
|
569
636
|
if (hit.length > 0) {
|
|
570
637
|
refusals.push(
|
|
571
638
|
`path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
|
|
572
639
|
);
|
|
573
640
|
}
|
|
574
641
|
}
|
|
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) {
|
|
642
|
+
if (mine && ownPaths.length > 0) {
|
|
579
643
|
const allowed = allowedScopesForEffort(c.m, mine);
|
|
580
|
-
const inScope = new Set(pathsMatching(
|
|
581
|
-
const outside =
|
|
644
|
+
const inScope = new Set(pathsMatching(ownPaths, allowed));
|
|
645
|
+
const outside = ownPaths.filter((p) => !inScope.has(p));
|
|
582
646
|
if (outside.length > 0) {
|
|
583
647
|
warnings.push(
|
|
584
648
|
`${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
|
|
@@ -905,8 +969,19 @@ function rangesFor(m) {
|
|
|
905
969
|
function rangesOverlap(a, b) {
|
|
906
970
|
return a.base < b.base + b.count && b.base < a.base + a.count;
|
|
907
971
|
}
|
|
972
|
+
function ensureRanges(registry, m, repoRoot) {
|
|
973
|
+
const mine = rangesFor(m);
|
|
974
|
+
assertNoOverlap(registry, m, mine);
|
|
975
|
+
const current = registry.project(m.project);
|
|
976
|
+
if (current && JSON.stringify(current.ranges) === JSON.stringify(mine)) return;
|
|
977
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
978
|
+
}
|
|
908
979
|
function claimRanges(registry, m, repoRoot) {
|
|
909
980
|
const mine = rangesFor(m);
|
|
981
|
+
assertNoOverlap(registry, m, mine);
|
|
982
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
983
|
+
}
|
|
984
|
+
function assertNoOverlap(registry, m, mine) {
|
|
910
985
|
const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
|
|
911
986
|
for (const [service, range] of Object.entries(mine)) {
|
|
912
987
|
for (const [name, entry] of others) {
|
|
@@ -921,7 +996,6 @@ function claimRanges(registry, m, repoRoot) {
|
|
|
921
996
|
}
|
|
922
997
|
}
|
|
923
998
|
}
|
|
924
|
-
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
925
999
|
}
|
|
926
1000
|
|
|
927
1001
|
// src/core/refresh.ts
|
|
@@ -1053,8 +1127,8 @@ import {
|
|
|
1053
1127
|
} from "fs";
|
|
1054
1128
|
var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
|
|
1055
1129
|
var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
|
|
1056
|
-
var LOCK_STALE_MS =
|
|
1057
|
-
var LOCK_WAIT_MS =
|
|
1130
|
+
var LOCK_STALE_MS = 1e4;
|
|
1131
|
+
var LOCK_WAIT_MS = 15e3;
|
|
1058
1132
|
function sleep(ms) {
|
|
1059
1133
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1060
1134
|
}
|
|
@@ -1110,7 +1184,9 @@ var Registry = class _Registry {
|
|
|
1110
1184
|
} catch {
|
|
1111
1185
|
try {
|
|
1112
1186
|
if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
|
|
1113
|
-
|
|
1187
|
+
const claimed = `${lock}.stale.${process.pid}.${Date.now()}`;
|
|
1188
|
+
renameSync(lock, claimed);
|
|
1189
|
+
rmSync2(claimed, { recursive: true, force: true });
|
|
1114
1190
|
continue;
|
|
1115
1191
|
}
|
|
1116
1192
|
} catch {
|
|
@@ -1220,7 +1296,7 @@ var Registry = class _Registry {
|
|
|
1220
1296
|
if (slot < 0) {
|
|
1221
1297
|
throw new WorkkitError(
|
|
1222
1298
|
"NO_FREE_SLOT",
|
|
1223
|
-
`no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")})`
|
|
1299
|
+
`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
1300
|
);
|
|
1225
1301
|
}
|
|
1226
1302
|
const lease = this.lease(
|
|
@@ -1481,6 +1557,7 @@ export {
|
|
|
1481
1557
|
currentOwner,
|
|
1482
1558
|
emitWorktreeConfig,
|
|
1483
1559
|
ensureExcluded,
|
|
1560
|
+
ensureRanges,
|
|
1484
1561
|
exitCodeFor,
|
|
1485
1562
|
expandScopes,
|
|
1486
1563
|
globsOverlap,
|