@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 +1 -1
- package/src/autonomy.ts +6 -2
- package/src/core/branch-policy.ts +8 -1
- package/src/core/branch.ts +270 -3
- package/src/core/route-intent.ts +155 -27
- package/src/forge/github.ts +13 -2
- package/src/forge/gitlab.ts +9 -1
- package/src/forge/pr-ops.ts +26 -8
- package/src/forge/types.ts +5 -0
- package/src/git/ops.ts +259 -4
- package/src/hooks/handle.ts +5 -3
- package/src/hooks/policy.ts +11 -5
- package/src/hooks/raw-git.ts +138 -10
package/package.json
CHANGED
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`
|
|
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
|
|
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");
|
package/src/core/branch.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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));
|
package/src/core/route-intent.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
};
|
package/src/forge/github.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -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
|
|
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
|
? {
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -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)
|
|
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 = (
|
|
378
|
+
const mergeBackFor = (
|
|
379
|
+
cwd: string,
|
|
380
|
+
base: string,
|
|
381
|
+
head: string,
|
|
382
|
+
): { track: string | null; branches: string[] } => {
|
|
372
383
|
try {
|
|
373
|
-
const
|
|
374
|
-
|
|
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,
|
package/src/forge/types.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
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
|
package/src/hooks/handle.ts
CHANGED
|
@@ -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}
|
|
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>\` (
|
|
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);
|
package/src/hooks/policy.ts
CHANGED
|
@@ -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:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 = (
|
|
10
|
-
|
|
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" };
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 {
|
|
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)
|
|
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
|
}
|