@brainervirus/workit-core 7.0.0 → 7.2.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.
@@ -1,14 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
- import { configDir, PRESETS } from "./config";
5
- import { resolveRuntimeWorkspaceVcs } from "./workspaces";
6
- import { resolveBranchPolicyFor } from "./branch";
4
+ import { configDir, vcsConfigPath } from "./config";
5
+ import { resolveRuntimeWorkspaceVcs, workspaceDefaultTarget } from "./workspaces";
6
+ import { resolveTrackFor, type RuntimeReleaseTrack, type TrackResolution } from "./release-tracks";
7
7
  // VCS resolution and CLI-backed identity/style reads.
8
8
 
9
- export const vcsConfigPath = (): string =>
10
- process.env.WORKFLOW_VCS_CONFIG ?? path.join(configDir(), "vcs.json");
11
-
12
9
  const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
13
10
 
14
11
  const vcsCwd = (cwd?: string): string =>
@@ -146,8 +143,45 @@ export const readVcsConfig = (): VcsConfigResult => {
146
143
  return { status: "malformed", path: file, config: {}, error: `${file} is not a JSON object` };
147
144
  };
148
145
 
146
+ /** The release track a vcsConfig call resolved (null: the workspace has no tracks). */
147
+ export type ResolvedReleaseTrack = {
148
+ name: string | null;
149
+ source: string;
150
+ detail: string;
151
+ warnings: string[];
152
+ track: RuntimeReleaseTrack | null;
153
+ /** Every configured track, for displays and the branch policy. */
154
+ tracks: RuntimeReleaseTrack[];
155
+ /** The line is undetermined (or WORKFLOW_RELEASE_TRACK is wrong): mutating verbs refuse. */
156
+ blocking: string | null;
157
+ };
158
+
159
+ export type VcsConfigOptions = {
160
+ /** An explicit release track (`--track`); WORKFLOW_RELEASE_TRACK is read below it. */
161
+ track?: string | null;
162
+ /** Resolve the track for this branch instead of the checked-out one. */
163
+ branch?: string | null;
164
+ };
165
+
166
+ const trackSummary = (resolution: TrackResolution): ResolvedReleaseTrack | null =>
167
+ resolution.status === "resolved"
168
+ ? {
169
+ name: resolution.track?.name ?? null,
170
+ source: resolution.source,
171
+ detail: resolution.detail,
172
+ warnings: resolution.warnings,
173
+ track: resolution.track,
174
+ tracks: resolution.tracks,
175
+ blocking: resolution.blocking,
176
+ }
177
+ : null;
178
+
149
179
  /** Port of scripts/vcs/config.sh — mode: load | summary | resolve. */
