@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.
@@ -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)) this.run(["worktree", "add", "-q", path, branch]);
178
- else
179
- this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
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)) this.run(["checkout", "-q", branch]);
186
- else this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
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(c.stagedPaths, globs);
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(c.stagedPaths, other.scopes);
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
- 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) {
642
+ if (mine && ownPaths.length > 0) {
579
643
  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));
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 = 3e4;
1057
- var LOCK_WAIT_MS = 5e3;
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
- rmSync2(lock, { recursive: true, force: true });
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,