@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.
@@ -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)) this.run(["worktree", "add", "-q", path, branch]);
178
- else
179
- this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
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)) this.run(["checkout", "-q", branch]);
186
- else this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
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(c.stagedPaths, globs);
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(c.stagedPaths, other.scopes);
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
- const mine = openEfforts.find(
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(c.stagedPaths, allowed));
581
- const outside = c.stagedPaths.filter((p) => !inScope.has(p));
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 = 3e4;
1057
- var LOCK_WAIT_MS = 5e3;
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
- rmSync2(lock, { recursive: true, force: true });
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,