@brainervirus/workit-core 7.4.0 → 7.5.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/src/fanout.ts CHANGED
@@ -104,14 +104,41 @@ const unescape = (pattern: string): string => pattern.replace(/\\(.)/gu, "$1");
104
104
  /** Has an unescaped `*`, `?` or `{`. */
105
105
  const hasWildcard = (pattern: string): boolean => /[*?{]/u.test(pattern.replace(/\\./gu, ""));
106
106
 
107
+ /** Brace alternatives expanded (`src/{a,b}/x` -> `src/a/x`, `src/b/x`), capped. */
108
+ function expandBraces(pattern: string, limit = 64): string[] {
109
+ for (let index = 0; index < pattern.length; index += 1) {
110
+ if (pattern[index] === "\\") {
111
+ index += 1;
112
+ continue;
113
+ }
114
+ const close = pattern.indexOf("}", index);
115
+ if (pattern[index] !== "{" || close < index) continue;
116
+ const head = pattern.slice(0, index);
117
+ const tail = pattern.slice(close + 1);
118
+ const out: string[] = [];
119
+ for (const alternative of pattern.slice(index + 1, close).split(","))
120
+ for (const rest of expandBraces(tail, limit)) {
121
+ if (out.length >= limit) return out;
122
+ out.push(`${head}${alternative}${rest}`);
123
+ }
124
+ return out;
125
+ }
126
+ return [pattern];
127
+ }
128
+
107
129
  /**
108
- * One concrete path the pattern matches, standing in for files nobody has
109
- * created yet: `src/new/**` -> `src/new/<any>`, `src/*.ts` -> `src/<any>.ts`.
130
+ * Concrete paths the pattern matches, one per brace alternative, standing in
131
+ * for files nobody has created yet: `src/new/**` -> `src/new/<any>`,
132
+ * `src/{a,b}/*.ts` -> `src/a/<any>.ts`, `src/b/<any>.ts`.
110
133
  */
111
134
  export function scopeSamples(pattern: string): string[] {
112
135
  // A plain path covers what is below it too, so `src/new` and `src/new/**`
113
136
  // overlap through `src/new/<any>` without a second sample.
114
137
  if (!hasWildcard(pattern)) return [unescape(pattern)];
138
+ return [...new Set(expandBraces(pattern).map(sampleOne))];
139
+ }
140
+
141
+ function sampleOne(pattern: string): string {
115
142
  let out = "";
116
143
  for (let index = 0; index < pattern.length; index += 1) {
117
144
  const char = pattern[index];
@@ -127,13 +154,9 @@ export function scopeSamples(pattern: string): string[] {
127
154
  }
128
155
  } else if (char === "*") out += "<any>";
129
156
  else if (char === "?") out += "x";
130
- else if (char === "{" && pattern.indexOf("}", index) > index) {
131
- const close = pattern.indexOf("}", index);
132
- out += pattern.slice(index + 1, close).split(",")[0];
133
- index = close;
134
- } else out += char;
157
+ else out += char;
135
158
  }
136
- return [out];
159
+ return out;
137
160
  }
138
161
 
