@brainervirus/workit-core 8.10.0 → 8.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.10.0",
3
+ "version": "8.11.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
package/src/autonomy.ts CHANGED
@@ -13,7 +13,10 @@
13
13
  // agent from raising its own ceiling by accident, not an adversarial one —
14
14
  // a process that drives a pseudo-terminal or edits the file directly can
15
15
  // still defeat it; the host's permission prompt is the hard boundary.
16
- // - `release` has no consuming verb yet; it is reserved, not enforced.
16
+ // - `release` gates `workit git push --release`: pushing the git-flow
17
+ // production and develop branches (fast-forward only) and `v*` tags whose
18
+ // commit already landed (a tag never moves). Outside git-flow it pushes
19
+ // tags only. It needs `push` too.
17
20
  // - Defaults (D4) when a kind is absent: push, pr and rerun are allowed;
18
21
  // merge and release need an explicit grant. The default ceiling is a stack
19
22
  // opened, CI green and independently verified: "verified, ready".
@@ -25,7 +28,8 @@
25
28
  // - Explicitly configured grants require an account (design §2.0 Identity):
26
29
  // a workspace with grants but no `vcs.account` cannot push, open or merge
27
30
  // through a forge, so a personal grant never rides a work credential.
28
- // - Protected-branch pushes stay denied by the git verbs, and the host
31
+ // - Protected-branch pushes stay denied by the git verbs (except the git-flow
32
+ // release push above), and the host
29
33
  // permission prompt applies in addition: a host deny always wins.
30
34
  import { copyFileSync, existsSync } from "node:fs";
31
35
  import os from "node:os";
