@brainervirus/workit-core 8.12.2 → 8.13.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/git/ops.ts CHANGED
@@ -37,11 +37,16 @@ import {
37
37
  type BranchKind,
38
38
  type FlowRoute,
39
39
  } from "../core/branch";
40
+ import { releaseTagPrefixOf } from "../core/branch-policy";
40
41
  import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
41
42
  import { pushTargetIsStable } from "../core/pr-create";
42
- import { recordBranchBase, trackBranchName } from "../core/release-tracks";
43
+ import { readReleaseTracks, recordBranchBase, trackBranchName } from "../core/release-tracks";
43
44
  import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
44
- import { workspaceTrailers } from "../core/workspaces";
45
+ import {
46
+ resolveRuntimeReleaseTracks,
47
+ resolveRuntimeWorkspacePolicy,
48
+ workspaceTrailers,
49
+ } from "../core/workspaces";
45
50
  import { redactText } from "../forge/redact";
46
51
  import { failure, success, type ForgeResult } from "../forge/types";
47
52
  import { appendObserved, readLedger, type LedgerActor } from "../ledger";
@@ -71,7 +76,7 @@ type Run = {
71
76
  stderr: string;
72
77
  };
73
78
 
74
- const git = (
79
+ export const git = (
75
80
  cwd: string,
76
81
  args: string[],
77
82
  options: { timeoutMs?: number; env?: NodeJS.ProcessEnv; input?: string } = {},
@@ -97,7 +102,7 @@ const git = (
97
102
  };
98
103
 
99
104
  /** The last meaningful lines of git's stderr, redacted, for an error message. */
100
- const gitError = (run: { stderr: string; stdout?: string }, max = 3): string => {
105
+ export const gitError = (run: { stderr: string; stdout?: string }, max = 3): string => {
101
106
  const lines = redactText(`${run.stderr}\n${run.stdout ?? ""}`)
102
107
  .split(/\r?\n/u)
103
108
  .map((value) => value.trim())
@@ -147,7 +152,7 @@ type InProgress = {
147
152
  };
148
153
 
149
154
  /** The multi-step git operation the checkout is in the middle of, if any. */
150
- function operationInProgress(cwd: string): InProgress | null {
155
+ export function operationInProgress(cwd: string): InProgress | null {
151
156
  const markers: [string, Operation][] = [
152
157
  ["rebase-merge", "rebase"],
153
158
  ["rebase-apply", "rebase"],
@@ -186,7 +191,7 @@ function operationInProgress(cwd: string): InProgress | null {
186
191
  }
187
192
 
188
193
  /** A refusal that names the operation in progress and how to finish or leave it. */
189
- function inProgressFailure<T>(operation: Operation, verb: string): ForgeResult<T> {
194
+ export function inProgressFailure<T>(operation: Operation, verb: string): ForgeResult<T> {
190
195
  const command = operation === "am" ? "git am" : `git ${operation}`;
191
196
  return failure(
192
197
  "blocked",
@@ -1130,31 +1135,116 @@ export type ReleaseRef = {
1130
1135
  ref: string;
1131
1136
  /** The commit it points at (a tag peeled). */
1132
1137
  sha: string;
1138
+ /** A tag's line production branch: the tag's commit must be on it. */
1139
+ production?: string | null;
1133
1140
  };
1134
1141
 
1135
1142
  export type ReleasePushPlan = Omit<PushPlan, "branch" | "sha"> & {
1136
1143
  refs: ReleaseRef[];
1137
- /** The branch a pushed tag's commit must already be on (or a branch in this push). */
1144
+ /** The workspace's production branch (git-flow production, else the default target). */
1138
1145
  production: string | null;
1139
1146
  };
1140
1147
 
1141
1148
  /**
1142
- * The release tag prefix: git-flow's own `gitflow.prefix.versiontag` when the
1143
- * repository sets it (an empty value means bare `4.1.0` tags), else `v`.
1149
+ * One release line: the branches a release or hotfix finishes onto. The
1150
+ * workspace's own (git-flow production and develop, else the trunk) or a
1151
+ * release track's.
1144
1152
  */
1145
- const releaseTagPrefix = (cwd: string): string => {
1146
- const configured = git(cwd, ["config", "--get", "gitflow.prefix.versiontag"]);
1147
- return configured.ok ? configured.stdout.trim() : "v";
1153
+ export type ReleaseLine = {
1154
+ /** The release track's name; null for the workspace's own branches. */
1155
+ track: string | null;
1156
+ production: string;
1157
+ integration: string;
1158
+ /** Where a finished release merges back (git-flow: develop). */
1159
+ mergeBack: string[];
1160
+ /** The release tag prefix: the track's tagNamespace, then the workspace prefix. */
1161
+ tagPrefix: string;
1162
+ /** git-flow merges a release locally; elsewhere the trunk moves only through a PR. */
1163
+ gitflow: boolean;
1164
+ /** A track's branch-name templates (`release/nun-{version}`). */
1165
+ naming: { release?: string; hotfix?: string };
1148
1166
  };
1149
1167
 
1150
- /** A release tag: the prefix and a digit (`v4.1.0`, `v2`, or `4.1.0` with an empty prefix). */
1151
- const isReleaseTag = (name: string, prefix: string): boolean =>
1152
- name.startsWith(prefix) && /^\d/u.test(name.slice(prefix.length));
1168
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
1169
+ typeof value === "object" && value !== null && !Array.isArray(value);
1170
+
1171
+ /**
1172
+ * The workspace's release lines: its own first, then one per release track
1173
+ * (core/release-tracks.ts reads them leniently). The tag prefix is the
1174
+ * branch policy's (releaseTagPrefixOf); a track adds its `tagNamespace`. A
1175
+ * track's `strategy`, else the workspace preset, decides whether it is git-flow.
1176
+ */
1177
+ export function releaseLinesOf(cwd: string): ReleaseLine[] {
1178
+ const { preset, branches, production } = releaseBranchesOf(cwd);
1179
+ let policyPrefix: unknown;
1180
+ try {
1181
+ const selected = resolveRuntimeWorkspacePolicy(
1182
+ cwd,
1183
+ "branch",
1184
+ process.env.WORKFLOW_PROFILE?.trim() || undefined,
1185
+ ).policy as Record<string, unknown> | undefined;
1186
+ policyPrefix = selected?.releaseTagPrefix;
1187
+ } catch {
1188
+ policyPrefix = undefined;
1189
+ }
1190
+ const prefix = releaseTagPrefixOf(cwd, policyPrefix);
1191
+ const trunk = production ?? "main";
1192
+ const lines: ReleaseLine[] = [
1193
+ {
1194
+ track: null,
1195
+ production: branches[0] ?? trunk,
1196
+ integration: branches[1] ?? trunk,
1197
+ mergeBack: branches[1] ? [branches[1]] : [],
1198
+ tagPrefix: prefix,
1199
+ gitflow: branches.length > 0,
1200
+ naming: {},
1201
+ },
1202
+ ];
1203
+ let raw: unknown;
1204
+ try {
1205
+ raw = resolveRuntimeReleaseTracks(cwd)?.releaseTracks;
1206
+ } catch {
1207
+ return lines;
1208
+ }
1209
+ for (const track of readReleaseTracks(raw).tracks) {
1210
+ const value = isRecord(raw) ? raw[track.name] : undefined;
1211
+ const entry: Record<string, unknown> = isRecord(value) ? value : {};
1212
+ const strategy = typeof entry.strategy === "string" ? entry.strategy : preset;
1213
+ const namespace = typeof entry.tagNamespace === "string" ? entry.tagNamespace.trim() : "";
1214
+ lines.push({
1215
+ track: track.name,
1216
+ production: track.productionBranch,
1217
+ integration: track.integrationBranch,
1218
+ // git-flow always merges a release back into the integration branch.
1219
+ mergeBack: track.mergeBackBranches.length
1220
+ ? track.mergeBackBranches
1221
+ : [track.integrationBranch],
1222
+ tagPrefix: `${namespace}${prefix}`,
1223
+ gitflow: strategy === "gitflow",
1224
+ naming: { release: track.naming.release, hotfix: track.naming.hotfix },
1225
+ });
1226
+ }
1227
+ return lines;
1228
+ }
1229
+
1230
+ /**
1231
+ * The line a release tag belongs to: its prefix and a digit (`v4.1.0`,
1232
+ * `1.4.0` with an empty prefix, `nun/v2`); the longest matching prefix wins.
1233
+ * Null for any other tag.
1234
+ */
1235
+ const lineOfTag = (tag: string, lines: readonly ReleaseLine[]): ReleaseLine | null =>
1236
+ lines
1237
+ .filter(
1238
+ (line) => tag.startsWith(line.tagPrefix) && /^\d/u.test(tag.slice(line.tagPrefix.length)),
1239
+ )
1240
+ .toSorted((a, b) => b.tagPrefix.length - a.tagPrefix.length)[0] ?? null;
1153
1241
 
1154
1242
  /**
1155
1243
  * What `workit git push --release` may push: the git-flow production and
1156
- * develop branches and release tags (`v*`, or git-flow's configured prefix), nothing else, each by its local ref. Everything else stays on the feature-branch
1157
- * path (`workit git push`, `workit pr create`).
1244
+ * develop branches (and a git-flow release track's production, integration
1245
+ * and merge-back branches) and release tags (the policy's tag prefix and a
1246
+ * digit), nothing else, each by its local ref. Everything else stays on the
1247
+ * feature-branch path (`workit git push`, `workit pr create`).
1158
1248
  */
1159
1249
  export function releasePushPreflight(
1160
1250
  cwd: string,
@@ -1164,7 +1254,16 @@ export function releasePushPreflight(
1164
1254
  if (progress) return inProgressFailure(progress.operation, "push");
1165
1255
  const wanted = [...new Set(names)];
1166
1256
  if (!wanted.length) return failure("invalid_input", "name the release refs to push");
1167
- const { preset, branches, production } = releaseBranchesOf(cwd);
1257
+ const { preset, production } = releaseBranchesOf(cwd);
1258
+ const lines = releaseLinesOf(cwd);
1259
+ const tracks = lines.filter((line) => line.track !== null && line.gitflow);
1260
+ const branches = [
1261
+ ...new Set([
1262
+ ...releaseBranchesOf(cwd).branches,
1263
+ ...tracks.flatMap((line) => [line.production, line.integration, ...line.mergeBack]),
1264
+ ]),
1265
+ ];
1266
+ const tagShape = `${lines[0].tagPrefix}<version>`;
1168
1267
  const refs: ReleaseRef[] = [];
1169
1268
  for (const raw of wanted) {
1170
1269
  if (raw.startsWith("-") || raw.includes(":"))
@@ -1182,14 +1281,20 @@ export function releasePushPreflight(
1182
1281
  `workit git push --release refs/heads/${raw} # or refs/tags/${raw}`,
1183
1282
  );
1184
1283
  if (tag) {
1185
- const prefix = releaseTagPrefix(cwd);
1186
- if (!isReleaseTag(tagName, prefix))
1284
+ const line = lineOfTag(tagName, lines);
1285
+ if (!line)
1187
1286
  return failure(
1188
1287
  "blocked",
1189
- `release_push_refused: tag ${tagName} is not a release tag (${prefix}<version>, from ${prefix === "v" ? "the default" : "gitflow.prefix.versiontag"})`,
1190
- `git tag -a ${prefix}<version> -m <message> # then: workit git push --release ${prefix}<version>`,
1288
+ `release_push_refused: tag ${tagName} is not a release tag (${tagShape})`,
1289
+ `git tag -a ${tagShape} -m <message> # then: workit git push --release ${tagShape}`,
1191
1290
  );
1192
- refs.push({ kind: "tag", name: tagName, ref: `refs/tags/${tagName}`, sha: tag });
1291
+ refs.push({
1292
+ kind: "tag",
1293
+ name: tagName,
1294
+ ref: `refs/tags/${tagName}`,
1295
+ sha: tag,
1296
+ production: line.track === null ? production : line.production,
1297
+ });
1193
1298
  continue;
1194
1299
  }
1195
1300
  if (!branch) return failure("not_found", `${raw} is not a local branch or tag`);
@@ -1198,10 +1303,10 @@ export function releasePushPreflight(
1198
1303
  "blocked",
1199
1304
  branches.length
1200
1305
  ? `release_push_refused: ${branchName} is not a git-flow release branch (${branches.join(", ")})`
1201
- : `release_push_refused: the ${preset} policy lands ${branchName} only through a reviewed PR; workit git push --release pushes v<version> tags there, not branches`,
1306
+ : `release_push_refused: the ${preset} policy lands ${branchName} only through a reviewed PR; workit git push --release pushes ${tagShape} tags there, not branches`,
1202
1307
  branches.length
1203
1308
  ? "workit git push # a feature, bugfix, release or hotfix branch, then: workit pr create"
1204
- : `workit pr create --base ${branchName} # then tag the merged commit and: workit git push --release v<version>`,
1309
+ : `workit pr create --base ${branchName} # then tag the merged commit and: workit git push --release ${tagShape}`,
1205
1310
  );
1206
1311
  refs.push({ kind: "branch", name: branchName, ref: `refs/heads/${branchName}`, sha: branch });
1207
1312
  }
@@ -1226,7 +1331,14 @@ export function releasePushPreflight(
1226
1331
  `push_target_rewritten: the push URL of "${remote}" is rewritten by a url.*.insteadOf rule, so the destination is not the one checked`,
1227
1332
  "git config --get-regexp '^url\\.' # remove the rewrite for this remote",
1228
1333
  );
1229
- return success({ refs, remote, url: redactRemote(raw), rawUrl: raw, local, production });
1334
+ return success({
1335
+ refs,
1336
+ remote,
1337
+ url: redactRemote(raw),
1338
+ rawUrl: raw,
1339
+ local,
1340
+ production,
1341
+ });
1230
1342
  }
1231
1343
 
1232
1344
  export type ReleasePushOutcome = {
@@ -1271,29 +1383,32 @@ export function executeReleasePush(
1271
1383
  `git fetch ${plan.remote} ${ref.name} && git switch ${ref.name} && git merge --ff-only ${plan.remote}/${ref.name} # then: workit git push --release ${ref.name}`,
1272
1384
  );
1273
1385
  }
1274
- // A release tag marks a commit the production branch already has (or a
1275
- // branch this push moves): never an unmerged feature commit.
1386
+ // A release tag marks a commit its line's production branch already has
1387
+ // (on the remote, or as this push moves it): never an unmerged feature
1388
+ // commit, and never another line's production (a nun/ tag on master).
1276
1389
  const tags = plan.refs.filter((ref) => ref.kind === "tag");
1277
- if (tags.length) {
1278
- let productionTip: string | null = null;
1279
- if (plan.production) {
1280
- const tip = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.production}`);
1390
+ const contains = (tip: string, sha: string) =>
1391
+ git(cwd, ["merge-base", "--is-ancestor", sha, tip]).ok;
1392
+ for (const tag of tags) {
1393
+ const production = tag.production ?? plan.production;
1394
+ let remoteTip: string | null = null;
1395
+ if (production) {
1396
+ const tip = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${production}`);
1281
1397
  if (!tip.ok) return failure(tip.code, tip.error);
1282
- productionTip = tip.sha;
1283
- }
1284
- const contains = (tip: string, sha: string) =>
1285
- git(cwd, ["merge-base", "--is-ancestor", sha, tip]).ok;
1286
- for (const tag of tags) {
1287
- const released =
1288
- (productionTip !== null && contains(productionTip, tag.sha)) ||
1289
- plan.refs.some((ref) => ref.kind === "branch" && contains(ref.sha, tag.sha));
1290
- if (!released)
1291
- return failure(
1292
- "blocked",
1293
- `tag_unreleased: ${tag.name} (${tag.sha.slice(0, 12)}) is not on ${plan.remote}/${plan.production ?? "the production branch"}${plan.refs.some((ref) => ref.kind === "branch") ? " or a branch in this push" : ""}; a release tag marks a commit that already landed`,
1294
- `land it through a PR first (workit pr create), git fetch ${plan.remote}, then tag the merged commit`,
1295
- );
1398
+ remoteTip = tip.sha;
1296
1399
  }
1400
+ const pushed = plan.refs.find(
1401
+ (ref) => ref.kind === "branch" && ref.name.toLowerCase() === production?.toLowerCase(),
1402
+ );
1403
+ const released =
1404
+ (remoteTip !== null && contains(remoteTip, tag.sha)) ||
1405
+ (pushed !== undefined && contains(pushed.sha, tag.sha));
1406
+ if (!released)
1407
+ return failure(
1408
+ "blocked",
1409
+ `tag_unreleased: ${tag.name} (${tag.sha.slice(0, 12)}) is not on ${plan.remote}/${production ?? "the production branch"}${pushed ? ` or the ${production} in this push` : ""}; a release tag marks a commit that already landed on its line's production branch`,
1410
+ `land it through a PR first (workit pr create), git fetch ${plan.remote}, then tag the merged commit`,
1411
+ );
1297
1412
  }
1298
1413
  const pending = plan.refs.filter((ref) => previous.get(ref.ref) !== ref.sha);
1299
1414
  if (pending.length) {