150
- export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): Record<string, any> {
180
+ export function vcsConfig(
181
+ mode: "load" | "summary" | "resolve",
182
+ cwd?: string,
183
+ options: VcsConfigOptions = {},
184
+ ): Record<string, any> {
151
185
  const ws = resolveRuntimeWorkspaceVcs(vcsCwd(cwd));
152
186
  const wsVcs = (ws?.vcs ?? {}) as Record<string, any>;
153
187
  const wsYt = (ws?.youtrack ?? {}) as Record<string, any>;
@@ -183,14 +217,9 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
183
217
  // main). Explicit workspace vcs.defaultTargetBranch stays authoritative; a
184
218
  // workspace without a branchPolicy still falls back to the global vcs.json
185
219
  // default, and unmatched repos keep it too.
186
- const wp = (ws?.branchPolicy ?? {}) as Record<string, any>;
187
- const hasWorkspacePolicy = typeof wp.preset === "string" && Object.hasOwn(PRESETS, wp.preset);
188
- const policyDefault = resolveBranchPolicyFor(root).defaultTargetBranch;
189
- const defaultTarget = String(
190
- wsVcs.defaultTargetBranch ??
191
- (hasWorkspacePolicy ? policyDefault : (cfg.defaultTargetBranch ?? policyDefault)) ??
192
- "develop",
193
- );
220
+ // CA-05/CA-02 (workspaces.ts workspaceDefaultTarget): shared with doctor
221
+ // and grant show so their display never disagrees with the commands.
222
+ const workspaceDefault = workspaceDefaultTarget(root, cfg.defaultTargetBranch);
194
223
  const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
195
224
  const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
196
225
  // github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
@@ -210,11 +239,30 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
210
239
  // instead of silently resolving defaults; a missing file is still a
211
240
  // legitimate unconfigured state that falls back to defaults.
212
241
  if (cfgStatus === "malformed") return { ok: false, error: cfgError, configPath: cfgPath };
242
+ // Release tracks (core/release-tracks.ts): the one place a branch's line is
243
+ // decided, in resolve mode only (load serves identity, push and tracker
244
+ // paths that never need it). With tracks, the PR target and the base for
245
+ // new branches come from the resolved track; without, the workspace
246
+ // default stays as is.
247
+ const trackResolution = resolveTrackFor(root, {
248
+ track: options.track,
249
+ ...(options.branch !== undefined ? { branch: options.branch } : {}),
250
+ defaultBranch: workspaceDefault,
251
+ });
252
+ if (trackResolution.status === "invalid")
253
+ return { ok: false, error: trackResolution.error, configPath: workspacesPath() };
254
+ const releaseTrack = trackSummary(trackResolution);
255
+ const defaultTarget = releaseTrack?.track?.pullRequestTarget ?? workspaceDefault;
256
+ const baseBranch = releaseTrack?.track?.baseBranch ?? defaultTarget;
213
257
  return {
214
258
  ok: true,
215
259
  workspace_name: ws?.name ?? null,
216
260
  provider,
217
261
  defaultTargetBranch: defaultTarget,
262
+ /** The target before release tracks (vcs.defaultTargetBranch or the policy default). */
263
+ workspaceDefaultTargetBranch: workspaceDefault,
264
+ baseBranch,
265
+ releaseTrack,
218
266
  link_issues: linkIssues,
219
267
  youtrack_base_url: youtrackBaseUrl,
220
268
  issues_provider: issuesProvider,
@@ -241,7 +289,8 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
241
289
  ok: true,
242
290
  configPath: path.resolve(cfgPath),
243
291
  provider,
244
- defaultTargetBranch: defaultTarget,
292
+ // Load mode does not resolve release tracks: the workspace default.
293
+ defaultTargetBranch: workspaceDefault,
245
294
  pr: cfg.pr ?? {},
246
295
  workspace_name: ws?.name ?? null,
247
296
  link_issues: linkIssues,
@@ -7,6 +7,7 @@ import {
7
7
  PRESETS,
8
8
  configDir,
9
9
  isConfigObject,
10
+ readConfig,
10
11
  resolveBranchPolicy as resolveConfiguredBranchPolicy,
11
12
  resolveCommitPolicy as resolveConfiguredCommitPolicy,
12
13
  type BranchPreset,
@@ -68,31 +69,43 @@ const versionSourceSchema = z.discriminatedUnion("kind", [
68
69
  z.object({ kind: z.literal("git-tag") }).strict(),
69
70
  z.object({ kind: z.literal("manual") }).strict(),
70
71
  ]);
72
+ // Only the production and integration branches are required: runtime
73
+ // (core/release-tracks.ts) defaults the rest. Unknown keys pass through (D17:
74
+ // a newer Workit's field must not invalidate the file for an older one); a
75
+ // track lists the ones an older reader must not ignore in `critical`.
71
76
  const releaseTrackSchema = z
72
77
  .object({
73
- strategy: branchPreset,
78
+ strategy: branchPreset.optional(),
74
79
  productionBranch: nonBlank,
75
80
  integrationBranch: nonBlank,
76
- naming: z.object({ feature: nonBlank, release: nonBlank, hotfix: nonBlank }).strict(),
77
- baseBranch: nonBlank,
78
- mergeBackBranches: z.array(nonBlank),
79
- pullRequestTarget: nonBlank,
80
- tagNamespace: z.string(),
81
- versionSource: versionSourceSchema,
82
- requiredChecks: z.array(nonBlank),
81
+ naming: z
82
+ .object({
83
+ feature: nonBlank.optional(),
84
+ release: nonBlank.optional(),
85
+ hotfix: nonBlank.optional(),
86
+ })
87
+ .passthrough()
88
+ .optional(),
89
+ baseBranch: nonBlank.optional(),
90
+ mergeBackBranches: z.array(nonBlank).optional(),
91
+ pullRequestTarget: nonBlank.optional(),
92
+ tagNamespace: z.string().optional(),
93
+ versionSource: versionSourceSchema.optional(),
94
+ requiredChecks: z.array(nonBlank).optional(),
95
+ critical: z.array(nonBlank).optional(),
83
96
  })
84
- .strict()
97
+ .passthrough()
85
98
  .superRefine((track, context) => {
86
99
  for (const [field, values] of [
87
- ["mergeBackBranches", track.mergeBackBranches],
88
- ["requiredChecks", track.requiredChecks],
100
+ ["mergeBackBranches", track.mergeBackBranches ?? []],
101
+ ["requiredChecks", track.requiredChecks ?? []],
89
102
  ] as const) {
90
103
  if (new Set(values).size !== values.length) {
91
104
  context.addIssue({ code: "custom", path: [field], message: "entries must be unique" });
92
105
  }
93
106
  }
94
107
  if (
95
- track.versionSource.kind === "package-json" &&
108
+ track.versionSource?.kind === "package-json" &&
96
109
  (path.posix.isAbsolute(track.versionSource.path.replaceAll("\\", "/")) ||
97
110
  /^[A-Za-z]:\//u.test(track.versionSource.path.replaceAll("\\", "/")) ||
98
111
  track.versionSource.path.replaceAll("\\", "/").split("/").includes(".."))
@@ -119,8 +132,13 @@ const workspaceConfigSchema = z
119
132
  .optional(),
120
133
  /** Legacy (≤4.x) standing approvals; read once as autonomy grants (autonomy.ts). */
121
134
  autoApprove: z.union([z.boolean(), z.array(nonBlank)]).optional(),
122
- /** Where an unnamed request stops (skills read it); same rules as grants. */
123
- defaultEndpoint: z.enum(["commit", "pr"]).optional(),
135
+ /**
136
+ * Where an unnamed request stops (skills read it): commit, pr, green or
137
+ * merged; same rules as grants. Read leniently (autonomy.ts endpointOf):
138
+ * a value this version does not know is reported and treated as commit,
139
+ * so a newer Workit's endpoint never invalidates the whole file.
140
+ */
141
+ defaultEndpoint: z.unknown().optional(),
124
142
  /** How a normal-risk behavior change is verified (S17); same rules as grants. */
125
143
  verification: z.enum(["self", "independent"]).optional(),
126
144
  /** Autonomy grants (D15): only ever set here, by the user. */
@@ -557,10 +575,10 @@ const runtimeWorkspaceIndexSchema = z
557
575
  })
558
576
  .passthrough();
559
577
 
560
- const resolveRuntimeWorkspaceCandidate = (
578
+ /** The workspaces.json entry matching cwd, checked only for name/glob (D17). */
579
+ const matchRuntimeWorkspace = (
561
580
  cwd: string,
562
- kind: RuntimeWorkspacePolicy,
563
- ): RuntimeWorkspaceCandidate | null => {
581
+ ): { file: string; selected: Record<string, unknown> & { name: string } } | null => {
564
582
  const file = path.join(configDir(), "workspaces.json");
565
583
  let raw: string;
566
584
  try {
@@ -601,7 +619,30 @@ const resolveRuntimeWorkspaceCandidate = (
601
619
  });
602
620
  const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
603
621
  const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
604
- if (!selected) return null;
622
+ return selected ? { file, selected } : null;
623
+ };
624
+
625
+ /**
626
+ * The raw `releaseTracks` of the workspace matching cwd, unvalidated: the
627
+ * runtime reads them leniently (core/release-tracks.ts) so one malformed track
628
+ * is reported instead of disabling every command.
629
+ */
630
+ export const resolveRuntimeReleaseTracks = (
631
+ cwd: string,
632
+ ): { workspace: string; releaseTracks: unknown } | null => {
633
+ const matched = matchRuntimeWorkspace(cwd);
634
+ return matched
635
+ ? { workspace: matched.selected.name, releaseTracks: matched.selected.releaseTracks }
636
+ : null;
637
+ };
638
+
639
+ const resolveRuntimeWorkspaceCandidate = (
640
+ cwd: string,
641
+ kind: RuntimeWorkspacePolicy,
642
+ ): RuntimeWorkspaceCandidate | null => {
643
+ const matched = matchRuntimeWorkspace(cwd);
644
+ if (!matched) return null;
645
+ const { file, selected } = matched;
605
646
  const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
606
647
  if (!policy.success) {
607
648
  const detail = policy.error.issues
@@ -730,3 +771,31 @@ export const selectReleaseTrack = (
730
771
  }
731
772
  return { status: "selected", name: requestedTrack, track: tracks[requestedTrack] };
732
773
  };
774
+
775
+ /**
776
+ * The workspace default target before release tracks: the one computation
777
+ * vcsConfig, `workit doctor` and `workit grant show` share (CA-05/CA-02).
778
+ * Explicit workspace vcs.defaultTargetBranch wins; a workspace with its own
779
+ * branchPolicy uses its preset's default (gitflow -> develop, github-flow ->
780
+ * main, trunk-based -> master); otherwise the global vcs.json
781
+ * `defaultTargetBranch` (`globalDefault`), else the effective preset's.
782
+ */
783
+ export function workspaceDefaultTarget(cwd: string, globalDefault: unknown): string {
784
+ const ws = resolveRuntimeWorkspaceVcs(cwd);
785
+ const wp = (ws?.branchPolicy ?? {}) as Record<string, unknown>;
786
+ const hasWorkspacePolicy = typeof wp.preset === "string" && Object.hasOwn(PRESETS, wp.preset);
787
+ const selected = resolveRuntimeWorkspacePolicy(
788
+ cwd,
789
+ "branch",
790
+ process.env.WORKFLOW_PROFILE?.trim() || undefined,
791
+ );
792
+ const policyDefault = resolveConfiguredBranchPolicy(
793
+ readConfig(),
794
+ selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
795
+ ).defaultTargetBranch;
796
+ return String(
797
+ (ws?.vcs as { defaultTargetBranch?: unknown } | undefined)?.defaultTargetBranch ??
798
+ (hasWorkspacePolicy ? policyDefault : (globalDefault ?? policyDefault)) ??
799
+ "develop",
800
+ );
801
+ }
@@ -18,6 +18,7 @@
18
18
  import { requireGrant, type AutonomySource } from "../autonomy";
19
19
  import { isProtectedTarget } from "../core/branch";
20
20
  import { vcsConfig } from "../core/vcs-config";
21
+ import { workspaceReleaseTracks } from "../core/release-tracks";
21
22
  import { deleteRemoteBranch } from "../git/ops";
22
23
  import { spawnSync } from "node:child_process";
23
24
  import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
@@ -260,6 +261,33 @@ export type MergeOutcome = {
260
261
  grant: { source: AutonomySource };
261
262
  deletedBranch: boolean | { error: string };
262
263
  recorded: { id: string } | { error: string };
264
+ /**
265
+ * Release tracks: when the PR landed on a track's production branch, the
266
+ * branches it must flow back into (mergeBackBranches); empty otherwise.
267
+ * Workit reports them; it does not open the merge-back PRs itself.
268
+ */
269
+ mergeBack: string[];
270
+ mergeBackTrack: string | null;
271
+ };
272
+
273
+ /**
274
+ * The merge-back a merge into `base` owes: the track whose production branch
275
+ * `base` is, by ownership alone (no --track, no WORKFLOW_RELEASE_TRACK, no
276
+ * history). Best effort: a config problem never stops the pr.merged record.
277
+ */
278
+ const mergeBackFor = (cwd: string, base: string): { track: string | null; branches: string[] } => {
279
+ try {
280
+ const owners = workspaceReleaseTracks(cwd).tracks.filter(
281
+ (track) => track.productionBranch === base,
282
+ );
283
+ if (owners.length !== 1) return { track: null, branches: [] };
284
+ return {
285
+ track: owners[0].name,
286
+ branches: owners[0].mergeBackBranches.filter((branch) => branch !== base),
287
+ };
288
+ } catch {
289
+ return { track: null, branches: [] };
290
+ }
263
291
  };
264
292
 
265
293
  /** Why `pr merge` refused, for the envelope `data` (agents branch on `reason`). */
@@ -431,6 +459,7 @@ export async function mergePullRequest(
431
459
  deletedBranch =
432
460
  deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
433
461
  }
462
+ const mergeBack = mergeBackFor(cwd, doc.base);
434
463
  const row = appendObserved(cwd, {
435
464
  type: "pr.merged",
436
465
  actor: input.actor,
@@ -457,6 +486,8 @@ export async function mergePullRequest(
457
486
  grant: { source: grant.source },
458
487
  deletedBranch,
459
488
  recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
489
+ mergeBack: mergeBack.branches,
490
+ mergeBackTrack: mergeBack.track,
460
491
  },
461
492
  };
462
493
  }
@@ -34,6 +34,23 @@ export type NextAction =
34
34
  | "NOT_MERGEABLE"
35
35
  | "READY";
36
36
 
37
+ /**
38
+ * The next babysitting step for a `green`/`merged` endpoint, coarser than
39
+ * `next`: `wait` is CI running (`workit ci wait`); `wait-forge` is CI done
40
+ * but the forge still deciding (merge queue, mergeability computing);
41
+ * `ready` means nothing is left for the agent (a human approval may still be
42
+ * pending); null for a closed, unmerged PR.
43
+ */
44
+ export type BabysitAction =
45
+ | "wait"
46
+ | "wait-forge"
47
+ | "mark-ready"
48
+ | "fix-ci"
49
+ | "address-threads"
50
+ | "update-branch"
51
+ | "ready"
52
+ | "merged";
53
+
37
54
  export type FailingCheck = {
38
55
  name: string;
39
56
  url: string | null;
@@ -90,6 +107,7 @@ export type PrStatusDoc = {
90
107
  identity: { login: string | null; credential: string; note?: string } | null;
91
108
  truncated: boolean;
92
109
  next: NextAction;
110
+ babysit: BabysitAction | null;
93
111
  };
94
112
 
95
113
  /** Failing checks whose log tail is fetched; the rest list without a tail. */
@@ -192,6 +210,42 @@ export function nextAction(input: NextInput): NextAction {
192
210
  return blockersOf(input)[0]?.next ?? "READY";
193
211
  }
194
212
 
213
+ /**
214
+ * Map `next` (plus the first blocker's reason) to one babysitting step. The
215
+ * branch is updated only when the forge requires it (conflicts, a required
216
+ * rebase): merely behind its base is still `ready`, so a busy base never
217
+ * starts a rebase/CI loop or drops approvals. A draft reads `mark-ready` once
218
+ * nothing else is open, even when a review is also pending.
219
+ */
220
+ export function babysitAction(
221
+ next: NextAction,
222
+ reason: string | null,
223
+ draft: boolean,
224
+ ): BabysitAction | null {
225
+ switch (next) {
226
+ case "MERGED":
227
+ return "merged";
228
+ case "CLOSED":
229
+ return null;
230
+ case "RESOLVE_CONFLICTS":
231
+ case "REBASE":
232
+ return "update-branch";
233
+ case "RESOLVE_THREADS":
234
+ case "ADDRESS_REVIEW":
235
+ return "address-threads";
236
+ case "FIX_CI":
237
+ return "fix-ci";
238
+ case "WAITING_CI":
239
+ return "wait";
240
+ case "IN_MERGE_QUEUE":
241
+ return "wait-forge";
242
+ default:
243
+ if (draft) return "mark-ready";
244
+ if (next === "NOT_MERGEABLE" && reason === "mergeability_unknown") return "wait-forge";
245
+ return "ready";
246
+ }
247
+ }
248
+
195
249
  /**
196
250
  * Count head against the base tip the API reported, locally. A pure read:
197
251
  * missing commits are fetched by id (no destination ref, no FETCH_HEAD), so
@@ -363,6 +417,11 @@ export function buildStatusDoc(
363
417
  identity: options.identity ?? null,
364
418
  truncated: status.truncated,
365
419
  next: blockers[0]?.next ?? "READY",
420
+ babysit: babysitAction(
421
+ blockers[0]?.next ?? "READY",
422
+ blockers[0]?.reason ?? null,
423
+ status.draft || blockers.some((blocker) => blocker.next === "MARK_READY"),
424
+ ),
366
425
  });
367
426
  }
368
427
 
package/src/git/ops.ts CHANGED
@@ -27,7 +27,8 @@ import {
27
27
  } from "../core/branch";
28
28
  import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
29
29
  import { pushTargetIsStable } from "../core/pr-create";
30
- import { vcsConfig } from "../core/vcs-config";
30
+ import { recordBranchBase, trackBranchName } from "../core/release-tracks";
31
+ import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
31
32
  import { redactText } from "../forge/redact";
32
33
  import { failure, success, type ForgeResult } from "../forge/types";
33
34
  import { appendObserved, readLedger, type LedgerActor } from "../ledger";
@@ -119,15 +120,51 @@ export type BranchOutcome = {
119
120
  carried: boolean;
120
121
  /** Fallbacks taken (fetch failed, base used from the local copy). */
121
122
  notes: string[];
123
+ /** The release track the base and name came from; null without tracks. */
124
+ releaseTrack: ResolvedReleaseTrack | null;
122
125
  };
123
126
 
124
- export function gitBranch(
125
- cwd: string,
126
- input: { name: string; base?: string | null; carry?: boolean },
127
- ): ForgeResult<BranchOutcome> {
128
- const name = input.name.trim();
127
+ export type BranchInput = {
128
+ /** An explicit branch name; else `kind` + `slug` through the track's naming. */
129
+ name?: string;
130
+ kind?: "feature" | "bugfix" | "hotfix";
131
+ slug?: string;
132
+ base?: string | null;
133
+ carry?: boolean;
134
+ /** An explicit release track (`--track`). */
135
+ track?: string | null;
136
+ };
137
+
138
+ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOutcome> {
129
139
  if (!headSha(cwd) && currentBranch(cwd) === null)
130
140
  return failure("invalid_input", "not inside a git repository with a commit");
141
+ // One resolution: the default target, the base for new branches and the
142
+ // release track (core/release-tracks.ts) all come from vcsConfig. With
143
+ // --base the track is the base's line (it decides the --kind naming);
144
+ // otherwise the checkout's.
145
+ const explicitBase = input.base?.trim() || null;
146
+ const resolved = vcsConfig("resolve", cwd, {
147
+ track: input.track ?? null,
148
+ ...(explicitBase ? { branch: explicitBase } : {}),
149
+ });
150
+ const releaseTrack: ResolvedReleaseTrack | null =
151
+ resolved.ok === false ? null : (resolved.releaseTrack ?? null);
152
+ // An undetermined line blocks whenever the track would decide something:
153
+ // the base (no --base) or the --kind/--slug name.
154
+ if (releaseTrack?.blocking && (!explicitBase || input.name === undefined))
155
+ return failure(
156
+ "blocked",
157
+ String(releaseTrack.blocking),
158
+ "workit git branch … --track <name> # or --base <branch>",
159
+ );
160
+ const name = (
161
+ input.name ??
162
+ (input.kind && input.slug
163
+ ? (trackBranchName(releaseTrack?.track ?? null, input.kind, input.slug) ??
164
+ `${input.kind}/${input.slug}`)
165
+ : "")
166
+ ).trim();
167
+ if (!name) return failure("invalid_input", "missing branch name");
131
168
  if (!validRefName(cwd, name))
132
169
  return failure("invalid_input", `invalid branch name ${JSON.stringify(name)}`);
133
170
  const policy = validateBranchNameFor(cwd, name);
@@ -135,28 +172,36 @@ export function gitBranch(
135
172
  if (resolveRef(cwd, `refs/heads/${name}`))
136
173
  return failure("failed", `branch_exists: ${name} already exists`, `git switch ${name}`);
137
174
 
138
- let base = input.base?.trim() || null;
175
+ let base = explicitBase;
139
176
  let defaultTarget: string | null = null;
140
- const resolved = vcsConfig("resolve", cwd);
141
- if (resolved.ok === false && !base)
177
+ let defaultBase: string | null = null;
178
+ // An explicit --track that does not resolve is never ignored, even with --base.
179
+ if (resolved.ok === false && (!base || input.track))
142
180
  return failure(
143
181
  "blocked",
144
182
  String(resolved.error),
145
183
  "fix ~/.config/workit/vcs.json or pass --base <branch>",
146
184
  );
147
- if (resolved.ok !== false) defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
148
- base ??= defaultTarget;
185
+ if (resolved.ok !== false) {
186
+ defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
187
+ defaultBase = String(resolved.baseBranch ?? "") || defaultTarget;
188
+ }
189
+ base ??= defaultBase;
149
190
  if (!base)
150
191
  return failure("invalid_input", "no default target branch is configured; pass --base <branch>");
151
192
  if (base.startsWith("-")) return failure("invalid_input", "--base must not start with -");
152
193
 
153
- const notes: string[] = [];
194
+ const notes: string[] = [...(releaseTrack?.warnings ?? [])];
154
195
  const remotes = remoteNames(cwd);
155
196
  const remote = remotes.includes("origin") ? "origin" : pushRemoteName(cwd);
156
197
  const localTip = resolveRef(cwd, `refs/heads/${base}`);
157
198
  // A local, unprotected branch that is not the default target is a stack
158
199
  // parent: its local tip is the truth (it may not be pushed yet).
159
- const stackParent = localTip !== null && base !== defaultTarget && !isProtectedTarget(cwd, base);
200
+ const stackParent =
201
+ localTip !== null &&
202
+ base !== defaultTarget &&
203
+ base !== defaultBase &&
204
+ !isProtectedTarget(cwd, base);
160
205
  let baseRef: string | null = null;
161
206
  if (stackParent) baseRef = `refs/heads/${base}`;
162
207
  else if (remote && validRefName(cwd, base)) {
@@ -194,6 +239,8 @@ export function gitBranch(
194
239
  const previous = currentBranch(cwd);
195
240
  const switched = git(cwd, ["switch", "--no-track", "-c", name, baseSha]);
196
241
  if (!switched.ok) return failure("failed", `git switch -c ${name} failed: ${gitError(switched)}`);
242
+ // The cheapest track signal later (release-tracks.ts): where this branch began.
243
+ recordBranchBase(cwd, name, base);
197
244
  return success({
198
245
  branch: name,
199
246
  previous,
@@ -202,6 +249,7 @@ export function gitBranch(
202
249
  baseSha,
203
250
  carried: dirt !== "clean",
204
251
  notes,
252
+ releaseTrack,
205
253
  });
206
254
  }
207
255