@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.
package/src/git/ops.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  // the named paths (`--only` semantics) or, with --all, everything is
13
13
  // committed: unrelated dirt is never swept in implicitly. The acting session
14
14
  // goes into a `Workit-Session:` trailer and a `commit.recorded` ledger row,
15
- // so a verdict from the same session reads as the author's (D18).
15
+ // so a verdict from the same session reads as the author's (D18). A
16
+ // workspace with `trailers: false` gets the ledger row only. `--trailer`
17
+ // adds the caller's own trailers (Co-Authored-By) to the same commit.
16
18
  // - push: never to a protected branch, an exact SHA (a commit that lands while
17
19
  // pushing is not pushed by accident), force only as --force-with-lease with
18
20
  // the tip workit last recorded, and success only when the remote tip
@@ -21,16 +23,25 @@ import { spawnSync } from "node:child_process";
21
23
  import { existsSync, readFileSync } from "node:fs";
22
24
  import path from "node:path";
23
25
  import {
26
+ branchKindOf,
24
27
  classifyBranchDirt,
28
+ flowMismatch,
29
+ flowRoute,
30
+ gitFlowOf,
25
31
  isProtectedTarget,
32
+ releaseBranch,
33
+ releaseBranchesOf,
26
34
  resolveCommitPolicyFor,
27
35
  validateBranchNameFor,
28
36
  validateCommitMessageFor,
37
+ type BranchKind,
38
+ type FlowRoute,
29
39
  } from "../core/branch";
30
40
  import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
31
41
  import { pushTargetIsStable } from "../core/pr-create";
32
42
  import { recordBranchBase, trackBranchName } from "../core/release-tracks";
33
43
  import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
44
+ import { workspaceTrailers } from "../core/workspaces";
34
45
  import { redactText } from "../forge/redact";
35
46
  import { failure, success, type ForgeResult } from "../forge/types";
36
47
  import { appendObserved, readLedger, type LedgerActor } from "../ledger";
@@ -221,12 +232,14 @@ export type BranchOutcome = {
221
232
  notes: string[];
222
233
  /** The release track the base and name came from; null without tracks. */
223
234
  releaseTrack: ResolvedReleaseTrack | null;
235
+ /** The git-flow route the base came from; null outside git-flow or with a release track. */
236
+ flow: FlowRoute | null;
224
237
  };
225
238
 
226
239
  export type BranchInput = {
227
240
  /** An explicit branch name; else `kind` + `slug` through the track's naming. */
228
241
  name?: string;
229
- kind?: "feature" | "bugfix" | "hotfix";
242
+ kind?: BranchKind;
230
243
  slug?: string;
231
244
  base?: string | null;
232
245
  carry?: boolean;
@@ -272,6 +285,21 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
272
285
  if (!policy.ok) return failure("blocked", policy.error, policy.correction);
273
286
  if (resolveRef(cwd, `refs/heads/${name}`))
274
287
  return failure("failed", `branch_exists: ${name} already exists`, `git switch ${name}`);
288
+ // git-flow (no release track): the kind decides the base, and an explicit
289
+ // base that is the other long-lived branch is refused.
290
+ const kind = input.kind ?? branchKindOf(name);
291
+ const flow =
292
+ resolved.ok !== false && !releaseTrack?.track
293
+ ? gitFlowOf(cwd, String(resolved.defaultTargetBranch ?? "") || null)
294
+ : null;
295
+ const route = flow && kind ? flowRoute(flow, kind) : null;
296
+ const mismatch = explicitBase ? flowMismatch(flow, kind, explicitBase, "base") : null;
297
+ if (mismatch && route)
298
+ return failure(
299
+ "blocked",
300
+ mismatch,
301
+ `workit git branch ${input.kind ? `--kind ${kind} --slug ${input.slug}` : name} # without --base it starts from ${route.base}`,
302
+ );
275
303
 
276
304
  let base = explicitBase;
277
305
  let defaultTarget: string | null = null;
@@ -287,12 +315,16 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
287
315
  defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
288
316
  defaultBase = String(resolved.baseBranch ?? "") || defaultTarget;
289
317
  }
290
- base ??= defaultBase;
318
+ base ??= route?.base ?? defaultBase;
291
319
  if (!base)
292
320
  return failure("invalid_input", "no default target branch is configured; pass --base <branch>");
