@brainervirus/workit-core 8.10.0 → 8.12.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.
@@ -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
  }