139
162
  function globSource(pattern: string): string {
@@ -190,9 +213,23 @@ const matchesAny = (patterns: readonly string[], file: string): boolean =>
190
213
 
191
214
  /** A scope entry in canonical form, or why it is refused. */
192
215
  function normalizePattern(raw: string): { pattern: string } | { error: string } {
193
- // A backslash escapes a glob character (`app/\\[id\\]`); any other one is a
194
- // Windows separator.
195
- let pattern = raw.trim().replace(/\\(?![[\]{}*?])/gu, "/");
216
+ // Only `\\[` and `\\]` are escapes (`app/\\[id\\]`); any other backslash is
217
+ // a Windows separator. Where both readings are possible, refuse.
218
+ let pattern = raw.trim();
219
+ if (pattern.includes("\\")) {
220
+ if (/\\[*?{}]/u.test(pattern))
221
+ return {
222
+ error:
223
+ "has \\*, \\?, \\{ or \\}: only \\[ and \\] are escapes; use / as the separator (src/**/*.ts)",
224
+ };
225
+ const brackets = /\\[[\]]/u.test(pattern);
226
+ if (brackets && pattern.replace(/\\[[\]]/gu, "").includes("\\"))
227
+ return {
228
+ error:
229
+ "mixes \\[ or \\] escapes with other backslashes, which is ambiguous: use / as the separator (app/\\[id\\]/page.tsx)",
230
+ };
231
+ if (!brackets) pattern = pattern.replaceAll("\\", "/");
232
+ }
196
233
  while (pattern.startsWith("./")) pattern = pattern.slice(2);
197
234
  if (pattern === "." || pattern === "") pattern = raw.trim() ? "**" : "";
198
235
  if (pattern.endsWith("/")) pattern = `${pattern}**`;
@@ -244,6 +281,8 @@ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
244
281
 
245
282
  export type PlanDefaults = {
246
283
  trunk: string;
284
+ /** The main checkout: a relative `worktree` is resolved against it at plan time. */
285
+ root: string;
247
286
  /** Where default worktrees go: `<repo>-wt/` next to the main checkout. */
248
287
  worktreeRoot: string;
249
288
  };
@@ -335,7 +374,10 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
335
374
  id,
336
375
  branch,
337
376
  base: optional("base") ?? "",
338
- worktree: optional("worktree") ?? path.join(defaults.worktreeRoot, id),
377
+ worktree: path.resolve(
378
+ defaults.root,
379
+ optional("worktree") ?? path.join(defaults.worktreeRoot, id),
380
+ ),
339
381
  tier: tier as Tier,
340
382
  dependsOn: [...new Set(dependsOn ?? [])],
341
383
  scope,
@@ -582,16 +624,59 @@ export function analyzeOverlap(
582
624
  return { overlaps, conflicts };
583
625
  }
584
626
 
627
+ // ---------------------------------------------------------------------------
628
+ // case-only collisions with the trunk
629
+
630
+ export type CaseCollision = { slice: string; path: string; trunk: string };
631
+
632
+ /** Trunk paths (files and the directories above them) by lower case. */
633
+ export function caseIndex(files: Iterable<string>): Map<string, Set<string>> {
634
+ const index = new Map<string, Set<string>>();
635
+ for (const file of files) {
636
+ const parts = file.split("/");
637
+ for (let end = 1; end <= parts.length; end += 1) {
638
+ const prefix = parts.slice(0, end).join("/");
639
+ const key = prefix.toLowerCase();
640
+ (index.get(key) ?? index.set(key, new Set()).get(key))?.add(prefix);
641
+ }
642
+ }
643
+ return index;
644
+ }
645
+
646
+ /**
647
+ * The shallowest part of `file` (a directory or the file itself) that the
648
+ * trunk spells differently only in case: one path on macOS and Windows
649
+ * checkouts, two in git. Null when there is none.
650
+ */
651
+ export function caseClash(
652
+ index: ReadonlyMap<string, ReadonlySet<string>>,
653
+ file: string,
654
+ ): { path: string; trunk: string } | null {
655
+ const parts = file.split("/");
656
+ for (let end = 1; end <= parts.length; end += 1) {
657
+ const prefix = parts.slice(0, end).join("/");
658
+ if (prefix.includes("<any>")) return null;
659
+ const spellings = index.get(prefix.toLowerCase());
660
+ if (spellings && !spellings.has(prefix))
661
+ return { path: prefix, trunk: [...spellings].toSorted()[0] };
662
+ }
663
+ return null;
664
+ }
665
+
585
666
  // ---------------------------------------------------------------------------
586
667
  // git
587
668
 
588
669
  type GitRun = { ok: boolean; status: number | null; stdout: string; stderr: string };
589
670
 
590
- export const gitRun = (cwd: string, args: string[]): GitRun => {
671
+ export const gitRun = (
672
+ cwd: string,
673
+ args: string[],
674
+ timeoutMs: number = GIT_TIMEOUTS.local,
675
+ ): GitRun => {
591
676
  const run = spawnSync("git", args, {
592
677
  cwd,
593
678
  encoding: "utf8",
594
- timeout: GIT_TIMEOUTS.local,
679
+ timeout: timeoutMs,
595
680
  killSignal: "SIGKILL",
596
681
  windowsHide: true,
597
682
  maxBuffer: 64 * 1024 * 1024,
@@ -631,10 +716,15 @@ export function defaultTrunk(cwd: string): string {
631
716
  return base ? base.replace(/^origin\//u, "") : "main";
632
717
  }
633
718
 
719
+ /** The main checkout (beside `.git`), else `cwd`. */
720
+ export function mainCheckout(cwd: string): string {
721
+ const common = gitCommonDir(cwd);
722
+ return common && path.basename(common) === ".git" ? path.dirname(common) : path.resolve(cwd);
723
+ }
724
+
634
725
  /** `<repo>-wt/` beside the main checkout. */
635
726
  export function worktreeRoot(cwd: string): string {
636
- const common = gitCommonDir(cwd);
637
- const main = common && path.basename(common) === ".git" ? path.dirname(common) : cwd;
727
+ const main = mainCheckout(cwd);
638
728
  return path.join(path.dirname(main), `${path.basename(main)}-wt`);
639
729
  }
640
730
 
@@ -823,7 +913,11 @@ export function planFanout(
823
913
  if (!dir.ok) return dir;
824
914
  const parsed = parsePlan(
825
915
  input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
826
- { trunk: input.trunk ?? defaultTrunk(cwd), worktreeRoot: worktreeRoot(cwd) },
916
+ {
917
+ trunk: input.trunk ?? defaultTrunk(cwd),
918
+ root: mainCheckout(cwd),
919
+ worktreeRoot: worktreeRoot(cwd),
920
+ },
827
921
  input.name,
828
922
  );
829
923
  if (!parsed.ok)
@@ -858,6 +952,26 @@ export function planFanout(
858
952
  { name: plan.name, conflicts, overlaps },
859
953
  );
860
954
 
955
+ const trunkCase = caseIndex(tracked);
956
+ const clashes: CaseCollision[] = [];
957
+ for (const slice of plan.slices) {
958
+ const seen = new Set<string>();
959
+ for (const sample of [...slice.scope, ...slice.owns].flatMap(scopeSamples)) {
960
+ const clash = caseClash(trunkCase, sample);
961
+ if (clash && !seen.has(clash.path)) {
962
+ seen.add(clash.path);
963
+ clashes.push({ slice: slice.id, ...clash });
964
+ }
965
+ }
966
+ }
967
+ if (clashes.length)
968
+ return fanoutFail(
969
+ "blocked",
970
+ `slice ${clashes[0].slice} names ${clashes[0].path}, which differs only in case from ${plan.trunk}'s ${clashes[0].trunk} (one path on macOS and Windows)`,
971
+ `use ${plan.trunk}'s spelling ${clashes[0].trunk} in slice ${clashes[0].slice}'s scope, then workit fanout plan again`,
972
+ { name: plan.name, caseCollisions: clashes, overlaps },
973
+ );
974
+
861
975
  const existing = readFanout(cwd, plan.name);
862
976
  const previous = existing.ok ? existing.data : null;
863
977
  plan.createdAt = previous?.createdAt || now.toISOString();