293
321
  if (base.startsWith("-")) return failure("invalid_input", "--base must not start with -");
294
322
 
295
323
  const notes: string[] = [...(releaseTrack?.warnings ?? [])];
324
+ if (route?.mergeBack)
325
+ notes.push(
326
+ `git-flow: ${route.kind}/* lands on ${route.target} (workit pr create targets it), then ${route.target} merges back into ${route.mergeBack}`,
327
+ );
296
328
  const remotes = remoteNames(cwd);
297
329
  const remote = remotes.includes("origin") ? "origin" : pushRemoteName(cwd);
298
330
  const localTip = resolveRef(cwd, `refs/heads/${base}`);
@@ -351,6 +383,7 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
351
383
  carried: dirt !== "clean",
352
384
  notes,
353
385
  releaseTrack,
386
+ flow: route,
354
387
  });
355
388
  }
356
389
 
@@ -365,6 +398,8 @@ export type CommitOutcome = {
365
398
  /** Changes left uncommitted (not selected). */
366
399
  leftDirty: number;
367
400
  session: string | null;
401
+ /** Whether the commit carries the `Workit-Session:` trailer (workspace `trailers`). */
402
+ sessionTrailer: boolean;
368
403
  /** With --amend: the commit this one replaced. */
369
404
  amended: string | null;
370
405
  /** Follow-ups the agent should know (an amended commit that was already pushed). */
@@ -400,6 +435,51 @@ export function lintCommitMessage(
400
435
  }
401
436
 
402
437
  const SESSION_SAFE = /^[\w.:@/+-]{1,128}$/u;
438
+ /** A `--trailer` the caller adds: one `Key: value` line. */
439
+ const EXTRA_TRAILER = /^[A-Za-z0-9][A-Za-z0-9-]*: *\S[^\n\r]*$/u;
440
+
441
+ const SESSION_KEY = /^workit-session\s*:/iu;
442
+ /** A trailer line (`Key: value`, no leading space) as git reads one. */
443
+ const TRAILER_LINE = /^[A-Za-z0-9][A-Za-z0-9-]*\s*:/u;
444
+
445
+ /**
446
+ * `text` without the `Workit-Session:` lines of its trailer block: the last
447
+ * paragraph, when every line in it is a trailer or a folded continuation
448
+ * (leading whitespace) of one. Anything else, an indented line included, is
449
+ * prose and is left alone; the rest of the text is returned byte for byte.
450
+ * With `subject`, the first paragraph is a subject and never a trailer
451
+ * block (a commit message); without it (a PR body) it can be.
452
+ */
453
+ export function stripSessionTrailers(text: string, options: { subject: boolean }): string {
454
+ const end = text.length - (/\s*$/u.exec(text)?.[0].length ?? 0);
455
+ const lines = text.slice(0, end).split("\n");
456
+ let start = lines.length;
457
+ while (start > 0 && lines[start - 1]?.trim() !== "") start -= 1;
458
+ if (start === 0 && options.subject) return text;
459
+ const block = lines.slice(start);
460
+ if (!block.length || !TRAILER_LINE.test(block[0] ?? "")) return text;
461
+ if (!block.every((line) => TRAILER_LINE.test(line) || /^\s+\S/u.test(line))) return text;
462
+ const kept: string[] = [];
463
+ let dropping = false;
464
+ for (const line of block) {
465
+ if (TRAILER_LINE.test(line)) dropping = SESSION_KEY.test(line);
466
+ if (!dropping) kept.push(line);
467
+ }
468
+ if (kept.length === block.length) return text;
469
+ let head = lines.slice(0, start);
470
+ if (!kept.length) {
471
+ let cut = head.length;
472
+ while (cut > 0 && head[cut - 1]?.trim() === "") cut -= 1;
473
+ head = head.slice(0, cut);
474
+ }
475
+ return [...head, ...kept].join("\n") + text.slice(end);
476
+ }
477
+
478
+ /** The values of the `Workit-Session:` trailers among `trailers`. */
479
+ const sessionValues = (trailers: readonly string[]): string[] =>
480
+ trailers
481
+ .filter((trailer) => SESSION_KEY.test(trailer))
482
+ .map((trailer) => trailer.slice(trailer.indexOf(":") + 1).trim());
403
483
 
404
484
  // Fixed trailer syntax, whatever the user's trailer.* config says.
405
485
  const TRAILER_CONFIG = ["-c", "trailer.separators=:", "-c", "trailer.where=end"];
@@ -492,6 +572,8 @@ export function gitCommit(
492
572
  amend?: boolean;
493
573
  /** Record a commit even when it changes no file. */
494
574
  allowEmpty?: boolean;
575
+ /** Extra `Key: value` trailers for this commit (`--trailer`, e.g. Co-Authored-By). */
576
+ trailers?: readonly string[];
495
577
  actor: LedgerActor;
496
578
  env?: NodeJS.ProcessEnv;
497
579
  },
@@ -499,6 +581,19 @@ export function gitCommit(
499
581
  const given = input.message?.trim() ?? "";
500
582
  if (!given && !input.amend)
501
583
  return failure("invalid_input", "a commit message is required (-m <msg> or -F <file>)");
584
+ const extra = (input.trailers ?? []).map((trailer) => trailer.trim());
585
+ const malformed = extra.find((trailer) => !EXTRA_TRAILER.test(trailer));
586
+ if (malformed !== undefined)
587
+ return failure(
588
+ "invalid_input",
589
+ `--trailer ${JSON.stringify(malformed)} is not one "Key: value" line`,
590
+ `workit git commit -m '<msg>' --trailer "Co-Authored-By: Name <email>"`,
591
+ );
592
+ if (extra.some((trailer) => /^workit-session:/iu.test(trailer)))
593
+ return failure(
594
+ "invalid_input",
595
+ "--trailer cannot set Workit-Session: workit writes it from the acting session",
596
+ );
502
597
  const paths = input.paths ?? [];
503
598
  if (input.all && paths.length) return failure("invalid_input", "pass --all or paths, not both");
504
599
  const progress = operationInProgress(cwd);
@@ -529,6 +624,7 @@ export function gitCommit(
529
624
  }
530
625
  let message = given;
531
626
  let carried: string[] = [];
627
+ let previousTrailers: string[] = [];
532
628
  if (given) {
533
629
  const lint = lintCommitMessage(cwd, given);
534
630
  if (!lint.ok) return failure("blocked", lint.error, lint.correction);
@@ -538,7 +634,8 @@ export function gitCommit(
538
634
  if (!kept.ok)
539
635
  return failure("failed", `could not read the message of ${branch}: ${gitError(kept)}`);
540
636
  // --no-edit keeps the message whole; a new message carries the old trailers over.
541
- if (given) carried = messageTrailers(cwd, kept.stdout.trim());
637
+ previousTrailers = messageTrailers(cwd, kept.stdout.trim());
638
+ if (given) carried = previousTrailers;
542
639
  else message = kept.stdout.trim();
543
640
  }
544
641
  const session = input.actor.session;
@@ -558,10 +655,36 @@ export function gitCommit(
558
655
  const args = ["commit", "--no-edit"];
559
656
  if (input.amend) args.push("--amend");
560
657
  if (input.allowEmpty || emptyAmend) args.push("--allow-empty");
561
- args.push(
562
- "-m",
563
- withTrailers(cwd, message, [...carried, ...(session ? [`Workit-Session: ${session}`] : [])]),
658
+ // Off (`trailers: false`): authorship is the commit.recorded row alone,
659
+ // and an amend drops the Workit-Session trailers the old commit had.
660
+ const trailersOn = workspaceTrailers(cwd).enabled;
661
+ const sessionTrailer = Boolean(session) && trailersOn;
662
+ if (!trailersOn) {
663
+ carried = carried.filter((trailer) => !SESSION_KEY.test(trailer));
664
+ if (!given) message = stripSessionTrailers(message, { subject: true });
665
+ }
666
+ const finalMessage = withTrailers(cwd, message, [
667
+ ...carried,
668
+ ...extra,
669
+ ...(sessionTrailer ? [`Workit-Session: ${session}`] : []),
670
+ ]);
671
+ // Only workit writes Workit-Session: the acting session's, or (trailers
672
+ // on) the amended commit's own. A message line or a `trailer.<alias>.key`
673
+ // config that expands a --trailer into the key never forges one.
674
+ const allowed = new Set([
675
+ ...(trailersOn ? sessionValues(previousTrailers) : []),
676
+ ...(sessionTrailer && session ? [session] : []),
677
+ ]);
678
+ const forged = sessionValues(messageTrailers(cwd, finalMessage)).find(
679
+ (value) => !allowed.has(value),
564
680
  );
681
+ if (forged !== undefined)
682
+ return failure(
683
+ "invalid_input",
684
+ `forged_session_trailer: the message would carry "Workit-Session: ${forged}", which workit did not add; workit writes that trailer itself`,
685
+ "drop the Workit-Session line (or the trailer.<alias>.key config that expands into it)",
686
+ );
687
+ args.push("-m", finalMessage);
565
688
  if (paths.length) {
566
689
  if (paths.some((value) => value.startsWith("-") || !value.trim()))
567
690
  return failure("invalid_input", "paths must not be empty or start with -");
@@ -602,7 +725,8 @@ export function gitCommit(
602
725
  }
603
726
  const committed = git(cwd, args, {
604
727
  timeoutMs: COMMIT_TIMEOUT_MS,
605
- env: input.env,
728
+ // Decided here: the session hook's helper skips its own `workit git trailers` call.
729
+ env: { ...(input.env ?? process.env), WORKIT_TRAILERS: trailersOn ? "on" : "off" },
606
730
  });
607
731
  if (!committed.ok)
608
732
  return failure(
@@ -658,6 +782,7 @@ export function gitCommit(
658
782
  files,
659
783
  leftDirty,
660
784
  session,
785
+ sessionTrailer,
661
786
  amended: previous,
662
787
  notes,
663
788
  recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
@@ -691,8 +816,10 @@ export function pushPreflight(
691
816
  if (isProtectedTarget(cwd, branch))
692
817
  return failure(
693
818
  "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",
819
+ `protected_branch: ${branch} is protected by the workspace branch policy; workit git push never pushes to it`,
820
+ releaseBranch(cwd, branch)
821
+ ? `workit git push --release ${branch} # finishing a release or hotfix; needs the release grant`
822
+ : "workit git branch <feature/name> # push a feature branch and open a PR",
696
823
  );
697
824
  const sha = resolveRef(cwd, `refs/heads/${branch}`);
698
825
  if (!sha) return failure("not_found", `branch ${branch} has no commit to push`);
@@ -992,6 +1119,229 @@ export function executePush(
992
1119
  });
993
1120
  }
994
1121
 
1122
+ // ---------------------------------------------------------------------------
1123
+ // release push: finishing a git-flow release or hotfix
1124
+
1125
+ export type ReleaseRef = {
1126
+ kind: "branch" | "tag";
1127
+ /** The short name (`main`, `v4.1.0`). */
1128
+ name: string;
1129
+ /** The full ref (`refs/heads/main`, `refs/tags/v4.1.0`). */
1130
+ ref: string;
1131
+ /** The commit it points at (a tag peeled). */
1132
+ sha: string;
1133
+ };
1134
+
1135
+ export type ReleasePushPlan = Omit<PushPlan, "branch" | "sha"> & {
1136
+ refs: ReleaseRef[];
1137
+ /** The branch a pushed tag's commit must already be on (or a branch in this push). */
1138
+ production: string | null;
1139
+ };
1140
+
1141
+ /** A release tag: `v` and a digit (`v4.1.0`, `v2`). */
1142
+ const RELEASE_TAG = /^v\d/u;
1143
+
1144
+ /**
1145
+ * What `workit git push --release` may push: the git-flow production and
1146
+ * develop branches and `v*` tags, nothing else, each by its local ref. Everything else stays on the feature-branch
1147
+ * path (`workit git push`, `workit pr create`).
1148
+ */
1149
+ export function releasePushPreflight(
1150
+ cwd: string,
1151
+ names: readonly string[],
1152
+ ): ForgeResult<ReleasePushPlan> {
1153
+ const progress = operationInProgress(cwd);
1154
+ if (progress) return inProgressFailure(progress.operation, "push");
1155
+ const wanted = [...new Set(names)];
1156
+ if (!wanted.length) return failure("invalid_input", "name the release refs to push");
1157
+ const { preset, branches, production } = releaseBranchesOf(cwd);
1158
+ const refs: ReleaseRef[] = [];
1159
+ for (const raw of wanted) {
1160
+ if (raw.startsWith("-") || raw.includes(":"))
1161
+ return failure("invalid_input", `${raw} is not a ref name (refspecs are not accepted)`);
1162
+ const tagName = raw.replace(/^refs\/tags\//u, "");
1163
+ const branchName = raw.replace(/^refs\/heads\//u, "");
1164
+ const tag = raw.startsWith("refs/heads/") ? null : resolveRef(cwd, `refs/tags/${tagName}`);
1165
+ const branch = raw.startsWith("refs/tags/")
1166
+ ? null
1167
+ : resolveRef(cwd, `refs/heads/${branchName}`);
1168
+ if (tag && branch)
1169
+ return failure(
1170
+ "invalid_input",
1171
+ `${raw} names both a branch and a tag`,
1172
+ `workit git push --release refs/heads/${raw} # or refs/tags/${raw}`,
1173
+ );
1174
+ if (tag) {
1175
+ if (!RELEASE_TAG.test(tagName))
1176
+ return failure(
1177
+ "blocked",
1178
+ `release_push_refused: tag ${tagName} is not a release tag (v<version>)`,
1179
+ "git tag -a v<version> -m <message> # then: workit git push --release v<version>",
1180
+ );
1181
+ refs.push({ kind: "tag", name: tagName, ref: `refs/tags/${tagName}`, sha: tag });
1182
+ continue;
1183
+ }
1184
+ if (!branch) return failure("not_found", `${raw} is not a local branch or tag`);
1185
+ if (!branches.some((name) => name.toLowerCase() === branchName.toLowerCase()))
1186
+ return failure(
1187
+ "blocked",
1188
+ branches.length
1189
+ ? `release_push_refused: ${branchName} is not a git-flow release branch (${branches.join(", ")})`
1190
+ : `release_push_refused: the ${preset} policy lands ${branchName} only through a reviewed PR; workit git push --release pushes v<version> tags there, not branches`,
1191
+ branches.length
1192
+ ? "workit git push # a feature, bugfix, release or hotfix branch, then: workit pr create"
1193
+ : `workit pr create --base ${branchName} # then tag the merged commit and: workit git push --release v<version>`,
1194
+ );
1195
+ refs.push({ kind: "branch", name: branchName, ref: `refs/heads/${branchName}`, sha: branch });
1196
+ }
1197
+ const remote = pushRemoteName(cwd, refs.find((ref) => ref.kind === "branch")?.name ?? null);
1198
+ if (!remote)
1199
+ return failure(
1200
+ "not_found",
1201
+ "no push remote is configured for this repository",
1202
+ "git remote add origin <url>",
1203
+ );
1204
+ const raw = pushUrl(cwd, remote);
1205
+ if (!raw)
1206
+ return failure(
1207
+ "blocked",
1208
+ `remote "${remote}" has no single push URL`,
1209
+ `git remote get-url --push --all ${remote} # keep exactly one push URL`,
1210
+ );
1211
+ const local = parseRemoteUrl(raw)?.protocol === "file";
1212
+ if (!local && !pushTargetIsStable(cwd, raw))
1213
+ return failure(
1214
+ "blocked",
1215
+ `push_target_rewritten: the push URL of "${remote}" is rewritten by a url.*.insteadOf rule, so the destination is not the one checked`,
1216
+ "git config --get-regexp '^url\\.' # remove the rewrite for this remote",
1217
+ );
1218
+ return success({ refs, remote, url: redactRemote(raw), rawUrl: raw, local, production });
1219
+ }
1220
+
1221
+ export type ReleasePushOutcome = {
1222
+ remote: string;
1223
+ url: string;
1224
+ refs: Array<ReleaseRef & { previous: string | null; pushed: boolean }>;
1225
+ recorded: { id: string } | { error: string };
1226
+ };
1227
+
1228
+ /**
1229
+ * Push the planned release refs in one atomic push: a branch only fast
1230
+ * forward, a tag only when the remote lacks it (a release tag never moves),
1231
+ * never forced. Success only when every remote tip observed afterwards is the
1232
+ * local one; recorded as one `release.pushed` row.
1233
+ */
1234
+ export function executeReleasePush(
1235
+ cwd: string,
1236
+ plan: ReleasePushPlan,
1237
+ input: {
1238
+ actor: LedgerActor;
1239
+ grant: string;
1240
+ timeoutMs?: number;
1241
+ identity?: Record<string, unknown>;
1242
+ },
1243
+ ): ForgeResult<ReleasePushOutcome> {
1244
+ const previous = new Map<string, string | null>();
1245
+ for (const ref of plan.refs) {
1246
+ const before = remoteRefTip(cwd, plan.rawUrl, ref.ref);
1247
+ if (!before.ok) return failure(before.code, before.error);
1248
+ previous.set(ref.ref, before.sha);
1249
+ if (before.sha === null || before.sha === ref.sha) continue;
1250
+ if (ref.kind === "tag")
1251
+ return failure(
1252
+ "blocked",
1253
+ `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`,
1254
+ `git tag -d ${ref.name} # tag the next version instead`,
1255
+ );
1256
+ if (!git(cwd, ["merge-base", "--is-ancestor", before.sha, ref.sha]).ok)
1257
+ return failure(
1258
+ "failed",
1259
+ `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`,
1260
+ `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}`,
1261
+ );
1262
+ }
1263
+ // A release tag marks a commit the production branch already has (or a
1264
+ // branch this push moves): never an unmerged feature commit.
1265
+ const tags = plan.refs.filter((ref) => ref.kind === "tag");
1266
+ if (tags.length) {
1267
+ let productionTip: string | null = null;
1268
+ if (plan.production) {
1269
+ const tip = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.production}`);
1270
+ if (!tip.ok) return failure(tip.code, tip.error);
1271
+ productionTip = tip.sha;
1272
+ }
1273
+ const contains = (tip: string, sha: string) =>
1274
+ git(cwd, ["merge-base", "--is-ancestor", sha, tip]).ok;
1275
+ for (const tag of tags) {
1276
+ const released =
1277
+ (productionTip !== null && contains(productionTip, tag.sha)) ||
1278
+ plan.refs.some((ref) => ref.kind === "branch" && contains(ref.sha, tag.sha));
1279
+ if (!released)
1280
+ return failure(
1281
+ "blocked",
1282
+ `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`,
1283
+ `land it through a PR first (workit pr create), git fetch ${plan.remote}, then tag the merged commit`,
1284
+ );
1285
+ }
1286
+ }
1287
+ const pending = plan.refs.filter((ref) => previous.get(ref.ref) !== ref.sha);
1288
+ if (pending.length) {
1289
+ const run = gitNetwork(
1290
+ cwd,
1291
+ [
1292
+ "push",
1293
+ "--porcelain",
1294
+ "--atomic",
1295
+ "--no-follow-tags",
1296
+ "--recurse-submodules=no",
1297
+ plan.remote,
1298
+ ...pending.map((ref) =>
1299
+ ref.kind === "tag" ? `${ref.ref}:${ref.ref}` : `${ref.sha}:${ref.ref}`,
1300
+ ),
1301
+ ],
1302
+ input.timeoutMs ?? PUSH_TIMEOUT_MS,
1303
+ );
1304
+ if (!run.ok)
1305
+ return run.timedOut
1306
+ ? failure("unavailable", `git push to ${plan.url} timed out`)
1307
+ : failure("failed", `git push to ${plan.url} failed:\n${gitOutput(run)}`);
1308
+ }
1309
+ for (const ref of plan.refs) {
1310
+ const after = remoteRefTip(cwd, plan.rawUrl, ref.ref);
1311
+ if (!after.ok) return failure(after.code, `push sent but not verified: ${after.error}`);
1312
+ if (after.sha !== ref.sha)
1313
+ return failure(
1314
+ "failed",
1315
+ `push_unverified: ${plan.remote} ${ref.ref} is at ${after.sha?.slice(0, 12) ?? "(absent)"}, not ${ref.sha.slice(0, 12)}`,
1316
+ );
1317
+ }
1318
+ const refs = plan.refs.map((ref) => ({
1319
+ ...ref,
1320
+ previous: previous.get(ref.ref) ?? null,
1321
+ pushed: pending.includes(ref),
1322
+ }));
1323
+ const row = appendObserved(cwd, {
1324
+ type: "release.pushed",
1325
+ actor: input.actor,
1326
+ remote: plan.remote,
1327
+ url: plan.url,
1328
+ refs: refs.map(({ ref, sha, previous: before, pushed }) => ({
1329
+ ref,
1330
+ sha,
1331
+ previous: before,
1332
+ pushed,
1333
+ })),
1334
+ grant: input.grant,
1335
+ ...(input.identity ? { identity: input.identity } : {}),
1336
+ });
1337
+ return success({
1338
+ remote: plan.remote,
1339
+ url: plan.url,
1340
+ refs,
1341
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
1342
+ });
1343
+ }
1344
+
995
1345
  /**
996
1346
  * Delete a remote branch only while it still points at `expectedTip`
997
1347
  * (`--force-with-lease=refs/heads/<b>:<tip> --delete`). `lease` is false when
@@ -28,6 +28,12 @@
28
28
  // it would commit a trailer-only message instead of aborting); for a
29
29
  // template (git aborts an unedited one); for a comment-only message; and in a
30
30
  // rebase or cherry-pick replaying commits that keep their own trailers.
31
+ // - A workspace with `trailers: false`: the helper adds nothing when
32
+ // WORKIT_TRAILERS=off (set by `workit git commit`) or `workit git trailers`
33
+ // prints `off` (asked with a timeout when workit is on PATH). Without
34
+ // workit it fails open and adds the trailer, so doctor warns about any
35
+ // Workit hook or helper left there and `--fix` removes them
36
+ // (removeSessionHook); another tool's hook and its manual line stay.
31
37
  // - `--if-exists addIfDifferent`: the same session's trailer is never repeated;
32
38
  // another session that amends adds its own, as `workit git commit --amend` does.
33
39
  import { spawnSync } from "node:child_process";
@@ -80,6 +86,19 @@ fi
80
86
  [ -n "$session" ] || exit 0
81
87
  [ \${#session} -le 128 ] || exit 0
82
88
  case "$session" in *[!A-Za-z0-9_.:@/+-]*) exit 0 ;; esac
89
+ # A workspace with \`trailers: false\` keeps authorship in the ledger only.
90
+ # \`workit git commit\` decides it already (WORKIT_TRAILERS); otherwise ask
91
+ # workit, bounded, and add the trailer when it cannot answer.
92
+ case "\${WORKIT_TRAILERS-}" in
93
+ off) exit 0 ;;
94
+ on) ;;
95
+ *)
96
+ if command -v workit >/dev/null 2>&1; then
97
+ t=; ! command -v timeout >/dev/null 2>&1 || t="timeout 5"
98
+ [ "$($t workit git trailers 2>/dev/null)" != off ] || exit 0
99
+ fi
100
+ ;;
101
+ esac
83
102
  add_trailer() {
84
103
  [ -f "$1" ] && [ -r "$1" ] || return 0
85
104
  body=$(sed '/^# -\\{24\\} >8 -\\{24\\}$/,$d' "$1" | git stripspace --strip-comments)
@@ -519,3 +538,52 @@ export function installSessionHook(
519
538
  detail: `${verb} the Workit ${SESSION_HOOK_NAME} hook at ${status.hookPath}${helper}`,
520
539
  };
521
540
  }
541
+
542
+ export type SessionHookRemoval = {
543
+ /** Whether Workit's own hook or helper was deleted. */
544
+ action: "removed" | "unchanged" | "skipped";
545
+ /** The files deleted: Workit's hook (only one carrying its marker) and the helper. */
546
+ removed: string[];
547
+ status: SessionHookStatus;
548
+ detail: string;
549
+ };
550
+
551
+ /** Whether `status` finds Workit's own hook (any version) in the slot. */
552
+ export const workitHookIn = (status: SessionHookStatus): boolean =>
553
+ status.hookPath !== null && (readText(status.hookPath)?.includes(SESSION_HOOK_MARKER) ?? false);
554
+
555
+ /** Whether the helper exists for `status`, current or not. */
556
+ export const helperIn = (status: SessionHookStatus): boolean =>
557
+ status.helperPath !== null && exists(status.helperPath);
558
+
559
+ /**
560
+ * For a workspace with `trailers: false`: delete Workit's own hook (only a
561
+ * slot holding its marker) and the helper. Another tool's hook, a husky
562
+ * script or a lefthook job that runs the helper is never edited; without the
563
+ * helper its manual line runs nothing.
564
+ */
565
+ export function removeSessionHook(
566
+ cwd: string,
567
+ env: NodeJS.ProcessEnv = process.env,
568
+ ): SessionHookRemoval {
569
+ const status = inspectSessionHook(cwd, env);
570
+ if (status.state === "not_git")
571
+ return { action: "skipped", removed: [], status, detail: "not inside a git work tree" };
572
+ const removed: string[] = [];
573
+ if (workitHookIn(status)) {
574
+ rmSync(status.hookPath!, { force: true });
575
+ removed.push(status.hookPath!);
576
+ }
577
+ if (helperIn(status)) {
578
+ rmSync(status.helperPath!, { force: true });
579
+ removed.push(status.helperPath!);
580
+ }
581
+ return {
582
+ action: removed.length ? "removed" : "unchanged",
583
+ removed,
584
+ status: inspectSessionHook(cwd, env),
585
+ detail: removed.length
586
+ ? `removed ${removed.join(" and ")}`
587
+ : "no Workit session hook or helper to remove",
588
+ };
589
+ }
@@ -6,6 +6,8 @@ import {
6
6
  turnContextText,
7
7
  } from "./context";
8
8
  import type { HostDescriptor, Support } from "./descriptor";
9
+ import { branchGuidance } from "../core/branch";
10
+ import { workspaceTrailers } from "../core/workspaces";
9
11
  import { recordIssuedSession } from "../host-session";
10
12
  import { shellPolicy } from "./policy";
11
13
  import {
@@ -77,8 +79,12 @@ const CLAUDE_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]/;
77
79
  * (`workit git …` included) and cannot be turned off, so an isolated agent
78
80
  * runs git itself and workit for everything else.
79
81
  */
80
- const ISOLATED_GIT =
81
- 'Run git as plain, separate commands inside your worktree (`git switch -c <branch> <base>`, `git commit --trailer "Workit-Session=<your session>"`, `git push`; `workit doctor --fix` can install a commit-msg hook that adds the trailer), never `workit git …`: Claude Code\'s worktree isolation refuses a command that wraps git and cannot be turned off (https://code.claude.com/docs/en/worktrees#how-claude-code-enforces-isolation). Never dodge that check by renaming or re-wrapping git. Use workit for the non-git verbs (`workit check`, `workit ledger`, `workit pr`).';
82
+ const isolatedGit = (cwd: string): string =>
83
+ `Run git as plain, separate commands inside your worktree (\`git switch -c <branch> <base>\`, ${
84
+ workspaceTrailers(cwd).enabled
85
+ ? '`git commit --trailer "Workit-Session=<your session>"`, `git push`; `workit doctor --fix` can install a prepare-commit-msg hook that adds the trailer'
86
+ : "`git commit` (this workspace sets trailers: false: add no Workit-Session trailer), `git push`"
87
+ }; add Co-Authored-By with \`git commit --trailer\` in the same commit, never by amending), never \`workit git …\`: Claude Code's worktree isolation refuses a command that wraps git and cannot be turned off (https://code.claude.com/docs/en/worktrees#how-claude-code-enforces-isolation). Never dodge that check by renaming or re-wrapping git. Use workit for the non-git verbs (\`workit check\`, \`workit ledger\`, \`workit pr\`).`;
82
88
 
83
89
  const subagentStartText = (
84
90
  input: HookInput,
@@ -91,13 +97,14 @@ const subagentStartText = (
91
97
  const isolated =
92
98
  descriptor.subagents.worktreeIsolation === "native" &&
93
99
  (role === "implementer" || CLAUDE_WORKTREE.test(input.cwd));
100
+ const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
94
101
  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.`;
102
+ return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${isolatedGit(input.cwd)} 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
103
  if (isolated)
97
- return `${ISOLATED_GIT}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
104
+ return `${isolatedGit(input.cwd)}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
98
105
  if (role === "implementer")
99
106
  // 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.`;
107
+ 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
108
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
102
109
  if (role !== "judge" || !event.agentId) return readOnly;
103
110
  const own = subagentSession(session, event.agentId);
@@ -139,7 +146,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
139
146
  const skillRead = skillReadIn(event.command, event.dialect);
140
147
  if (skillRead) recordSkillLoad(input, skillRead, "read");
141
148
  const canDeny = usable(descriptor.shellPolicy.deny);
142
- const policy = canDeny ? shellPolicy(input.cwd, event.command) : NONE;
149
+ const policy = canDeny ? shellPolicy(input.cwd, event.command, event.dialect) : NONE;
143
150
  if (policy.kind !== "none") return policy;
144
151
  // Without a post-tool event, the previous raw commit is recorded now.
145
152
  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" };