@brainervirus/workit-core 7.0.0 → 7.1.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.
@@ -0,0 +1,779 @@
1
+ // Release tracks at runtime: one repository, several independent release lines
2
+ // (e.g. `nun-develop` -> `nun-master` beside `develop` -> `master`). The
3
+ // workspace's `releaseTracks` (workspaces.ts) name each line; this module
4
+ // decides which line a branch belongs to, so branch creation, PR targets,
5
+ // merges and the branch policy follow that line instead of one workspace-wide
6
+ // default.
7
+ //
8
+ // - readReleaseTracks is the lenient runtime reader (D17): an unknown or
9
+ // malformed track field is reported and ignored (the track is dropped when
10
+ // it lacks a usable production or integration branch, but every branch it
11
+ // names stays protected), unless the track lists that field (or a parent of
12
+ // it) in its `critical` array, in which case the read fails closed so an
13
+ // older Workit never routes work on a track it misreads.
14
+ // - resolveReleaseTrack is pure: git facts come through an injected probe, so
15
+ // the decision table is testable without a repository. Resolution order,
16
+ // cheapest deterministic signal first:
17
+ // 1. an explicit `--track`;
18
+ // 2. the branch is a track's integration, production or base branch;
19
+ // 3. WORKFLOW_RELEASE_TRACK (a host session's choice);
20
+ // 4. only one track, when the workspace default target is on it;
21
+ // 5. the base `workit git branch` recorded for the branch
22
+ // (git config branch.<b>.workitBase), followed through stacked
23
+ // parents;
24
+ // 6. the branch reflog's "Created from <track branch>";
25
+ // 7. for a branch created at HEAD (plain `git checkout -b` / `switch -c`,
26
+ // branch reflog "Created from HEAD"), the HEAD reflog's
27
+ // "checkout: moving from <track branch> to <b>";
28
+ // 8. ancestry by fork point: the track whose merge-base with the branch
29
+ // strictly descends from every other track's merge-base.
30
+ // When the workspace default target belongs to no track it is an implicit
31
+ // line of its own (resolving to it means "no track": the legacy default).
32
+ // A tie or no usable signal falls back to the workspace default and says so
33
+ // in `warnings`; with two or more tracks it also sets `blocking`, which
34
+ // mutating verbs (git branch, pr create, stack plan) refuse on. It never
35
+ // guesses.
36
+ // - No configured tracks: status "none", and every caller keeps its legacy
37
+ // behavior unchanged (an explicit `--track` is then an error).
38
+ import { createHash } from "node:crypto";
39
+ import { spawnSync } from "node:child_process";
40
+ import { currentBranch, mergeBase, pushRemoteName, remoteNames, resolveRef } from "../git/rev";
41
+ import { readFileSync } from "node:fs";
42
+ import { vcsConfigPath } from "./config";
43
+ import { resolveRuntimeReleaseTracks, workspaceDefaultTarget } from "./workspaces";
44
+
45
+ export type RuntimeReleaseTrack = {
46
+ name: string;
47
+ productionBranch: string;
48
+ integrationBranch: string;
49
+ /** Where new work branches start; defaults to the integration branch. */
50
+ baseBranch: string;
51
+ /** Where work branch PRs go; defaults to the integration branch. */
52
+ pullRequestTarget: string;
53
+ /** Branches a change landed on the production branch must flow back into. */
54
+ mergeBackBranches: string[];
55
+ /** Branch name templates (`feature/{name}`, `release/{version}`, …). */
56
+ naming: { feature?: string; release?: string; hotfix?: string };
57
+ };
58
+
59
+ export type ReleaseTracksRead = {
60
+ tracks: RuntimeReleaseTrack[];
61
+ /** Reported, non-fatal problems (unknown or malformed fields, dropped tracks). */
62
+ issues: string[];
63
+ /** Set when a problem touches a field a track marked `critical`: fail closed. */
64
+ error: string | null;
65
+ /**
66
+ * Every branch name any track entry names, usable or not: the protected
67
+ * union is built from this, so a dropped track never loses protection.
68
+ */
69
+ protectedBranches: string[];
70
+ };
71
+
72
+ /** A release-track problem that must fail closed; callers turn it into a refusal. */
73
+ export class ReleaseTrackError extends Error {}
74
+
75
+ const KNOWN_FIELDS = new Set([
76
+ "strategy",
77
+ "productionBranch",
78
+ "integrationBranch",
79
+ "naming",
80
+ "baseBranch",
81
+ "mergeBackBranches",
82
+ "pullRequestTarget",
83
+ "tagNamespace",
84
+ "versionSource",
85
+ "requiredChecks",
86
+ "critical",
87
+ ]);
88
+ const BRANCH_FIELDS = ["productionBranch", "integrationBranch", "baseBranch", "pullRequestTarget"];
89
+ const PLACEHOLDER = /\{[^}]*\}/gu;
90
+ const HAS_PLACEHOLDER = /\{[^}]*\}/u;
91
+
92
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
93
+ typeof value === "object" && value !== null && !Array.isArray(value);
94
+ const branchName = (value: unknown): string | null =>
95
+ typeof value === "string" && value.trim() && !value.trim().startsWith("-") ? value.trim() : null;
96
+
97
+ /** Every branch-valued string an entry names, whatever else is wrong with it. */
98
+ const namedBranches = (entry: Record<string, unknown>): string[] =>
99
+ [
100
+ ...BRANCH_FIELDS.map((field) => branchName(entry[field])),
101
+ ...(Array.isArray(entry.mergeBackBranches) ? entry.mergeBackBranches.map(branchName) : []),
102
+ ].filter((name): name is string => name !== null);
103
+
104
+ /** A naming template needs literal text besides placeholders and globs, or it would allow anything. */
105
+ const literalOf = (template: string): string =>
106
+ template.replace(PLACEHOLDER, "").replace(/[*/]/gu, "").trim();
107
+
108
+ /** Lenient read of a workspace's raw `releaseTracks` value (D17). */
109
+ export function readReleaseTracks(raw: unknown): ReleaseTracksRead {
110
+ const out: ReleaseTracksRead = { tracks: [], issues: [], error: null, protectedBranches: [] };
111
+ if (raw === undefined || raw === null) return out;
112
+ if (!isRecord(raw)) {
113
+ out.issues.push("releaseTracks is not an object; ignored");
114
+ return out;
115
+ }
116
+ const fatal: string[] = [];
117
+ const protectedNames = new Set<string>();
118
+ for (const [name, entry] of Object.entries(raw).toSorted(([a], [b]) => (a < b ? -1 : 1))) {
119
+ const label = `release track ${JSON.stringify(name)}`;
120
+ if (!isRecord(entry)) {
121
+ out.issues.push(`${label} is not an object; ignored`);
122
+ continue;
123
+ }
124
+ for (const branch of namedBranches(entry)) protectedNames.add(branch);
125
+ if (!name.trim()) {
126
+ out.issues.push("a release track with a blank name was ignored");
127
+ continue;
128
+ }
129
+ let critical: string[] = [];
130
+ if (Array.isArray(entry.critical))
131
+ critical = entry.critical.filter((item): item is string => typeof item === "string");
132
+ else if (entry.critical !== undefined)
133
+ out.issues.push(`${label} critical: must be a list of field names; ignored`);
134
+ // A critical entry covers the field and everything under it (`naming` covers `naming.feature`).
135
+ const isCritical = (field: string) =>
136
+ critical.some((item) => field === item || field.startsWith(`${item}.`));
137
+ const problem = (field: string, message: string) => {
138
+ if (isCritical(field)) fatal.push(`${label} ${field}: ${message}`);
139
+ else out.issues.push(`${label} ${field}: ${message}`);
140
+ };
141
+ for (const key of Object.keys(entry))
142
+ if (!KNOWN_FIELDS.has(key)) problem(key, "unknown field, ignored");
143
+ const productionBranch = branchName(entry.productionBranch);
144
+ const integrationBranch = branchName(entry.integrationBranch);
145
+ if (!productionBranch) problem("productionBranch", "missing or not a branch name");
146
+ if (!integrationBranch) problem("integrationBranch", "missing or not a branch name");
147
+ const optionalBranch = (field: "baseBranch" | "pullRequestTarget"): string | null => {
148
+ if (entry[field] === undefined) return null;
149
+ const value = branchName(entry[field]);
150
+ if (!value) problem(field, "not a branch name; using the integration branch");
151
+ return value;
152
+ };
153
+ const baseBranch = optionalBranch("baseBranch");
154
+ const pullRequestTarget = optionalBranch("pullRequestTarget");
155
+ let mergeBackBranches: string[] = [];
156
+ if (entry.mergeBackBranches !== undefined) {
157
+ const list = Array.isArray(entry.mergeBackBranches)
158
+ ? entry.mergeBackBranches.map(branchName)
159
+ : [null];
160
+ if (list.some((item) => item === null))
161
+ problem("mergeBackBranches", "must be a list of branch names; ignored");
162
+ else mergeBackBranches = [...new Set(list as string[])];
163
+ }
164
+ const naming: RuntimeReleaseTrack["naming"] = {};
165
+ if (entry.naming !== undefined) {
166
+ if (!isRecord(entry.naming)) problem("naming", "not an object; ignored");
167
+ else
168
+ for (const kind of ["feature", "release", "hotfix"] as const) {
169
+ const template = entry.naming[kind];
170
+ if (template === undefined) continue;
171
+ if (typeof template !== "string" || !template.trim())
172
+ problem(`naming.${kind}`, "not a template string; ignored");
173
+ else if (!literalOf(template))
174
+ problem(`naming.${kind}`, "needs literal text besides placeholders; ignored");
175
+ else naming[kind] = template.trim();
176
+ }
177
+ }
178
+ if (!productionBranch || !integrationBranch) {
179
+ out.issues.push(`${label} was skipped: it needs a productionBranch and integrationBranch`);
180
+ continue;
181
+ }
182
+ out.tracks.push({
183
+ name,
184
+ productionBranch,
185
+ integrationBranch,
186
+ baseBranch: baseBranch ?? integrationBranch,
187
+ pullRequestTarget: pullRequestTarget ?? integrationBranch,
188
+ mergeBackBranches,
189
+ naming,
190
+ });
191
+ }
192
+ out.protectedBranches = [...protectedNames];
193
+ if (fatal.length)
194
+ out.error = `workspace releaseTracks cannot be read by this Workit (${fatal.join("; ")}); upgrade Workit or fix the track`;
195
+ return out;
196
+ }
197
+
198
+ /** Every long-lived branch the tracks name: protected by the branch policy. */
199
+ export const trackBranches = (tracks: readonly RuntimeReleaseTrack[]): string[] => [
200
+ ...new Set(
201
+ tracks.flatMap((track) => [
202
+ track.productionBranch,
203
+ track.integrationBranch,
204
+ track.baseBranch,
205
+ track.pullRequestTarget,
206
+ ...track.mergeBackBranches,
207
+ ]),
208
+ ),
209
+ ];
210
+
211
+ /**
212
+ * A naming template as an allowed-branch glob: `feature/{name}` -> `feature/*`;
213
+ * a template without a placeholder is a prefix (`nun/feature` -> `nun/feature/*`).
214
+ */
215
+ export const namingGlob = (template: string): string =>
216
+ HAS_PLACEHOLDER.test(template)
217
+ ? template.replace(PLACEHOLDER, "*")
218
+ : `${template.replace(/\/?\*?$/u, "")}/*`;
219
+
220
+ /** The branch name a track's naming gives `<kind>/<slug>`; null when it has no template. */
221
+ export const trackBranchName = (
222
+ track: Pick<RuntimeReleaseTrack, "naming"> | null,
223
+ kind: string,
224
+ slug: string,
225
+ ): string | null => {
226
+ const template = track?.naming[kind as keyof RuntimeReleaseTrack["naming"]];
227
+ if (!template) return null;
228
+ return HAS_PLACEHOLDER.test(template)
229
+ ? template.replace(PLACEHOLDER, slug)
230
+ : `${template.replace(/\/?\*?$/u, "")}/${slug}`;
231
+ };
232
+
233
+ /** Git facts the resolver needs, injected so the decision itself stays pure. */
234
+ export type TrackProbe = {
235
+ /** Configured remote names (for stripping `<remote>/` from recorded refs). */
236
+ remotes: () => string[];
237
+ /** The base `workit git branch` recorded for a branch, if any. */
238
+ recordedBase: (branch: string) => string | null;
239
+ /** The branch reflog's "Created from <ref>", if it names a ref (not HEAD). */
240
+ createdFrom: (branch: string) => string | null;
241
+ /**
242
+ * For a branch created at HEAD (`git checkout -b` / `switch -c` without a
243
+ * start point), the HEAD reflog's oldest "checkout: moving from <X> to <branch>": X.
244
+ */
245
+ checkedOutFrom: (branch: string) => string | null;
246
+ /** The fork point (merge-base) of a branch and a track branch, or null. */
247
+ mergeBase: (branch: string, trackBranch: string) => string | null;
248
+ /** True when commit `ancestor` is in the history of commit `descendant`. */
249
+ isAncestor: (ancestor: string, descendant: string) => boolean;
250
+ };
251
+
252
+ export type TrackSource =
253
+ | "flag"
254
+ | "env"
255
+ | "branch"
256
+ | "only-track"
257
+ | "recorded-base"
258
+ | "reflog"
259
+ | "checkout"
260
+ | "ancestry"
261
+ | "default";
262
+
263
+ export type TrackResolution =
264
+ | { status: "none"; issues: string[] }
265
+ | {
266
+ status: "resolved";
267
+ /** Null: the legacy workspace default line (no track applies). */
268
+ track: RuntimeReleaseTrack | null;
269
+ source: TrackSource;
270
+ detail: string;
271
+ warnings: string[];
272
+ tracks: RuntimeReleaseTrack[];
273
+ /**
274
+ * Set when the line could not be decided (or WORKFLOW_RELEASE_TRACK is
275
+ * wrong): read-only verbs warn, mutating verbs refuse with this.
276
+ */
277
+ blocking: string | null;
278
+ }
279
+ | { status: "invalid"; error: string; issues: string[] };
280
+
281
+ export type TrackInput = {
282
+ tracks: readonly RuntimeReleaseTrack[];
283
+ /** An explicit `--track`. */
284
+ requested?: string | null;
285
+ /** WORKFLOW_RELEASE_TRACK: below "the branch is a track branch". */
286
+ envRequested?: string | null;
287
+ /** The branch being resolved (current branch, or a base); null when detached. */
288
+ branch: string | null;
289
+ /** The workspace's legacy default target (vcs.defaultTargetBranch or policy default). */
290
+ defaultBranch: string | null;
291
+ probe: TrackProbe;
292
+ /** Problems the reader reported; carried into the warnings. */
293
+ issues?: readonly string[];
294
+ };
295
+
296
+ /** The implicit track standing for the legacy default line; resolving to it yields no track. */
297
+ const IMPLICIT = "";
298
+
299
+ const ownedBy = (tracks: readonly RuntimeReleaseTrack[], branch: string): RuntimeReleaseTrack[] =>
300
+ tracks.filter((track) =>
301
+ [track.integrationBranch, track.productionBranch, track.baseBranch].includes(branch),
302
+ );
303
+
304
+ /** Strip `refs/heads/`, `refs/remotes/<remote>/` and a leading `<remote>/`. */
305
+ const shortRef = (ref: string, remotes: readonly string[]): string => {
306
+ const bare = ref.replace(/^refs\/heads\//u, "").replace(/^refs\/remotes\/[^/]+\//u, "");
307
+ const remote = remotes.find((name) => bare.startsWith(`${name}/`));
308
+ return remote ? bare.slice(remote.length + 1) : bare;
309
+ };
310
+
311
+ const MAX_RECORDED_HOPS = 10;
312
+
313
+ /** Pick the release track for a branch. Pure: git facts come from `probe`. */
314
+ export function resolveReleaseTrack(input: TrackInput): TrackResolution {
315
+ const tracks = [...input.tracks];
316
+ const issues = [...(input.issues ?? [])];
317
+ const requested = input.requested?.trim();
318
+ if (tracks.length === 0) {
319
+ if (requested)
320
+ return {
321
+ status: "invalid",
322
+ error: `no release tracks configured for this workspace; --track ${requested} cannot apply`,
323
+ issues,
324
+ };
325
+ return { status: "none", issues };
326
+ }
327
+ const names = tracks.map((track) => track.name).join(", ");
328
+ let envProblem: string | null = null;
329
+ const done = (
330
+ track: RuntimeReleaseTrack | null,
331
+ source: TrackSource,
332
+ detail: string,
333
+ warnings: string[] = [],
334
+ ambiguous: string | null = null,
335
+ ): TrackResolution => ({
336
+ status: "resolved",
337
+ track: track && track.name !== IMPLICIT ? track : null,
338
+ source,
339
+ detail,
340
+ warnings: [...issues, ...(envProblem ? [envProblem] : []), ...warnings],
341
+ tracks,
342
+ blocking: envProblem ?? ambiguous,
343
+ });
344
+
345
+ if (requested) {
346
+ const named = tracks.find((track) => track.name === requested);
347
+ if (!named)
348
+ return {
349
+ status: "invalid",
350
+ error: `no release track ${JSON.stringify(requested)}; configured: ${names}`,
351
+ issues,
352
+ };
353
+ return done(named, "flag", `track ${named.name} was requested explicitly`);
354
+ }
355
+
356
+ // The legacy default line, when no track owns it, competes as an implicit track.
357
+ const owned = (branch: string | null) =>
358
+ branch !== null &&
359
+ tracks.some((track) =>
360
+ [
361
+ track.integrationBranch,
362
+ track.productionBranch,
363
+ track.baseBranch,
364
+ track.pullRequestTarget,
365
+ ].includes(branch),
366
+ );
367
+ const implicit: RuntimeReleaseTrack | null =
368
+ input.defaultBranch && !owned(input.defaultBranch)
369
+ ? {
370
+ name: IMPLICIT,
371
+ productionBranch: input.defaultBranch,
372
+ integrationBranch: input.defaultBranch,
373
+ baseBranch: input.defaultBranch,
374
+ pullRequestTarget: input.defaultBranch,
375
+ mergeBackBranches: [],
376
+ naming: {},
377
+ }
378
+ : null;
379
+ const candidates = implicit ? [...tracks, implicit] : tracks;
380
+ const label = (track: RuntimeReleaseTrack) =>
381
+ track.name === IMPLICIT
382
+ ? `the workspace default line (${track.integrationBranch})`
383
+ : `track ${track.name}`;
384
+
385
+ const fallback = (why: string): TrackResolution => {
386
+ const byDefault = input.defaultBranch
387
+ ? tracks.filter(
388
+ (track) =>
389
+ track.integrationBranch === input.defaultBranch ||
390
+ track.pullRequestTarget === input.defaultBranch,
391
+ )
392
+ : [];
393
+ const track = byDefault.length === 1 ? byDefault[0] : null;
394
+ const target = track
395
+ ? `track ${track.name}`
396
+ : `default target ${input.defaultBranch ?? "(none)"}`;
397
+ // Count lines, not configured tracks: one track beside a distinct default
398
+ // line is still two lines to choose between.
399
+ const ambiguous =
400
+ candidates.length >= 2
401
+ ? `can't tell which release line ${input.branch ?? "HEAD"} is on (${why}); pass --track <name> (${names}) or --base <branch>`
402
+ : null;
403
+ return done(
404
+ track,
405
+ "default",
406
+ `${why}; using the workspace ${target}`,
407
+ [
408
+ `release track not determined: ${why}; using the workspace ${target} (pass --track <name> to choose)`,
409
+ ],
410
+ ambiguous,
411
+ );
412
+ };
413
+ const branch = input.branch;
414
+
415
+ // A track's own long-lived branch: no history needed, and nothing outranks it but --track.
416
+ if (branch) {
417
+ const owners = ownedBy(candidates, branch);
418
+ if (owners.length === 1)
419
+ return done(owners[0], "branch", `${branch} is a branch of ${label(owners[0])}`);
420
+ if (owners.length > 1)
421
+ return fallback(
422
+ `${branch} belongs to more than one track (${owners.map((track) => track.name).join(", ")})`,
423
+ );
424
+ }
425
+
426
+ const env = input.envRequested?.trim();
427
+ if (env) {
428
+ const named = tracks.find((track) => track.name === env);
429
+ if (named) return done(named, "env", `WORKFLOW_RELEASE_TRACK selects track ${named.name}`);
430
+ envProblem = `WORKFLOW_RELEASE_TRACK=${env} names no release track of this workspace (${names}); ignored`;
431
+ }
432
+
433
+ if (!branch) return fallback("HEAD is detached");
434
+ if (tracks.length === 1 && !implicit)
435
+ return done(tracks[0], "only-track", `${tracks[0].name} is the only track`);
436
+
437
+ let remotes: readonly string[] | null = null;
438
+ const short = (ref: string) => shortRef(ref, (remotes ??= input.probe.remotes()));
439
+ const ownerOf = (ref: string): RuntimeReleaseTrack | null => {
440
+ const owner = ownedBy(candidates, short(ref));
441
+ return owner.length === 1 ? owner[0] : null;
442
+ };
443
+
444
+ // The base workit recorded when it created a branch, followed through
445
+ // stacked parents (feature/b on feature/a on develop).
446
+ const followRecorded = (start: string): RuntimeReleaseTrack | null => {
447
+ let cursor = start;
448
+ const seen = new Set<string>([start]);
449
+ for (let hop = 0; hop < MAX_RECORDED_HOPS; hop += 1) {
450
+ const recorded = input.probe.recordedBase(cursor);
451
+ if (!recorded) return null;
452
+ const base = short(recorded);
453
+ const owner = ownerOf(base);
454
+ if (owner) return owner;
455
+ if (ownedBy(candidates, base).length > 1 || seen.has(base)) return null;
456
+ seen.add(base);
457
+ cursor = base;
458
+ }
459
+ return null;
460
+ };
461
+ const recordedOwner = followRecorded(branch);
462
+ if (recordedOwner)
463
+ return done(
464
+ recordedOwner,
465
+ "recorded-base",
466
+ `${branch} was created on ${label(recordedOwner)}'s line (recorded base)`,
467
+ );
468
+
469
+ // Where the branch started, from the reflogs; a start that is itself a
470
+ // stacked work branch is followed through its recorded base.
471
+ for (const source of ["reflog", "checkout"] as const) {
472
+ const ref =
473
+ source === "reflog" ? input.probe.createdFrom(branch) : input.probe.checkedOutFrom(branch);
474
+ if (!ref) continue;
475
+ const owner = ownerOf(ref) ?? followRecorded(short(ref));
476
+ if (owner)
477
+ return done(
478
+ owner,
479
+ source,
480
+ `${branch} was created from ${short(ref)} (${source === "reflog" ? "branch reflog" : "HEAD reflog"})`,
481
+ );
482
+ }
483
+
484
+ // Ancestry by fork point: a line wins only when its merge-base with the
485
+ // branch strictly descends from every other line's merge-base. Equal fork
486
+ // points (a line forked from another, or one that merges the other in) are
487
+ // ambiguous, never a guess by commit counts.
488
+ type Fork = { track: RuntimeReleaseTrack; ref: string; sha: string };
489
+ const forks: Fork[] = [];
490
+ for (const track of candidates)
491
+ for (const ref of new Set([track.integrationBranch, track.baseBranch])) {
492
+ const sha = input.probe.mergeBase(branch, ref);
493
+ if (sha) forks.push({ track, ref, sha });
494
+ }
495
+ if (forks.length === 0)
496
+ return fallback(`no track branch could be compared with ${branch} (fetch the track branches)`);
497
+ const ancestry = new Map<string, boolean>();
498
+ const descends = (older: string, newer: string): boolean => {
499
+ if (older === newer) return false;
500
+ const key = `${older}>${newer}`;
501
+ if (!ancestry.has(key)) ancestry.set(key, input.probe.isAncestor(older, newer));
502
+ return ancestry.get(key) as boolean;
503
+ };
504
+ const winners = forks.filter((fork) =>
505
+ forks.every((other) => other.track === fork.track || descends(other.sha, fork.sha)),
506
+ );
507
+ const winningTracks = [...new Set(winners.map((fork) => fork.track))];
508
+ if (winningTracks.length === 1 && forks.some((fork) => fork.track !== winningTracks[0])) {
509
+ const win = winners[0];
510
+ return done(
511
+ win.track,
512
+ "ancestry",
513
+ `${branch} forked from ${win.ref} at ${win.sha.slice(0, 12)}, after every other line's fork point`,
514
+ );
515
+ }
516
+ if (winningTracks.length === 1)
517
+ return fallback(`only ${label(winningTracks[0])} could be compared with ${branch}`);
518
+ const newest = forks.filter((fork) => !forks.some((other) => descends(fork.sha, other.sha)));
519
+ return fallback(
520
+ `${branch} has the same or unrelated fork points with ${[...new Set(newest.map((fork) => `${fork.ref} (${fork.track.name === IMPLICIT ? "workspace default" : fork.track.name})`))].join(" and ")}`,
521
+ );
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // git-backed probe and the workspace entry point
526
+
527
+ const git = (cwd: string, args: string[]): string | null => {
528
+ const run = spawnSync("git", args, {
529
+ cwd,
530
+ encoding: "utf8",
531
+ timeout: 20_000,
532
+ windowsHide: true,
533
+ stdio: ["ignore", "pipe", "ignore"],
534
+ });
535
+ return run.status === 0 ? (run.stdout ?? "").trim() : null;
536
+ };
537
+
538
+ /** The git config key `workit git branch` records a new branch's base under. */
539
+ export const recordedBaseKey = (branch: string): string => `branch.${branch}.workitBase`;
540
+
541
+ /** Record the base a new branch was created from (best effort). */
542
+ export const recordBranchBase = (cwd: string, branch: string, base: string): void => {
543
+ git(cwd, ["config", recordedBaseKey(branch), base]);
544
+ };
545
+
546
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
547
+
548
+ type ReflogEntry = { sha: string; time: number; subject: string };
549
+
550
+ /** A ref's reflog, newest first, with unix times. */
551
+ const reflog = (cwd: string, ref: string): ReflogEntry[] =>
552
+ (git(cwd, ["log", "-g", "--date=unix", "--format=%H%x09%gd%x09%gs", ref, "--"]) ?? "")
553
+ .split("\n")
554
+ .map((line) => {
555
+ const [sha = "", selector = "", ...subject] = line.split("\t");
556
+ return {
557
+ sha,
558
+ time: Number(/@\{(\d+)\}$/u.exec(selector)?.[1] ?? Number.NaN),
559
+ subject: subject.join("\t"),
560
+ };
561
+ })
562
+ .filter((entry) => entry.sha && Number.isFinite(entry.time));
563
+
564
+ export function gitTrackProbe(cwd: string): TrackProbe {
565
+ let remote: string | null | undefined;
566
+ let remotes: string[] | undefined;
567
+ const tip = (branch: string): string | null => {
568
+ remote ??= pushRemoteName(cwd) ?? "origin";
569
+ return (
570
+ resolveRef(cwd, `refs/remotes/${remote}/${branch}`) ?? resolveRef(cwd, `refs/heads/${branch}`)
571
+ );
572
+ };
573
+ return {
574
+ remotes: () => (remotes ??= remoteNames(cwd)),
575
+ recordedBase: (branch) => git(cwd, ["config", "--get", recordedBaseKey(branch)]) || null,
576
+ createdFrom: (branch) => {
577
+ const log = git(cwd, ["reflog", "show", "--format=%gs", `refs/heads/${branch}`, "--"]);
578
+ const oldest = log?.split("\n").findLast(Boolean) ?? "";
579
+ const match = /^branch: Created from (\S+)$/u.exec(oldest);
580
+ return match && match[1] !== "HEAD" ? match[1] : null;
581
+ },
582
+ checkedOutFrom: (branch) => {
583
+ // Only a branch started at HEAD ("Created from HEAD"): then the branch
584
+ // HEAD was on is its start. `switch -c b <sha>` names another start point.
585
+ const own = reflog(cwd, `refs/heads/${branch}`);
586
+ const created = own.at(-1);
587
+ if (!created || created.subject !== "branch: Created from HEAD") return null;
588
+ // A reused name leaves older "moving from X to <b>" entries behind: only
589
+ // an entry at or after this branch's creation, landing on its creation
590
+ // commit, counts. The oldest such entry is the creation itself (a later
591
+ // switch back to the branch before any commit lands on the same commit).
592
+ const pattern = new RegExp(`^checkout: moving from (\\S+) to ${escapeRegExp(branch)}$`, "u");
593
+ const entry = reflog(cwd, "HEAD").findLast(
594
+ (item) =>
595
+ item.time >= created.time && item.sha === created.sha && pattern.test(item.subject),
596
+ );
597
+ return entry ? (pattern.exec(entry.subject)?.[1] ?? null) : null;
598
+ },
599
+ mergeBase: (branch, trackBranch) => {
600
+ const base = tip(trackBranch);
601
+ return base && resolveRef(cwd, `refs/heads/${branch}`)
602
+ ? mergeBase(cwd, base, `refs/heads/${branch}`)
603
+ : null;
604
+ },
605
+ isAncestor: (ancestor, descendant) =>
606
+ git(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]) !== null,
607
+ };
608
+ }
609
+
610
+ /** The tracks configured for the workspace matching cwd (lenient). */
611
+ export function workspaceReleaseTracks(cwd: string): ReleaseTracksRead & {
612
+ workspace: string | null;
613
+ } {
614
+ const raw = resolveRuntimeReleaseTracks(cwd);
615
+ return { workspace: raw?.workspace ?? null, ...readReleaseTracks(raw?.releaseTracks) };
616
+ }
617
+
618
+ /** vcs.json's global `defaultTargetBranch`, read leniently (display only; vcsConfig reports a malformed file). */
619
+ const globalDefaultTarget = (): unknown => {
620
+ try {
621
+ const parsed: unknown = JSON.parse(readFileSync(vcsConfigPath(), "utf8"));
622
+ return isRecord(parsed) ? parsed.defaultTargetBranch : undefined;
623
+ } catch {
624
+ return undefined;
625
+ }
626
+ };
627
+
628
+ // Per-process memo: a CLI command asks several times (vcsConfig callers,
629
+ // policy checks). Keyed on every input, including a digest of all branch tips,
630
+ // so a long-lived host never sees a stale answer after a fetch or commit.
631
+ const memo = new Map<string, TrackResolution>();
632
+ const MEMO_LIMIT = 64;
633
+
634
+ /** Resolve the release track for cwd's workspace and a branch (default: HEAD). */
635
+ export function resolveTrackFor(
636
+ cwd: string,
637
+ options: {
638
+ track?: string | null;
639
+ branch?: string | null;
640
+ defaultBranch: string | null;
641
+ probe?: TrackProbe;
642
+ },
643
+ ): TrackResolution {
644
+ const read = workspaceReleaseTracks(cwd);
645
+ if (read.error) return { status: "invalid", error: read.error, issues: read.issues };
646
+ if (read.tracks.length === 0 && !options.track?.trim())
647
+ return { status: "none", issues: read.issues };
648
+ const branch = options.branch === undefined ? currentBranch(cwd) : options.branch;
649
+ const envRequested = process.env.WORKFLOW_RELEASE_TRACK?.trim() || null;
650
+ const input = {
651
+ tracks: read.tracks,
652
+ requested: options.track ?? null,
653
+ envRequested,
654
+ branch,
655
+ defaultBranch: options.defaultBranch,
656
+ issues: read.issues,
657
+ };
658
+ let key: string | null = null;
659
+ if (!options.probe) {
660
+ const refs =
661
+ git(cwd, [
662
+ "for-each-ref",
663
+ "--format=%(refname) %(objectname)",
664
+ "refs/heads",
665
+ "refs/remotes",
666
+ ]) ?? "";
667
+ const head = git(cwd, ["reflog", "show", "-n", "1", "--format=%H %gs", "HEAD", "--"]) ?? "";
668
+ // A deleted and recreated branch name, or a changed recorded base, must miss.
669
+ const own = branch
670
+ ? [
671
+ git(cwd, [
672
+ "log",
673
+ "-g",
674
+ "-n",
675
+ "1",
676
+ "--date=unix",
677
+ "--format=%H %gd %gs",
678
+ `refs/heads/${branch}`,
679
+ "--",
680
+ ]) ?? "",
681
+ git(cwd, ["config", "--get", recordedBaseKey(branch)]) ?? "",
682
+ ]
683
+ : [];
684
+ key = createHash("sha256")
685
+ .update(JSON.stringify({ cwd, input, refs, head, own }))
686
+ .digest("hex");
687
+ const hit = memo.get(key);
688
+ if (hit) return hit;
689
+ }
690
+ const resolution = resolveReleaseTrack({ ...input, probe: options.probe ?? gitTrackProbe(cwd) });
691
+ if (key) {
692
+ if (memo.size >= MEMO_LIMIT) memo.delete(memo.keys().next().value as string);
693
+ memo.set(key, resolution);
694
+ }
695
+ return resolution;
696
+ }
697
+
698
+ /** What `workit grant show` and `workit doctor` print about release tracks. */
699
+ export type ReleaseTracksReport = {
700
+ workspace: string | null;
701
+ tracks: { name: string; integrationBranch: string; productionBranch: string }[];
702
+ /** The track cwd's checkout resolves to; null without tracks or on error. */
703
+ resolved: { name: string | null; source: string; detail: string } | null;
704
+ /** Reader issues and resolution warnings (non-fatal). */
705
+ warnings: string[];
706
+ /** A fatal problem (a critical track field, an unreadable config). */
707
+ error: string | null;
708
+ };
709
+
710
+ /**
711
+ * The configured tracks and the one cwd resolves to, for display. The default
712
+ * target comes from workspaceDefaultTarget, the computation vcsConfig uses,
713
+ * without importing the VCS resolver (the doctor stays light); it resolves once.
714
+ */
715
+ export function releaseTracksReport(cwd: string): ReleaseTracksReport {
716
+ let raw: ReturnType<typeof resolveRuntimeReleaseTracks>;
717
+ try {
718
+ raw = resolveRuntimeReleaseTracks(cwd);
719
+ } catch (error) {
720
+ return {
721
+ workspace: null,
722
+ tracks: [],
723
+ resolved: null,
724
+ warnings: [],
725
+ error: error instanceof Error ? error.message : String(error),
726
+ };
727
+ }
728
+ const read = readReleaseTracks(raw?.releaseTracks);
729
+ const tracks = read.tracks.map(({ name, integrationBranch, productionBranch }) => ({
730
+ name,
731
+ integrationBranch,
732
+ productionBranch,
733
+ }));
734
+ const base = { workspace: raw?.workspace ?? null, tracks };
735
+ if (read.error) return { ...base, resolved: null, warnings: read.issues, error: read.error };
736
+ if (tracks.length === 0) return { ...base, resolved: null, warnings: read.issues, error: null };
737
+ let defaultBranch: string;
738
+ try {
739
+ // The same default vcsConfig uses, so the display matches the commands.
740
+ defaultBranch = workspaceDefaultTarget(cwd, globalDefaultTarget());
741
+ } catch (error) {
742
+ return {
743
+ ...base,
744
+ resolved: null,
745
+ warnings: read.issues,
746
+ error: error instanceof Error ? error.message : String(error),
747
+ };
748
+ }
749
+ const resolution = resolveTrackFor(cwd, { defaultBranch });
750
+ if (resolution.status === "invalid")
751
+ return { ...base, resolved: null, warnings: resolution.issues, error: resolution.error };
752
+ if (resolution.status === "none")
753
+ return { ...base, resolved: null, warnings: resolution.issues, error: null };
754
+ return {
755
+ ...base,
756
+ resolved: {
757
+ name: resolution.track?.name ?? null,
758
+ source: resolution.source,
759
+ detail: resolution.detail,
760
+ },
761
+ warnings: resolution.warnings,
762
+ error: null,
763
+ };
764
+ }
765
+
766
+ /** One human line per fact, shared by grant show and doctor. */
767
+ export const describeReleaseTracks = (report: ReleaseTracksReport): string[] =>
768
+ report.tracks.length === 0 && !report.error
769
+ ? []
770
+ : [
771
+ `release tracks: ${report.tracks.map((track) => `${track.name} (${track.integrationBranch} -> ${track.productionBranch})`).join(", ") || "(none usable)"}`,
772
+ ...(report.resolved
773
+ ? [
774
+ `this checkout: ${report.resolved.name ?? "workspace default"} (${report.resolved.detail})`,
775
+ ]
776
+ : []),
777
+ ...(report.error ? [`release tracks error: ${report.error}`] : []),
778
+ ...report.warnings.map((warning) => `note: ${warning}`),
779
+ ];