@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/package.json +1 -1
- package/skills/workit-fanout/references/brief.md +23 -8
- package/skills/workit-fanout/references/orchestration.md +9 -4
- package/src/autonomy.ts +6 -2
- package/src/check-config.ts +3 -1
- package/src/checks.ts +14 -1
- package/src/core/branch-policy.ts +8 -1
- package/src/core/branch.ts +270 -3
- package/src/core/methods.ts +8 -6
- package/src/core/resources.ts +747 -0
- package/src/core/route-intent.ts +155 -27
- package/src/core/workspaces.ts +46 -3
- package/src/fanout-brief.ts +66 -13
- package/src/forge/exec.ts +8 -2
- package/src/forge/github.ts +27 -6
- package/src/forge/gitlab.ts +29 -5
- package/src/forge/pr-ops.ts +125 -10
- package/src/forge/types.ts +13 -1
- package/src/git/ops.ts +360 -10
- package/src/git/session-hook.ts +68 -0
- package/src/hooks/handle.ts +13 -6
- package/src/hooks/policy.ts +11 -5
- package/src/hooks/raw-git.ts +138 -10
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?:
|
|
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
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/git/session-hook.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/hooks/handle.ts
CHANGED
|
@@ -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
|
|
81
|
-
|
|
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. ${
|
|
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 `${
|
|
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>\` (
|
|
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);
|
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" };
|