@@ -13,7 +13,7 @@ const PREFIXES = {
13
13
  * Local branch names plus origin's remote-tracking ones (`origin/develop`
14
14
  * counts as `develop`; a fork or mirror remote's branches do not).
15
15
  */
16
- const branchNames = (workspaceRoot: string): Set<string> => {
16
+ export const branchNames = (workspaceRoot: string): Set<string> => {
17
17
  const r = spawnSync(
18
18
  "git",
19
19
  ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes/origin"],
@@ -32,6 +32,13 @@ const branchNames = (workspaceRoot: string): Set<string> => {
32
32
  );
33
33
  };
34
34
 
35
+ /**
36
+ * The git-flow production branch: main when it exists (locally or on
37
+ * origin), else master, else main.
38
+ */
39
+ export const productionBranchOf = (names: ReadonlySet<string>): string =>
40
+ names.has("main") ? "main" : names.has("master") ? "master" : "main";
41
+
35
42
  export const detectBranchPolicy = (workspaceRoot: string) => {
36
43
  const names = branchNames(workspaceRoot);
37
44
  const develop = names.has("develop");
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import path from "node:path";
5
- import { repoBranchPreset } from "./branch-policy";
5
+ import { branchNames, productionBranchOf, repoBranchPreset } from "./branch-policy";
6
6
  import { matchCommitFlavor } from "./commit-flavors";
7
7
  import { gitContext } from "./git";
8
8
  import {
@@ -142,14 +142,105 @@ export type CommitMessagePolicyCheck =
142
142
  error: string;
143
143
  };
144
144
 
145
+ /** Prefixes agents reach for, mapped to the git-flow kind that means the same. */
146
+ const KIND_SYNONYMS: Readonly<Record<string, string>> = {
147
+ fix: "bugfix",
148
+ fixes: "bugfix",
149
+ bug: "bugfix",
150
+ bugs: "bugfix",
151
+ bugfixes: "bugfix",
152
+ feat: "feature",
153
+ features: "feature",
154
+ chore: "feature",
155
+ docs: "feature",
156
+ doc: "feature",
157
+ refactor: "feature",
158
+ test: "feature",
159
+ tests: "feature",
160
+ ci: "feature",
161
+ build: "feature",
162
+ perf: "feature",
163
+ style: "feature",
164
+ deps: "feature",
165
+ task: "feature",
166
+ enhancement: "feature",
167
+ hot: "hotfix",
168
+ hotfixes: "hotfix",
169
+ releases: "release",
170
+ rel: "release",
171
+ };
172
+
173
+ /** The `<kind>/` prefixes an allowed-pattern list names (`feature/*` -> feature). */
174
+ export const allowedKinds = (allowed: readonly RegExp[]): string[] => [
175
+ ...new Set(
176
+ allowed
177
+ // RegExp#source escapes the slash: `^feature\/.*$`.
178
+ .map((pattern) => /^\^([\w.-]+)\\?\/\.\*\$$/u.exec(pattern.source)?.[1]?.toLowerCase())
179
+ .filter((kind): kind is string => Boolean(kind)),
180
+ ),
181
+ ];
182
+
183
+ const editDistance = (a: string, b: string): number => {
184
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
185
+ for (let i = 1; i <= a.length; i++) {
186
+ let previous = row[0];
187
+ row[0] = i;
188
+ for (let j = 1; j <= b.length; j++) {
189
+ const saved = row[j];
190
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
191
+ previous = saved;
192
+ }
193
+ }
194
+ return row[b.length];
195
+ };
196
+
197
+ /**
198
+ * The allowed name closest to `name`: its prefix mapped through the usual
199
+ * synonyms (fix/ -> bugfix/, chore/ -> feature/), else the kind within two
200
+ * edits, else feature/ (or the first kind); the rest of the name is kept.
201
+ * Null when the policy names no kinds.
202
+ */
203
+ export const suggestBranchName = (name: string, kinds: readonly string[]): string | null => {
204
+ if (!kinds.length) return null;
205
+ const slash = name.indexOf("/");
206
+ const prefix = slash > 0 ? name.slice(0, slash).toLowerCase() : "";
207
+ const rest = (slash > 0 ? name.slice(slash + 1) : name) || "name";
208
+ const synonym = KIND_SYNONYMS[prefix];
209
+ const near = kinds
210
+ .map((kind) => ({ kind, distance: editDistance(prefix, kind) }))
211
+ .filter((entry) => prefix && entry.distance <= 2)
212
+ .toSorted((a, b) => a.distance - b.distance)[0]?.kind;
213
+ const kind =
214
+ (synonym && kinds.includes(synonym) ? synonym : null) ??
215
+ near ??
216
+ (kinds.includes("feature") ? "feature" : kinds[0]);
217
+ return `${kind}/${rest}`;
218
+ };
219
+
220
+ const kindList = (kinds: readonly string[]) => kinds.map((kind) => `${kind}/`).join(", ");
221
+
222
+ /** ` (fix/ is bugfix/, chore/ is feature/)`, naming only the kinds this policy allows. */
223
+ const aliasNote = (kinds: readonly string[]): string => {
224
+ const notes = [
225
+ ["fix", "bugfix"],
226
+ ["chore", "feature"],
227
+ ]
228
+ .filter(([, kind]) => kinds.includes(kind))
229
+ .map(([alias, kind]) => `${alias}/ is ${kind}/`);
230
+ return notes.length ? ` (${notes.join(", ")})` : "";
231
+ };
232
+
145
233
  export const validateBranchNamePolicy = (
146
234
  name: string,
147
235
  policy: BranchNamePolicy,
148
236
  source: string,
149
237
  ): BranchNamePolicyCheck => {
150
238
  const allowed = policy.allowed.map((pattern) => `/${pattern.source}/${pattern.flags}`);
239
+ const kinds = allowedKinds(policy.allowed);
151
240
  if (policy.protected.has(name.toLowerCase())) {
152
- const correction = `choose a non-protected branch matching one of: ${allowed.join(", ")}`;
241
+ const correction = kinds.length
242
+ ? `choose a non-protected branch of an allowed kind (${kindList(kinds)}), e.g. ${kinds[0]}/<slug>: workit git branch --kind ${kinds[0]} --slug <slug>`
243
+ : `choose a non-protected branch matching one of: ${allowed.join(", ")}`;
153
244
  return {
154
245
  ok: false,
155
246
  rule: "protected_ref",
@@ -160,7 +251,11 @@ export const validateBranchNamePolicy = (
160
251
  };
161
252
  }
162
253
  if (!policy.allowed.some((pattern) => pattern.test(name))) {
163
- const correction = `choose a branch matching one of: ${allowed.join(", ")}`;
254
+ const suggestion = suggestBranchName(name, kinds);
255
+ const correction =
256
+ suggestion && policy.allowed.some((pattern) => pattern.test(suggestion))
257
+ ? `use ${JSON.stringify(suggestion)}; the allowed kinds are ${kindList(kinds)} (workit git branch ${suggestion})`
258
+ : `choose a branch matching one of: ${allowed.join(", ")}`;
164
259
  return {
165
260
  ok: false,
166
261
  rule: "allowed_pattern",
@@ -272,6 +367,178 @@ export const resolveBranchPolicyFor = (workspaceRoot: string, profileName?: stri
272
367
  export const resolveCommitPolicyFor = (workspaceRoot: string, profileName?: string) =>
273
368
  effectiveWorkspacePolicy(workspaceRoot, "commit", profileName).commitPolicy;
274
369
 
370
+ // ---------------------------------------------------------------------------
371
+ // git-flow routes: where each branch kind starts and lands
372
+
373
+ export const BRANCH_KINDS = ["feature", "bugfix", "release", "hotfix"] as const;
374
+ export type BranchKind = (typeof BRANCH_KINDS)[number];
375
+
376
+ /** The git-flow kind a branch name's prefix names (`release/4.1.0` -> release), or null. */
377
+ export const branchKindOf = (name: string | null | undefined): BranchKind | null => {
378
+ const prefix = name?.split("/", 1)[0]?.toLowerCase() ?? "";
379
+ return name?.includes("/") && (BRANCH_KINDS as readonly string[]).includes(prefix)
380
+ ? (prefix as BranchKind)
381
+ : null;
382
+ };
383
+
384
+ export type GitFlow = {
385
+ /** The integration branch: the workspace's `developBranch`, else develop. */
386
+ develop: string;
387
+ /** The production branch: main, else master. */
388
+ production: string;
389
+ /** The develop branch exists locally or on origin. */
390
+ developExists: boolean;
391
+ };
392
+
393
+ export type FlowRoute = {
394
+ kind: BranchKind;
395
+ /** Where a new branch of this kind starts. */
396
+ base: string;
397
+ /** Where its PR lands. */
398
+ target: string;
399
+ /** The branch that must receive the landed work afterwards (release/hotfix -> develop). */
400
+ mergeBack: string | null;
401
+ };
402
+
403
+ /**
404
+ * The repository's git-flow branches when the resolved branch policy is the
405
+ * gitflow preset; null for github-flow, trunk-based or custom (they route
406
+ * every kind to the default target). `integration` is the resolved default
407
+ * target: used as develop when the workspace names no developBranch and it
408
+ * is not the production branch.
409
+ */
410
+ export const gitFlowOf = (root: string, integration?: string | null): GitFlow | null => {
411
+ try {
412
+ if (resolveBranchPolicyFor(root).preset !== "gitflow") return null;
413
+ const selected = resolveRuntimeWorkspacePolicy(
414
+ root,
415
+ "branch",
416
+ process.env.WORKFLOW_PROFILE?.trim() || undefined,
417
+ ).policy as WorkspaceBranchPolicy | undefined;
418
+ const names = branchNames(root);
419
+ const production = productionBranchOf(names);
420
+ const develop =
421
+ selected?.developBranch?.trim() ||
422
+ (integration && integration.toLowerCase() !== production ? integration : "develop");
423
+ return { develop, production, developExists: names.has(develop) };
424
+ } catch {
425
+ return null;
426
+ }
427
+ };
428
+
429
+ /** git-flow: feature, bugfix and release start from develop; hotfix from production. */
430
+ export const flowRoute = (flow: GitFlow, kind: BranchKind): FlowRoute =>
431
+ kind === "hotfix"
432
+ ? { kind, base: flow.production, target: flow.production, mergeBack: flow.develop }
433
+ : kind === "release"
434
+ ? { kind, base: flow.develop, target: flow.production, mergeBack: flow.develop }
435
+ : { kind, base: flow.develop, target: flow.develop, mergeBack: null };
436
+
437
+ /**
438
+ * What `workit git push --release` may push, per preset: git-flow its
439
+ * production and develop branches; github-flow, trunk-based and custom no
440
+ * branch (only tags). `production` is the branch a release tag's commit must
441
+ * already be on (git-flow production, else the default target). Empty when
442
+ * the policy cannot be read.
443
+ */
444
+ export const releaseBranchesOf = (
445
+ cwd: string,
446
+ ): { preset: string; branches: string[]; production: string | null } => {
447
+ try {
448
+ const resolved = resolveBranchPolicyFor(cwd);
449
+ const flow = resolved.preset === "gitflow" ? gitFlowOf(cwd) : null;
450
+ // Outside git-flow a protected trunk only moves through a reviewed PR:
451
+ // --release pushes tags there, nothing else.
452
+ return {
453
+ preset: resolved.preset,
454
+ branches: flow ? [flow.production, flow.develop] : [],
455
+ production: flow ? flow.production : resolved.defaultTargetBranch,
456
+ };
457
+ } catch {
458
+ return { preset: "unknown", branches: [], production: null };
459
+ }
460
+ };
461
+
462
+ /** A branch `workit git push --release` may push here. */
463
+ export const releaseBranch = (cwd: string, branch: string): boolean =>
464
+ releaseBranchesOf(cwd).branches.some((name) => name.toLowerCase() === branch.toLowerCase());
465
+
466
+ /** `origin/develop`, `refs/remotes/origin/develop`, `refs/heads/develop` -> develop. */
467
+ export const shortBranch = (ref: string): string =>
468
+ ref.replace(/^refs\/heads\//u, "").replace(/^(?:refs\/remotes\/)?origin\//u, "");
469
+
470
+ /**
471
+ * A git-flow mismatch: an explicit base or PR target that is the other
472
+ * long-lived branch than the kind's route (a feature from main, a hotfix
473
+ * from develop). Only once develop exists, so a main-only repository on the
474
+ * gitflow preset keeps working. Null when it fits.
475
+ */
476
+ export const flowMismatch = (
477
+ flow: GitFlow | null,
478
+ kind: BranchKind | null,
479
+ ref: string,
480
+ role: "base" | "target",
481
+ ): string | null => {
482
+ if (!flow || !kind || !flow.developExists) return null;
483
+ const route = flowRoute(flow, kind);
484
+ const expected = role === "base" ? route.base : route.target;
485
+ const given = shortBranch(ref).toLowerCase();
486
+ const longLived = [flow.develop, flow.production].map((name) => name.toLowerCase());
487
+ // A release or hotfix PR into develop is the git-flow back-merge.
488
+ const backMerge = role === "target" && route.mergeBack?.toLowerCase() === given;
489
+ if (!longLived.includes(given) || given === expected.toLowerCase() || backMerge) return null;
490
+ return role === "base"
491
+ ? `gitflow_base: git-flow starts ${kind}/* from ${expected}, not ${shortBranch(ref)}`
492
+ : `gitflow_target: git-flow lands ${kind}/* on ${expected}, not ${shortBranch(ref)}`;
493
+ };
494
+
495
+ /**
496
+ * One line for an agent about to create a branch at `cwd`: the allowed kinds
497
+ * and where each starts. Plain text (no `workit git` command), so it fits an
498
+ * agent that runs git itself. Null when the policy cannot be read.
499
+ */
500
+ export const branchGuidance = (cwd: string): string | null => {
501
+ try {
502
+ const patterns = branchPolicySnapshotFor(cwd).namePolicy.allowed;
503
+ const kinds = allowedKinds(patterns);
504
+ const kindShaped = new Set(kinds.map((kind) => branchGlobPattern(`${kind}/*`).source));
505
+ const anyName = patterns.some((pattern) => pattern.source === "^.*$");
506
+ const others = patterns
507
+ .filter((pattern) => !kindShaped.has(pattern.source))
508
+ .map((pattern) => globOf(pattern));
509
+ const allowed = anyName
510
+ ? "Any non-protected branch name is allowed here."
511
+ : `${kinds.length ? `Allowed branch kinds here: ${kindList(kinds)}${aliasNote(kinds)}` : "Allowed branch names here:"}${others.length ? `${kinds.length ? "; also" : ""} names matching ${others.join(", ")}` : ""}; anything else is denied.`;
512
+ const resolved = vcsConfig("resolve", cwd);
513
+ const integration =
514
+ resolved.ok === false ? null : String(resolved.defaultTargetBranch ?? "") || null;
515
+ const flow = resolved.releaseTrack?.track ? null : gitFlowOf(cwd, integration);
516
+ if (flow?.developExists)
517
+ return `${allowed} git-flow: feature/, bugfix/ and release/ start from origin/${flow.develop}, hotfix/ from origin/${flow.production}; feature/ and bugfix/ land on ${flow.develop}, release/ and hotfix/ on ${flow.production}.`;
518
+ // Name only a base that exists: a preset default (custom's develop,
519
+ // github-flow's main) may not.
520
+ const names = branchNames(cwd);
521
+ const configured = String(resolved.baseBranch ?? integration ?? "") || null;
522
+ const trunk = productionBranchOf(names);
523
+ const base =
524
+ configured && names.has(configured)
525
+ ? `Branch from origin/${configured}.`
526
+ : names.has(trunk)
527
+ ? `Branch from origin/${trunk}${configured ? ` (the default target ${configured} does not exist here)` : ""}.`
528
+ : "No base branch exists here yet; pass the base explicitly.";
529
+ return `${allowed} ${base}`;
530
+ } catch {
531
+ return null;
532
+ }
533
+ };
534
+
535
+ /** An allowed pattern back as its glob (`^fix-.*$` -> fix-*). */
536
+ const globOf = (pattern: RegExp): string =>
537
+ pattern.source
538
+ .replace(/^\^|\$$/gu, "")
539
+ .replaceAll(".*", "*")
540
+ .replace(/\\(.)/gu, "$1");
541
+
275
542
  const policy = (root: string) => resolveBranchPolicyFor(root);
276
543
  const allowedBranch = (root: string, name: string) =>
277
544
  policy(root).allowed.some((r) => r.test(name));
@@ -1,37 +1,165 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { inWorkitWorkspace, rawInvocations } from "../hooks/raw-git";
5
+ import type { ShellDialect } from "../hooks/shell-words";
1
6
  import { validateBranchNameFor, type BranchNamePolicyCheck } from "./branch";
2
7
 
3
- // ponytail: recognize direct literal Git forms only; expand coverage if users need shell AST support.
4
- export const shellBranchTarget = (command: string): string | null => {
5
- if (
6
- !command.trim() ||
7
- /[\r\n;&|<>`'"$()\\*?{}~!#]/u.test(command) ||
8
- command.includes("[") ||
9
- command.includes("]")
10
- )
11
- return null;
12
-
13
- const args = command.trim().split(/\s+/u);
14
- if (args[0] !== "git") return null;
15
- const option = args[2];
8
+ /** A name a shell cannot settle before running (`$X`, `` `cmd` ``): never judged. */
9
+ const UNKNOWABLE = /[$`*?[\]{}~]/u;
10
+
11
+ const SWITCH_CREATE = new Set(["-c", "--create", "-C", "--force-create", "--orphan"]);
12
+ const CHECKOUT_CREATE = new Set(["-b", "-B", "--orphan"]);
13
+ const WORKTREE_CREATE = new Set(["-b", "-B"]);
14
+ /** `git branch` options that list, delete or configure: no branch is created. */
15
+ const BRANCH_NO_CREATE = new Set(
16
+ ["-d", "-D", "--delete", "-l", "--list", "-a", "--all", "-r", "--remotes", "--show-current"]
17
+ .concat(["-v", "-vv", "--verbose", "--contains", "--no-contains", "--merged", "--no-merged"])
18
+ .concat(["--points-at", "--sort", "--format", "--column", "--no-column", "-u"])
19
+ .concat(["--set-upstream-to", "--unset-upstream", "--edit-description", "-i", "--ignore-case"]),
20
+ );
21
+ /** `git branch -m|-c [<old>] <new>`: a rename or copy creates `<new>`. */
22
+ const BRANCH_RENAME = new Set(["-m", "-M", "--move", "-c", "-C", "--copy"]);
23
+
24
+ /** The value of `flag` in `args` (`-c x`, `-cx`, `--create=x`), or null. */
25
+ const flagValue = (args: readonly string[], flags: ReadonlySet<string>): string | null => {
26
+ for (let index = 1; index < args.length; index++) {
27
+ const arg = args[index];
28
+ if (arg === "--") return null;
29
+ if (flags.has(arg)) return args[index + 1] ?? null;
30
+ const eq = arg.indexOf("=");
31
+ if (arg.startsWith("--") && eq > 0 && flags.has(arg.slice(0, eq))) return arg.slice(eq + 1);
32
+ // An attached short value: `-cchore/x`.
33
+ if (!arg.startsWith("--") && arg.length > 2 && flags.has(arg.slice(0, 2))) return arg.slice(2);
34
+ }
35
+ return null;
36
+ };
37
+
38
+ /** `git worktree add` options that take a value. */
39
+ const WORKTREE_VALUES = new Set(["--reason"]);
40
+
41
+ /**
42
+ * `git worktree add [-b|-B <b>] <path> [<commit-ish>]`: the -b name, or, with
43
+ * only a path (no -b, -B, --detach or commit-ish), the branch git names after
44
+ * the path's last component.
45
+ */
46
+ const worktreeTarget = (args: readonly string[]): string | null => {
47
+ const named = flagValue(args, WORKTREE_CREATE);
48
+ if (named !== null) return named;
49
+ const positionals: string[] = [];
50
+ for (let index = 1; index < args.length; index++) {
51
+ const arg = args[index];
52
+ if (arg === "--detach" || arg === "-d") return null;
53
+ if (WORKTREE_VALUES.has(arg)) index++;
54
+ else if (!arg.startsWith("-")) positionals.push(arg);
55
+ }
56
+ if (positionals.length !== 1) return null;
57
+ const base = positionals[0].split(/[\\/]/u).findLast(Boolean) ?? "";
58
+ return base && base !== "." && base !== ".." ? base : null;
59
+ };
60
+
61
+ /** The branch `git branch …` creates (plain, rename or copy), or null. */
62
+ const branchCommandTarget = (args: readonly string[]): string | null => {
63
+ let rename = false;
64
+ const positionals: string[] = [];
65
+ for (let index = 1; index < args.length; index++) {
66
+ const arg = args[index];
67
+ if (arg === "--") {
68
+ positionals.push(...args.slice(index + 1));
69
+ break;
70
+ }
71
+ const flag = arg.split("=", 1)[0];
72
+ if (BRANCH_NO_CREATE.has(flag)) return null;
73
+ if (BRANCH_RENAME.has(flag)) rename = true;
74
+ else if (!arg.startsWith("-")) positionals.push(arg);
75
+ }
76
+ if (positionals.length < 1 || positionals.length > 2) return null;
77
+ return rename ? (positionals.at(-1) ?? null) : positionals[0];
78
+ };
79
+
80
+ /**
81
+ * The branch one git invocation creates: `switch -c|-C|--create <b>`,
82
+ * `checkout -b|-B <b>`, `branch [-m|-c] <b> [start]` and `worktree add -b|-B
83
+ * <b>`, wherever the option stands. `args` start at the subcommand (after
84
+ * git's global options). Null when it creates none.
85
+ */
86
+ export const gitBranchCreation = (args: readonly string[]): string | null => {
87
+ const [sub, second] = args;
16
88
  const target =
17
- args[1] === "switch" &&
18
- ["-c", "--create", "-C", "--force-create"].includes(option ?? "") &&
19
- (args.length === 4 || args.length === 5)
20
- ? args[3]
21
- : args[1] === "checkout" &&
22
- ["-b", "-B"].includes(option ?? "") &&
23
- (args.length === 4 || args.length === 5)
24
- ? args[3]
25
- : args[1] === "branch" && (args.length === 3 || args.length === 4)
26
- ? args[2]
27
- : null;
28
- return target && !target.startsWith("-") ? target : null;
89
+ sub === "switch"
90
+ ? flagValue(args, SWITCH_CREATE)
91
+ : sub === "checkout"
92
+ ? flagValue(args, CHECKOUT_CREATE)
93
+ : sub === "branch"
94
+ ? branchCommandTarget(args)
95
+ : sub === "worktree" && second === "add"
96
+ ? worktreeTarget(args.slice(1))
97
+ : null;
98
+ return target && !target.startsWith("-") && !UNKNOWABLE.test(target) ? target : null;
99
+ };
100
+
101
+ export type ShellBranchTarget = {
102
+ branch: string;
103
+ /** The directory git runs in, relative to the hook's cwd; undefined when unknowable. */
104
+ dir: string | null | undefined;
105
+ };
106
+
107
+ /**
108
+ * Every branch a shell command creates, through the raw-git tokenizer: `&&`,
109
+ * `;` and `|` chains, `cd`/`pushd` and `git -C`, env prefixes and wrappers,
110
+ * quoted names and `bash -c` scripts.
111
+ */
112
+ export const shellBranchTargets = (
113
+ command: string,
114
+ dialect: ShellDialect = "posix",
115
+ ): ShellBranchTarget[] => {
116
+ if (!/\bgit\b/u.test(command)) return [];
117
+ const found: ShellBranchTarget[] = [];
118
+ for (const invocation of rawInvocations(command, dialect)) {
119
+ if (invocation.tool !== "git") continue;
120
+ const branch = gitBranchCreation(invocation.args);
121
+ if (branch) found.push({ branch, dir: invocation.dir });
122
+ }
123
+ return found;
29
124
  };
30
125
 
126
+ /** The first branch a shell command creates, or null. */
127
+ export const shellBranchTarget = (command: string): string | null =>
128
+ shellBranchTargets(command)[0]?.branch ?? null;
129
+
130
+ /** The branch already exists in `dir`: resetting or checking it out is not a new name. */
131
+ const localBranchExists = (dir: string, branch: string): boolean =>
132
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
133
+ cwd: dir,
134
+ stdio: "ignore",
135
+ timeout: 5_000,
136
+ }).status === 0;
137
+
138
+ /**
139
+ * The branch policy verdict for every new branch a shell command creates,
140
+ * each checked in the directory git runs in (a linked worktree resolves to
141
+ * its main checkout's workspace). A command that runs in the hook's cwd is
142
+ * judged there. One that changes directory is judged only when that
143
+ * directory is known, already exists and is in a Workit workspace: a scratch
144
+ * repository (`cd "$(mktemp -d)"`, `git init x && cd x`, a fresh clone) is
145
+ * not the cwd workspace's business. A name that already exists locally is
146
+ * skipped (`git branch -f legacy HEAD`). Null when no new branch is judged;
147
+ * the first violation otherwise.
148
+ */
31
149
  export const shellBranchPolicyViolation = (
32
150
  root: string,
33
151
  command: string,
152
+ dialect: ShellDialect = "posix",
34
153
  ): BranchNamePolicyCheck | null => {
35
- const target = shellBranchTarget(command);
36
- return target === null ? null : validateBranchNameFor(root, target);
154
+ let judged = false;
155
+ for (const target of shellBranchTargets(command, dialect)) {
156
+ if (target.dir === undefined) continue;
157
+ const dir = target.dir === null ? root : path.resolve(root, target.dir);
158
+ if (target.dir !== null && (!existsSync(dir) || !inWorkitWorkspace(dir))) continue;
159
+ if (localBranchExists(dir, target.branch)) continue;
160
+ judged = true;
161
+ const check = validateBranchNameFor(dir, target.branch);
162
+ if (!check.ok) return check;
163
+ }
164
+ return judged ? { ok: true } : null;
37
165
  };
@@ -27,7 +27,7 @@ const CONTEXTS = `contexts(first: 100, after: $contexts) { pageInfo { hasNextPag
27
27
 
28
28
  export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threads: String, $contexts: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_FIELDS} ${THREADS} commits(last: 1) { nodes { commit { oid statusCheckRollup { state ${CONTEXTS} } } } } } } }`;
29
29
 
30
- export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state headRefName headRefOid headRepositoryOwner { login } } } } }`;
30
+ export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
31
31
 
32
32
  const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
33
33
  const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
@@ -351,6 +351,7 @@ export function createGitHubForge(options: {
351
351
  number: number;
352
352
  url: string;
353
353
  state: string;
354
+ baseRefName?: string;
354
355
  headRefName: string;
355
356
  headRefOid: string | null;
356
357
  headRepositoryOwner: { login: string } | null;
@@ -365,11 +366,21 @@ export function createGitHubForge(options: {
365
366
  const ours = nodes.filter(
366
367
  (node) =>
367
368
  node.headRefName === head &&
369
+ (!match.base || node.baseRefName === undefined || node.baseRefName === match.base) &&
368
370
  ((match.owner !== null &&
369
371
  node.headRepositoryOwner?.login.toLowerCase() === match.owner.toLowerCase()) ||
370
372
  (match.sha !== null && node.headRefOid === match.sha)),
371
373
  );
372
- const pick = ours.find((node) => node.state === "OPEN") ?? ours[0];
374
+ const open = ours.filter((node) => node.state === "OPEN");
375
+ // No base named and open PRs into several bases (a git-flow release
376
+ // into main and its back-merge into develop): never guess.
377
+ if (!match.base && new Set(open.map((node) => node.baseRefName)).size > 1)
378
+ return failure(
379
+ "blocked",
380
+ `ambiguous_pr: ${head} has open pull requests ${open.map((node) => `#${node.number} -> ${node.baseRefName}`).join(", ")}`,
381
+ `pass --pr <n>, e.g. --pr ${open[0].number}`,
382
+ );
383
+ const pick = open[0] ?? ours[0];
373
384
  if (!pick) return success(null);
374
385
  const ref: PrRef = {
375
386
  number: pick.number,
@@ -327,10 +327,18 @@ export function createGitLabForge(options: {
327
327
  const matches = list.data.filter(
328
328
  (mr) =>
329
329
  mr.source_branch === head &&
330
+ (!match.base || mr.target_branch === undefined || mr.target_branch === match.base) &&
330
331
  ((match.projectId !== null && mr.source_project_id === match.projectId) ||
331
332
  (match.sha !== null && mr.sha === match.sha)),
332
333
  );
333
- const pick = matches.find((mr) => mr.state === "opened") ?? matches[0];
334
+ const open = matches.filter((mr) => mr.state === "opened");
335
+ if (!match.base && new Set(open.map((mr) => mr.target_branch)).size > 1)
336
+ return failure(
337
+ "blocked",
338
+ `ambiguous_pr: ${head} has open merge requests ${open.map((mr) => `!${mr.iid} -> ${mr.target_branch}`).join(", ")}`,
339
+ `pass --pr <n>, e.g. --pr ${open[0].iid}`,
340
+ );
341
+ const pick = open[0] ?? matches[0];
334
342
  return success(
335
343
  pick
336
344
  ? {
@@ -22,7 +22,7 @@ import {
22
22
  type AutonomySource,
23
23
  type DefaultEndpoint,
24
24
  } from "../autonomy";
25
- import { isProtectedTarget } from "../core/branch";
25
+ import { branchKindOf, flowRoute, gitFlowOf, isProtectedTarget } from "../core/branch";
26
26
  import { vcsConfig } from "../core/vcs-config";
27
27
  import { workspaceReleaseTracks } from "../core/release-tracks";
28
28
  import { deleteRemoteBranch } from "../git/ops";
@@ -211,6 +211,7 @@ async function verifiedHead(
211
211
  sha: string,
212
212
  first: PrRef,
213
213
  sleep: Sleep,
214
+ base: string,
214
215
  ): Promise<ForgeResult<PrRef>> {
215
216
  let ref = first;
216
217
  for (const delay of [0, ...VERIFY_DELAYS_MS]) {
@@ -220,6 +221,7 @@ async function verifiedHead(
220
221
  owner: resolved.headRepo.split("/")[0] ?? null,
221
222
  projectId: resolved.headProjectId,
222
223
  sha,
224
+ base,
223
225
  });
224
226
  if (!found.ok) return found;
225
227
  if (found.data && found.data.number === first.number) ref = found.data;
@@ -258,10 +260,13 @@ export async function createPullRequest(
258
260
  "pass --base <branch>",
259
261
  );
260
262
 
263
+ // Head AND base: a release branch with an open PR into main still gets
264
+ // its back-merge PR into develop.
261
265
  const existing = resolved.forge.findPr(branch, {
262
266
  owner: resolved.headRepo.split("/")[0] ?? null,
263
267
  projectId: resolved.headProjectId,
264
268
  sha,
269
+ base: input.base,
265
270
  });
266
271
  if (!existing.ok) return existing;
267
272
  let created = false;
@@ -281,7 +286,7 @@ export async function createPullRequest(
281
286
  ref = made.data;
282
287
  created = true;
283
288
  }
284
- const verified = await verifiedHead(resolved, branch, sha, ref, sleep);
289
+ const verified = await verifiedHead(resolved, branch, sha, ref, sleep, input.base);
285
290
  if (!verified.ok) return verified;
286
291
  const row = appendObserved(cwd, {
287
292
  type: "pr.created",
@@ -366,13 +371,26 @@ export type MergeOutcome = {
366
371
  /**
367
372
  * The merge-back a merge into `base` owes: the track whose production branch
368
373
  * `base` is, by ownership alone (no --track, no WORKFLOW_RELEASE_TRACK, no
369
- * history). Best effort: a config problem never stops the pr.merged record.
374
+ * history); without tracks, a git-flow release/* or hotfix/* landing on the
375
+ * production branch owes develop. Best effort: a config problem never stops
376
+ * the pr.merged record.
370
377
  */
371
- const mergeBackFor = (cwd: string, base: string): { track: string | null; branches: string[] } => {
378
+ const mergeBackFor = (
379
+ cwd: string,
380
+ base: string,
381
+ head: string,
382
+ ): { track: string | null; branches: string[] } => {
372
383
  try {
373
- const owners = workspaceReleaseTracks(cwd).tracks.filter(
374
- (track) => track.productionBranch === base,
375
- );
384
+ const read = workspaceReleaseTracks(cwd);
385
+ if (!read.tracks.length) {
386
+ const flow = gitFlowOf(cwd);
387
+ const kind = branchKindOf(head);
388
+ const route = flow && kind ? flowRoute(flow, kind) : null;
389
+ return route?.mergeBack && route.target === base
390
+ ? { track: "git-flow", branches: [route.mergeBack] }
391
+ : { track: null, branches: [] };
392
+ }
393
+ const owners = read.tracks.filter((track) => track.productionBranch === base);
376
394
  if (owners.length !== 1) return { track: null, branches: [] };
377
395
  return {
378
396
  track: owners[0].name,
@@ -604,7 +622,7 @@ export async function mergePullRequest(
604
622
  deletedBranch =
605
623
  deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
606
624
  }
607
- const mergeBack = mergeBackFor(cwd, doc.base);
625
+ const mergeBack = mergeBackFor(cwd, doc.base, branch);
608
626
  const row = appendObserved(cwd, {
609
627
  type: "pr.merged",
610
628
  actor: input.actor,
@@ -42,6 +42,11 @@ export type PrMatch = {
42
42
  projectId: number | null;
43
43
  /** A PR whose head is exactly this commit is ours whatever its owner. */
44
44
  sha: string | null;
45
+ /**
46
+ * Only a PR into this base: one head may have PRs into several bases (a
47
+ * git-flow release into main and its back-merge into develop).
48
+ */
49
+ base?: string | null;
45
50
  };
46
51
 
47
52
  /** One CI check (GitHub check run / commit status, GitLab job or bridge). */
package/src/git/ops.ts CHANGED
@@ -21,11 +21,19 @@ import { spawnSync } from "node:child_process";
21
21
  import { existsSync, readFileSync } from "node:fs";
22
22
  import path from "node:path";
23
23
  import {
24
+ branchKindOf,
24
25
  classifyBranchDirt,
26
+ flowMismatch,
27
+ flowRoute,
28
+ gitFlowOf,
25
29
  isProtectedTarget,
30
+ releaseBranch,
31
+ releaseBranchesOf,
26
32
  resolveCommitPolicyFor,
27
33
  validateBranchNameFor,
28
34
  validateCommitMessageFor,
35
+ type BranchKind,
36
+ type FlowRoute,
29
37
  } from "../core/branch";
30
38
  import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
31
39
  import { pushTargetIsStable } from "../core/pr-create";
@@ -221,12 +229,14 @@ export type BranchOutcome = {
221
229
  notes: string[];
222
230
  /** The release track the base and name came from; null without tracks. */
223
231
  releaseTrack: ResolvedReleaseTrack | null;
232
+ /** The git-flow route the base came from; null outside git-flow or with a release track. */
233
+ flow: FlowRoute | null;
224
234
  };
225
235
 
226
236
  export type BranchInput = {
227
237
  /** An explicit branch name; else `kind` + `slug` through the track's naming. */
228
238
  name?: string;
229
- kind?: "feature" | "bugfix" | "hotfix";
239
+ kind?: BranchKind;
230
240
  slug?: string;
231
241
  base?: string | null;
232
242
  carry?: boolean;
@@ -272,6 +282,21 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
272
282
  if (!policy.ok) return failure("blocked", policy.error, policy.correction);
273
283
  if (resolveRef(cwd, `refs/heads/${name}`))
274
284
  return failure("failed", `branch_exists: ${name} already exists`, `git switch ${name}`);
285
+ // git-flow (no release track): the kind decides the base, and an explicit
286
+ // base that is the other long-lived branch is refused.
287
+ const kind = input.kind ?? branchKindOf(name);
288
+ const flow =
289
+ resolved.ok !== false && !releaseTrack?.track
290
+ ? gitFlowOf(cwd, String(resolved.defaultTargetBranch ?? "") || null)
291
+ : null;
292
+ const route = flow && kind ? flowRoute(flow, kind) : null;
293
+ const mismatch = explicitBase ? flowMismatch(flow, kind, explicitBase, "base") : null;
294
+ if (mismatch && route)
295
+ return failure(
296
+ "blocked",
297
+ mismatch,
298
+ `workit git branch ${input.kind ? `--kind ${kind} --slug ${input.slug}` : name} # without --base it starts from ${route.base}`,
299
+ );
275
300
 
276
301
  let base = explicitBase;
277
302
  let defaultTarget: string | null = null;
@@ -287,12 +312,16 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
287
312
  defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
288
313
  defaultBase = String(resolved.baseBranch ?? "") || defaultTarget;
289
314
  }
290
- base ??= defaultBase;
315
+ base ??= route?.base ?? defaultBase;
291
316
  if (!base)
292
317
  return failure("invalid_input", "no default target branch is configured; pass --base <branch>");
293
318
  if (base.startsWith("-")) return failure("invalid_input", "--base must not start with -");
294
319
 
295
320
  const notes: string[] = [...(releaseTrack?.warnings ?? [])];
321
+ if (route?.mergeBack)
322
+ notes.push(
323
+ `git-flow: ${route.kind}/* lands on ${route.target} (workit pr create targets it), then ${route.target} merges back into ${route.mergeBack}`,
324
+ );
296
325
  const remotes = remoteNames(cwd);
297
326
  const remote = remotes.includes("origin") ? "origin" : pushRemoteName(cwd);
298
327
  const localTip = resolveRef(cwd, `refs/heads/${base}`);
@@ -351,6 +380,7 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
351
380
  carried: dirt !== "clean",
352
381
  notes,
353
382
  releaseTrack,
383
+ flow: route,
354
384
  });
355
385
  }
356
386
 
@@ -691,8 +721,10 @@ export function pushPreflight(
691
721
  if (isProtectedTarget(cwd, branch))
692
722
  return failure(
693
723
  "blocked",
694
- `protected_branch: ${branch} is protected by the workspace branch policy; workit never pushes to it`,
695
- "workit git branch <feature/name> # push a feature branch and open a PR",
724
+ `protected_branch: ${branch} is protected by the workspace branch policy; workit git push never pushes to it`,
725
+ releaseBranch(cwd, branch)
726
+ ? `workit git push --release ${branch} # finishing a release or hotfix; needs the release grant`
727
+ : "workit git branch <feature/name> # push a feature branch and open a PR",
696
728
  );
697
729
  const sha = resolveRef(cwd, `refs/heads/${branch}`);
698
730
  if (!sha) return failure("not_found", `branch ${branch} has no commit to push`);
@@ -992,6 +1024,229 @@ export function executePush(
992
1024
  });
993
1025
  }
994
1026
 
1027
+ // ---------------------------------------------------------------------------
1028
+ // release push: finishing a git-flow release or hotfix
1029
+
1030
+ export type ReleaseRef = {
1031
+ kind: "branch" | "tag";
1032
+ /** The short name (`main`, `v4.1.0`). */
1033
+ name: string;
1034
+ /** The full ref (`refs/heads/main`, `refs/tags/v4.1.0`). */
1035
+ ref: string;
1036
+ /** The commit it points at (a tag peeled). */
1037
+ sha: string;
1038
+ };
1039
+
1040
+ export type ReleasePushPlan = Omit<PushPlan, "branch" | "sha"> & {
1041
+ refs: ReleaseRef[];
1042
+ /** The branch a pushed tag's commit must already be on (or a branch in this push). */
1043
+ production: string | null;
1044
+ };
1045
+
1046
+ /** A release tag: `v` and a digit (`v4.1.0`, `v2`). */
1047
+ const RELEASE_TAG = /^v\d/u;
1048
+
1049
+ /**
1050
+ * What `workit git push --release` may push: the git-flow production and
1051
+ * develop branches and `v*` tags, nothing else, each by its local ref. Everything else stays on the feature-branch
1052
+ * path (`workit git push`, `workit pr create`).
1053
+ */
1054
+ export function releasePushPreflight(
1055
+ cwd: string,
1056
+ names: readonly string[],
1057
+ ): ForgeResult<ReleasePushPlan> {
1058
+ const progress = operationInProgress(cwd);
1059
+ if (progress) return inProgressFailure(progress.operation, "push");
1060
+ const wanted = [...new Set(names)];
1061
+ if (!wanted.length) return failure("invalid_input", "name the release refs to push");
1062
+ const { preset, branches, production } = releaseBranchesOf(cwd);
1063
+ const refs: ReleaseRef[] = [];
1064
+ for (const raw of wanted) {
1065
+ if (raw.startsWith("-") || raw.includes(":"))
1066
+ return failure("invalid_input", `${raw} is not a ref name (refspecs are not accepted)`);
1067
+ const tagName = raw.replace(/^refs\/tags\//u, "");
1068
+ const branchName = raw.replace(/^refs\/heads\//u, "");
1069
+ const tag = raw.startsWith("refs/heads/") ? null : resolveRef(cwd, `refs/tags/${tagName}`);
1070
+ const branch = raw.startsWith("refs/tags/")
1071
+ ? null
1072
+ : resolveRef(cwd, `refs/heads/${branchName}`);
1073
+ if (tag && branch)
1074
+ return failure(
1075
+ "invalid_input",
1076
+ `${raw} names both a branch and a tag`,
1077
+ `workit git push --release refs/heads/${raw} # or refs/tags/${raw}`,
1078
+ );
1079
+ if (tag) {
1080
+ if (!RELEASE_TAG.test(tagName))
1081
+ return failure(
1082
+ "blocked",
1083
+ `release_push_refused: tag ${tagName} is not a release tag (v<version>)`,
1084
+ "git tag -a v<version> -m <message> # then: workit git push --release v<version>",
1085
+ );
1086
+ refs.push({ kind: "tag", name: tagName, ref: `refs/tags/${tagName}`, sha: tag });
1087
+ continue;
1088
+ }
1089
+ if (!branch) return failure("not_found", `${raw} is not a local branch or tag`);
1090
+ if (!branches.some((name) => name.toLowerCase() === branchName.toLowerCase()))
1091
+ return failure(
1092
+ "blocked",
1093
+ branches.length
1094
+ ? `release_push_refused: ${branchName} is not a git-flow release branch (${branches.join(", ")})`
1095
+ : `release_push_refused: the ${preset} policy lands ${branchName} only through a reviewed PR; workit git push --release pushes v<version> tags there, not branches`,
1096
+ branches.length
1097
+ ? "workit git push # a feature, bugfix, release or hotfix branch, then: workit pr create"
1098
+ : `workit pr create --base ${branchName} # then tag the merged commit and: workit git push --release v<version>`,
1099
+ );
1100
+ refs.push({ kind: "branch", name: branchName, ref: `refs/heads/${branchName}`, sha: branch });
1101
+ }
1102
+ const remote = pushRemoteName(cwd, refs.find((ref) => ref.kind === "branch")?.name ?? null);
1103
+ if (!remote)
1104
+ return failure(
1105
+ "not_found",
1106
+ "no push remote is configured for this repository",
1107
+ "git remote add origin <url>",
1108
+ );
1109
+ const raw = pushUrl(cwd, remote);
1110
+ if (!raw)
1111
+ return failure(
1112
+ "blocked",
1113
+ `remote "${remote}" has no single push URL`,
1114
+ `git remote get-url --push --all ${remote} # keep exactly one push URL`,
1115
+ );
1116
+ const local = parseRemoteUrl(raw)?.protocol === "file";
1117
+ if (!local && !pushTargetIsStable(cwd, raw))
1118
+ return failure(
1119
+ "blocked",
1120
+ `push_target_rewritten: the push URL of "${remote}" is rewritten by a url.*.insteadOf rule, so the destination is not the one checked`,
1121
+ "git config --get-regexp '^url\\.' # remove the rewrite for this remote",
1122
+ );
1123
+ return success({ refs, remote, url: redactRemote(raw), rawUrl: raw, local, production });
1124
+ }
1125
+
1126
+ export type ReleasePushOutcome = {
1127
+ remote: string;
1128
+ url: string;
1129
+ refs: Array<ReleaseRef & { previous: string | null; pushed: boolean }>;
1130
+ recorded: { id: string } | { error: string };
1131
+ };
1132
+
1133
+ /**
1134
+ * Push the planned release refs in one atomic push: a branch only fast
1135
+ * forward, a tag only when the remote lacks it (a release tag never moves),
1136
+ * never forced. Success only when every remote tip observed afterwards is the
1137
+ * local one; recorded as one `release.pushed` row.
1138
+ */
1139
+ export function executeReleasePush(
1140
+ cwd: string,
1141
+ plan: ReleasePushPlan,
1142
+ input: {
1143
+ actor: LedgerActor;
1144
+ grant: string;
1145
+ timeoutMs?: number;
1146
+ identity?: Record<string, unknown>;
1147
+ },
1148
+ ): ForgeResult<ReleasePushOutcome> {
1149
+ const previous = new Map<string, string | null>();
1150
+ for (const ref of plan.refs) {
1151
+ const before = remoteRefTip(cwd, plan.rawUrl, ref.ref);
1152
+ if (!before.ok) return failure(before.code, before.error);
1153
+ previous.set(ref.ref, before.sha);
1154
+ if (before.sha === null || before.sha === ref.sha) continue;
1155
+ if (ref.kind === "tag")
1156
+ return failure(
1157
+ "blocked",
1158
+ `tag_exists: ${plan.remote} already has ${ref.name} at ${before.sha.slice(0, 12)}, not ${ref.sha.slice(0, 12)}; a release tag never moves`,
1159
+ `git tag -d ${ref.name} # tag the next version instead`,
1160
+ );
1161
+ if (!git(cwd, ["merge-base", "--is-ancestor", before.sha, ref.sha]).ok)
1162
+ return failure(
1163
+ "failed",
1164
+ `non_fast_forward: ${plan.remote}/${ref.name} (${before.sha.slice(0, 12)}) is not part of local ${ref.name}; a release push never rewrites it`,
1165
+ `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}`,
1166
+ );
1167
+ }
1168
+ // A release tag marks a commit the production branch already has (or a
1169
+ // branch this push moves): never an unmerged feature commit.
1170
+ const tags = plan.refs.filter((ref) => ref.kind === "tag");
1171
+ if (tags.length) {
1172
+ let productionTip: string | null = null;
1173
+ if (plan.production) {
1174
+ const tip = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.production}`);
1175
+ if (!tip.ok) return failure(tip.code, tip.error);
1176
+ productionTip = tip.sha;
1177
+ }
1178
+ const contains = (tip: string, sha: string) =>
1179
+ git(cwd, ["merge-base", "--is-ancestor", sha, tip]).ok;
1180
+ for (const tag of tags) {
1181
+ const released =
1182
+ (productionTip !== null && contains(productionTip, tag.sha)) ||
1183
+ plan.refs.some((ref) => ref.kind === "branch" && contains(ref.sha, tag.sha));
1184
+ if (!released)
1185
+ return failure(
1186
+ "blocked",
1187
+ `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`,
1188
+ `land it through a PR first (workit pr create), git fetch ${plan.remote}, then tag the merged commit`,
1189
+ );
1190
+ }
1191
+ }
1192
+ const pending = plan.refs.filter((ref) => previous.get(ref.ref) !== ref.sha);
1193
+ if (pending.length) {
1194
+ const run = gitNetwork(
1195
+ cwd,
1196
+ [
1197
+ "push",
1198
+ "--porcelain",
1199
+ "--atomic",
1200
+ "--no-follow-tags",
1201
+ "--recurse-submodules=no",
1202
+ plan.remote,
1203
+ ...pending.map((ref) =>
1204
+ ref.kind === "tag" ? `${ref.ref}:${ref.ref}` : `${ref.sha}:${ref.ref}`,
1205
+ ),
1206
+ ],
1207
+ input.timeoutMs ?? PUSH_TIMEOUT_MS,
1208
+ );
1209
+ if (!run.ok)
1210
+ return run.timedOut
1211
+ ? failure("unavailable", `git push to ${plan.url} timed out`)
1212
+ : failure("failed", `git push to ${plan.url} failed:\n${gitOutput(run)}`);
1213
+ }
1214
+ for (const ref of plan.refs) {
1215
+ const after = remoteRefTip(cwd, plan.rawUrl, ref.ref);
1216
+ if (!after.ok) return failure(after.code, `push sent but not verified: ${after.error}`);
1217
+ if (after.sha !== ref.sha)
1218
+ return failure(
1219
+ "failed",
1220
+ `push_unverified: ${plan.remote} ${ref.ref} is at ${after.sha?.slice(0, 12) ?? "(absent)"}, not ${ref.sha.slice(0, 12)}`,
1221
+ );
1222
+ }
1223
+ const refs = plan.refs.map((ref) => ({
1224
+ ...ref,
1225
+ previous: previous.get(ref.ref) ?? null,
1226
+ pushed: pending.includes(ref),
1227
+ }));
1228
+ const row = appendObserved(cwd, {
1229
+ type: "release.pushed",
1230
+ actor: input.actor,
1231
+ remote: plan.remote,
1232
+ url: plan.url,
1233
+ refs: refs.map(({ ref, sha, previous: before, pushed }) => ({
1234
+ ref,
1235
+ sha,
1236
+ previous: before,
1237
+ pushed,
1238
+ })),
1239
+ grant: input.grant,
1240
+ ...(input.identity ? { identity: input.identity } : {}),
1241
+ });
1242
+ return success({
1243
+ remote: plan.remote,
1244
+ url: plan.url,
1245
+ refs,
1246
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
1247
+ });
1248
+ }
1249
+
995
1250
  /**
996
1251
  * Delete a remote branch only while it still points at `expectedTip`
997
1252
  * (`--force-with-lease=refs/heads/<b>:<tip> --delete`). `lease` is false when
@@ -6,6 +6,7 @@ import {
6
6
  turnContextText,
7
7
  } from "./context";
8
8
  import type { HostDescriptor, Support } from "./descriptor";
9
+ import { branchGuidance } from "../core/branch";
9
10
  import { recordIssuedSession } from "../host-session";
10
11
  import { shellPolicy } from "./policy";
11
12
  import {
@@ -91,13 +92,14 @@ const subagentStartText = (
91
92
  const isolated =
92
93
  descriptor.subagents.worktreeIsolation === "native" &&
93
94
  (role === "implementer" || CLAUDE_WORKTREE.test(input.cwd));
95
+ const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
94
96
  if (role === "implementer" && isolated)
95
- return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${ISOLATED_GIT} Use a policy-compliant branch (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
97
+ return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${ISOLATED_GIT} Plain git is expected here, and the branch name is still policy-checked: a raw \`git switch -c\`, \`git checkout -b\`, \`git branch\` or \`git worktree add -b\` onto a protected or non-compliant name is denied, so use a policy-compliant branch.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
96
98
  if (isolated)
97
99
  return `${ISOLATED_GIT}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
98
100
  if (role === "implementer")
99
101
  // No host isolation: the lead makes the worktree, and workit git works there.
100
- return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
102
+ return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
101
103
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
102
104
  if (role !== "judge" || !event.agentId) return readOnly;
103
105
  const own = subagentSession(session, event.agentId);
@@ -139,7 +141,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
139
141
  const skillRead = skillReadIn(event.command, event.dialect);
140
142
  if (skillRead) recordSkillLoad(input, skillRead, "read");
141
143
  const canDeny = usable(descriptor.shellPolicy.deny);
142
- const policy = canDeny ? shellPolicy(input.cwd, event.command) : NONE;
144
+ const policy = canDeny ? shellPolicy(input.cwd, event.command, event.dialect) : NONE;
143
145
  if (policy.kind !== "none") return policy;
144
146
  // Without a post-tool event, the previous raw commit is recorded now.
145
147
  const postTool = usable(descriptor.events["shell.post"].support);
@@ -1,13 +1,19 @@
1
1
  import { shellBranchPolicyViolation } from "../core/route-intent";
2
2
  import type { HookDecision } from "./protocol";
3
+ import type { ShellDialect } from "./shell-words";
3
4
 
4
5
  /**
5
- * Branch policy for one shell command: only direct literal branch creation
6
- * onto a noncompliant name is denied. Every other command keeps the host's own
7
- * permission decision. The reason carries the policy's correction.
6
+ * Branch policy for one shell command: a raw branch creation (`git switch -c`,
7
+ * `checkout -b`, `branch`, `worktree add -b`, in any chain, directory or
8
+ * wrapper) onto a noncompliant name is denied. Every other command keeps the
9
+ * host's own permission decision. The reason carries the policy's correction.
8
10
  */
9
- export const shellPolicy = (cwd: string, command: string): HookDecision => {
10
- const policy = shellBranchPolicyViolation(cwd, command);
11
+ export const shellPolicy = (
12
+ cwd: string,
13
+ command: string,
14
+ dialect: ShellDialect = "posix",
15
+ ): HookDecision => {
16
+ const policy = shellBranchPolicyViolation(cwd, command, dialect);
11
17
  return policy && !policy.ok
12
18
  ? { kind: "deny", reason: `branch_policy_denied: ${policy.error}`, unblock: null }
13
19
  : { kind: "none" };
@@ -24,7 +24,7 @@ import { createHash } from "node:crypto";
24
24
  import fs from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
- import { resolveBranchPolicyFor } from "../core/branch";
27
+ import { releaseBranch, resolveBranchPolicyFor, validateBranchNameFor } from "../core/branch";
28
28
  import { resolveRuntimeWorkspaceVcs } from "../core/workspaces";
29
29
  import { pushRemoteName } from "../git/rev";
30
30
  import { appendHookObserved, readLedger } from "../ledger";
@@ -230,6 +230,18 @@ export type RawAction =
230
230
  blindForce: boolean;
231
231
  /** The remote named on the command line; null pushes to the default one. */
232
232
  remote: string | null;
233
+ /** Tags named as `refs/tags/<t>` destinations (bare names are told apart later). */
234
+ tags?: string[];
235
+ /** `--tags`: every tag goes along. */
236
+ allTags?: true;
237
+ /** `--delete`/`-d`: the refspecs are deleted, not pushed. */
238
+ deletes?: true;
239
+ /** Names deleted by a colon refspec (`:chore/old`). */
240
+ deleted?: string[];
241
+ /** `--all`/`--branches`: every local branch. */
242
+ allBranches?: true;
243
+ /** `--mirror`: every ref, deletions included. */
244
+ mirror?: true;
233
245
  }
234
246
  | { kind: "merge"; forge: "gh" | "glab" }
235
247
  | { kind: "pr-create"; forge: "gh" | "glab" }
@@ -242,6 +254,10 @@ const pushAction = (args: string[]): RawAction | null => {
242
254
  let force = false;
243
255
  let lease = false;
244
256
  let wholesale = false;
257
+ let allTags = false;
258
+ let deletes = false;
259
+ let allBranches = false;
260
+ let mirror = false;
245
261
  let remote: string | null = null;
246
262
  const positionals: string[] = [];
247
263
  for (let index = 1; index < args.length; index++) {
@@ -256,26 +272,51 @@ const pushAction = (args: string[]): RawAction | null => {
256
272
  else if (PUSH_VALUE_OPTIONS.has(arg)) index++;
257
273
  else if (arg === "--force" || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg)) force = true;
258
274
  else if (arg.startsWith("--force-with-lease") || arg === "--force-if-includes") lease = true;
259
- else if (["--all", "--mirror", "--branches", "--tags"].includes(arg)) wholesale = true;
275
+ else if (arg === "--delete" || arg === "-d") deletes = true;
276
+ else if (arg === "--tags") allTags = wholesale = true;
277
+ else if (arg === "--all" || arg === "--branches") allBranches = wholesale = true;
278
+ else if (arg === "--mirror") mirror = wholesale = true;
260
279
  else if (!arg.startsWith("-")) positionals.push(arg);
261
280
  }
262
281
  // `--repo` names the remote; positionals are then all refspecs.
263
282
  const specs = remote === null ? positionals.slice(1) : positionals;
264
283
  remote ??= positionals[0] ?? null;
265
284
  const targets: Array<string | null> = [];
285
+ const tags: string[] = [];
286
+ const deleted: string[] = [];
266
287
  for (const raw of specs) {
267
288
  if (raw.startsWith("+")) force = true;
268
289
  const spec = raw.replace(/^\+/, "");
269
290
  const colon = spec.indexOf(":");
270
291
  const destination = colon >= 0 ? spec.slice(colon + 1) : spec;
271
292
  if (!destination) continue;
272
- if (destination === "HEAD") targets.push(null);
273
- else if (destination.startsWith("refs/heads/")) targets.push(destination.slice(11));
274
- else if (!destination.startsWith("refs/")) targets.push(destination);
293
+ const name =
294
+ destination === "HEAD"
295
+ ? null
296
+ : destination.startsWith("refs/heads/")
297
+ ? destination.slice(11)
298
+ : destination.startsWith("refs/")
299
+ ? undefined
300
+ : destination;
301
+ if (destination.startsWith("refs/tags/")) tags.push(destination.slice(10));
302
+ if (name === undefined) continue;
303
+ targets.push(name);
304
+ if (colon === 0 && name !== null) deleted.push(name);
275
305
  }
276
306
  // No refspec pushes the current branch (push.default simple/current).
277
307
  if (specs.length === 0 && !wholesale) targets.push(null);
278
- return { kind: "push", targets, blindForce: force && !lease, remote };
308
+ return {
309
+ kind: "push",
310
+ targets,
311
+ blindForce: force && !lease,
312
+ remote,
313
+ ...(tags.length ? { tags } : {}),
314
+ ...(allTags ? { allTags: true as const } : {}),
315
+ ...(deletes ? { deletes: true as const } : {}),
316
+ ...(deleted.length ? { deleted } : {}),
317
+ ...(allBranches ? { allBranches: true as const } : {}),
318
+ ...(mirror ? { mirror: true as const } : {}),
319
+ };
279
320
  };
280
321
 
281
322
  /** Options whose next word is their value (never an option or subcommand). */
@@ -432,12 +473,88 @@ const headSha = (repo: Repo): string | null => {
432
473
 
433
474
  const quote = (text: string) => `\`${text}\``;
434
475
 
435
- const refusal = (action: RawAction, branch: string | null): string => {
476
+ const refusal = (action: RawAction, branch: string | null, dir?: string): string => {
436
477
  if (action.kind === "merge")
437
478
  return `workit: raw ${quote(action.forge === "gh" ? "gh pr merge" : "glab mr merge")} bypasses the merge grant and the verdict gate. Run ${quote("workit pr merge")} instead (it checks both; ${quote("workit help pr")}).`;
479
+ // Finishing a release (git-flow main/develop, the github-flow trunk, a
480
+ // custom protected branch): the push has its own granted verb.
481
+ const plainPush = action.kind === "push" && !action.blindForce && !action.deletes;
482
+ if (dir && branch && plainPush && releaseBranch(dir, branch))
483
+ return `workit: raw ${quote("git push")} onto ${branch}, a protected release branch, bypasses workit's protected-branch check. To finish a release or hotfix, push it with ${quote(`workit git push --release ${branch} [--release v<version>]`)}: it pushes ${branch} (fast-forward only) and the release tag, verifies the remote and records it, under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}). Feature work goes through ${quote("workit git push")} and ${quote("workit pr merge")}.`;
438
484
  return `workit: raw ${quote("git push")}${action.kind === "push" && action.blindForce ? " --force" : ""} onto ${branch}, a protected branch or the default target, bypasses workit's protected-branch check. Push your feature branch with ${quote("workit git push")} (${quote("workit git push --force-with-lease")} to rewrite it) and land it with ${quote("workit pr merge")}.`;
439
485
  };
440
486
 
487
+ type PushAction = Extract<RawAction, { kind: "push" }>;
488
+
489
+ /** The local branches and tags, and the remote's branches as last fetched: one git call. */
490
+ type RefFacts = { heads: Set<string>; tags: Set<string>; remoteHeads: Set<string> };
491
+
492
+ const refFacts = (repo: Repo, remote: string): RefFacts => {
493
+ const facts: RefFacts = { heads: new Set(), tags: new Set(), remoteHeads: new Set() };
494
+ const listed = spawnSync(
495
+ "git",
496
+ ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags", `refs/remotes/${remote}`],
497
+ { cwd: repo.dir, encoding: "utf8", timeout: 5_000 },
498
+ );
499
+ const remotePrefix = `refs/remotes/${remote}/`;
500
+ for (const ref of (listed.stdout ?? "").split("\n")) {
501
+ if (ref.startsWith("refs/heads/")) facts.heads.add(ref.slice(11));
502
+ else if (ref.startsWith("refs/tags/")) facts.tags.add(ref.slice(10));
503
+ else if (ref.startsWith(remotePrefix)) facts.remoteHeads.add(ref.slice(remotePrefix.length));
504
+ }
505
+ return facts;
506
+ };
507
+
508
+ /** A bare refspec naming a local tag and no local branch pushes the tag. */
509
+ const isTag = (facts: RefFacts, name: string): boolean =>
510
+ facts.tags.has(name) && !facts.heads.has(name);
511
+
512
+ /**
513
+ * A raw tag push (`git push origin v1.0`, `refs/tags/v1.0`, `--tags`) skips
514
+ * the release grant: denied with the workit command. `--follow-tags` only
515
+ * nudges (it pushes annotated tags the pushed commits already reach).
516
+ */
517
+ const tagRefusal = (action: PushAction, facts: RefFacts): string | null => {
518
+ const tags = [
519
+ ...(action.tags ?? []),
520
+ ...action.targets.filter((name): name is string => name !== null && isTag(facts, name)),
521
+ ];
522
+ if (!tags.length && !action.allTags) return null;
523
+ const named = tags.length ? tags.join(" --release ") : "v<version>";
524
+ return `workit: raw ${quote(`git push${action.allTags ? " --tags" : ""}`)} of ${tags.length ? `tag ${tags.join(", ")}` : "tags"} bypasses the release grant. Push a release tag with ${quote(`workit git push --release ${named}`)} (v<version> tags whose commit the production branch already has; a tag never moves), under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}).`;
525
+ };
526
+
527
+ /** The remote already has the branch (ls-remote, bounded; unreachable answers yes: fail open). */
528
+ const remoteHas = (repo: Repo, remote: string, branch: string): boolean => {
529
+ const listed = spawnSync("git", ["ls-remote", "--heads", remote, `refs/heads/${branch}`], {
530
+ cwd: repo.dir,
531
+ encoding: "utf8",
532
+ timeout: 3_000,
533
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
534
+ });
535
+ return listed.status !== 0 || (listed.stdout ?? "").trim() !== "";
536
+ };
537
+
538
+ /**
539
+ * A branch name that would be new on the remote and that the workspace policy
540
+ * does not allow, with the closest allowed name. An existing remote branch
541
+ * (`dependabot/…`, a legacy name) is never judged: pushing it is not a new
542
+ * name. `--all`/`--branches` expand to every local branch.
543
+ */
544
+ const offPolicyPush = (action: PushAction, repo: Repo, facts: RefFacts, remote: string) => {
545
+ const names = action.allBranches
546
+ ? [...facts.heads]
547
+ : action.targets.map((target) => target ?? repo.branch);
548
+ for (const branch of names) {
549
+ if (!branch || action.deleted?.includes(branch) || isTag(facts, branch)) continue;
550
+ if (facts.remoteHeads.has(branch)) continue;
551
+ const check = validateBranchNameFor(repo.dir, branch);
552
+ if (check.ok || check.rule !== "allowed_pattern" || remoteHas(repo, remote, branch)) continue;
553
+ return `workit: raw ${quote("git push")} onto ${branch}: ${check.error}`;
554
+ }
555
+ return null;
556
+ };
557
+
441
558
  const nudgeFor = (action: RawAction, host: HookInput["host"], session: string): string | null => {
442
559
  switch (action.kind) {
443
560
  case "commit": {
@@ -550,12 +667,23 @@ export function rawGitPre(
550
667
  for (const { action, repo } of actionsIn(input.cwd, command, dialect)) {
551
668
  store ??= repo.location.dir;
552
669
  if (action.kind === "merge") return deny(refusal(action, null));
553
- if (action.kind === "push")
670
+ if (action.kind === "push" && toPushRemote(repo, action.remote)) {
671
+ if (action.mirror)
672
+ return deny(
673
+ `workit: raw ${quote("git push --mirror")} rewrites and deletes every remote ref, protected branches and tags included. Push one branch with ${quote("workit git push")}.`,
674
+ );
554
675
  for (const target of action.targets) {
555
676
  const branch = target ?? repo.branch;
556
- if (branch && guardedBranch(repo.dir, branch) && toPushRemote(repo, action.remote))
557
- return deny(refusal(action, branch));
677
+ if (branch && guardedBranch(repo.dir, branch))
678
+ return deny(refusal(action, branch, repo.dir));
558
679
  }
680
+ const remote = action.remote ?? pushRemoteName(repo.dir, repo.branch) ?? "origin";
681
+ const facts = refFacts(repo, remote);
682
+ const tagDeny = tagRefusal(action, facts);
683
+ if (tagDeny) return deny(tagDeny);
684
+ const nameDeny = action.deletes ? null : offPolicyPush(action, repo, facts, remote);
685
+ if (nameDeny) return deny(nameDeny);
686
+ }
559
687
  const nudge = nudgeFor(action, input.host, input.session.id);
560
688
  if (nudge && !nudges.has(nudgeKind(action))) nudges.set(nudgeKind(action), nudge);
561
689
  }