@brainervirus/workit-core 8.11.0 → 8.12.1
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/check-config.ts +3 -1
- package/src/checks.ts +14 -1
- package/src/core/methods.ts +8 -6
- package/src/core/resources.ts +747 -0
- 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 +14 -4
- package/src/forge/gitlab.ts +20 -4
- package/src/forge/pr-ops.ts +99 -2
- package/src/forge/types.ts +8 -1
- package/src/git/ops.ts +101 -6
- package/src/git/session-hook.ts +68 -0
- package/src/hooks/handle.ts +9 -4
- package/src/host-session.ts +69 -21
package/src/core/workspaces.ts
CHANGED
|
@@ -143,6 +143,12 @@ const workspaceConfigSchema = z
|
|
|
143
143
|
defaultEndpoint: z.unknown().optional(),
|
|
144
144
|
/** How a normal-risk behavior change is verified (S17); same rules as grants. */
|
|
145
145
|
verification: z.enum(["self", "independent"]).optional(),
|
|
146
|
+
/**
|
|
147
|
+
* Whether commits get the `Workit-Session:` trailer (default true). With
|
|
148
|
+
* `false`, `workit git commit` and the session hook write none and
|
|
149
|
+
* authorship lives in the ledger only (`commit.recorded`).
|
|
150
|
+
*/
|
|
151
|
+
trailers: z.boolean().optional(),
|
|
146
152
|
/** Autonomy grants (D15): only ever set here, by the user. */
|
|
147
153
|
autonomy: z
|
|
148
154
|
.object(
|
|
@@ -633,8 +639,14 @@ const runtimeWorkspaceIndexSchema = z
|
|
|
633
639
|
/** The workspaces.json entry matching cwd, checked only for name/glob (D17). */
|
|
634
640
|
const matchRuntimeWorkspace = (
|
|
635
641
|
cwd: string,
|
|
636
|
-
|
|
637
|
-
|
|
642
|
+
dir: string = configDir(),
|
|
643
|
+
): {
|
|
644
|
+
file: string;
|
|
645
|
+
selected: Record<string, unknown> & { name: string };
|
|
646
|
+
/** Every entry whose glob matches cwd, the selected one included. */
|
|
647
|
+
matches: Array<Record<string, unknown> & { name: string; glob: string }>;
|
|
648
|
+
} | null => {
|
|
649
|
+
const file = path.join(dir, "workspaces.json");
|
|
638
650
|
let raw: string;
|
|
639
651
|
try {
|
|
640
652
|
raw = readFileSync(file, "utf8");
|
|
@@ -666,7 +678,7 @@ const matchRuntimeWorkspace = (
|
|
|
666
678
|
const matches = matchingEntries(cwd, entries ?? []);
|
|
667
679
|
const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
|
|
668
680
|
const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
|
|
669
|
-
return selected ? { file, selected } : null;
|
|
681
|
+
return selected ? { file, selected, matches } : null;
|
|
670
682
|
};
|
|
671
683
|
|
|
672
684
|
/**
|
|
@@ -683,6 +695,37 @@ export const resolveRuntimeReleaseTracks = (
|
|
|
683
695
|
: null;
|
|
684
696
|
};
|
|
685
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Whether commits in cwd's workspace carry the `Workit-Session:` trailer.
|
|
700
|
+
* The selected workspace's own `trailers` wins; unset, it inherits from the
|
|
701
|
+
* nearest enclosing workspace that also matches cwd (a repo workspace nested
|
|
702
|
+
* in `work`), and defaults to true. Read leniently: no workspace, or an
|
|
703
|
+
* unreadable workspaces.json, keeps the trailer. `from` names the entry the
|
|
704
|
+
* value came from (null for the default).
|
|
705
|
+
*/
|
|
706
|
+
export const workspaceTrailers = (
|
|
707
|
+
cwd: string,
|
|
708
|
+
dir?: string,
|
|
709
|
+
): { enabled: boolean; workspace: string | null; from: string | null } => {
|
|
710
|
+
let matched: ReturnType<typeof matchRuntimeWorkspace>;
|
|
711
|
+
try {
|
|
712
|
+
matched = matchRuntimeWorkspace(cwd, dir);
|
|
713
|
+
} catch {
|
|
714
|
+
return { enabled: true, workspace: null, from: null };
|
|
715
|
+
}
|
|
716
|
+
if (!matched) return { enabled: true, workspace: null, from: null };
|
|
717
|
+
const { selected, matches } = matched;
|
|
718
|
+
const enclosing = matches
|
|
719
|
+
.filter((entry) => entry !== selected)
|
|
720
|
+
.toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
|
|
721
|
+
const source = [selected, ...enclosing].find((entry) => typeof entry.trailers === "boolean");
|
|
722
|
+
return {
|
|
723
|
+
enabled: source?.trailers !== false,
|
|
724
|
+
workspace: selected.name,
|
|
725
|
+
from: source?.name ?? null,
|
|
726
|
+
};
|
|
727
|
+
};
|
|
728
|
+
|
|
686
729
|
const resolveRuntimeWorkspaceCandidate = (
|
|
687
730
|
cwd: string,
|
|
688
731
|
kind: RuntimeWorkspacePolicy,
|
package/src/fanout-brief.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// `<branch>-try<n>` (refused when another slice has that branch), worktree
|
|
8
8
|
// `<worktree>+try<n>` and session `<lead>-w-<slice>+try<n>`: slice ids cannot
|
|
9
9
|
// contain `+`, so no attempt collides with another slice's path or session.
|
|
10
|
+
// The lead's host (WORKIT_HOST, or its own session variable) picks the identity
|
|
11
|
+
// and git rules: Claude Code workers use the WORKIT_SESSION_ID in their env (the lead's: SubagentStart issues ids only to judges) and plain
|
|
12
|
+
// git; OpenCode, Pi and Codex workers export nothing (the host derives the id);
|
|
13
|
+
// Cursor, CI and plain terminals export `<lead>-w-<slice>`.
|
|
10
14
|
// The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
|
|
11
15
|
// worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
|
|
12
16
|
// one PR per slice forbids every merge; integration mode has the worker merge
|
|
@@ -36,7 +40,8 @@ export type BriefOutcome = {
|
|
|
36
40
|
tier: string;
|
|
37
41
|
/** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
|
|
38
42
|
scratch: string;
|
|
39
|
-
|
|
43
|
+
/** The id a worker exports, or null when its host issues or derives it. */
|
|
44
|
+
session: string | null;
|
|
40
45
|
standing: Array<{ id: string; what: string }>;
|
|
41
46
|
text: string;
|
|
42
47
|
};
|
|
@@ -49,15 +54,61 @@ const REPORT = [
|
|
|
49
54
|
" anything out of scope as a follow-up, not a diff.",
|
|
50
55
|
];
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
/**
|
|
58
|
+
* How a worker's identity is settled on the lead's host (host-session.ts,
|
|
59
|
+
* docs/agents/hosts.md):
|
|
60
|
+
* - `issued`: Claude Code. SubagentStart issues ids only to verifiers and
|
|
61
|
+
* reviewers, so an implementer keeps the lead's WORKIT_SESSION_ID from its
|
|
62
|
+
* env; worktree isolation refuses every `workit git` form, so it uses
|
|
63
|
+
* plain git and never mints an id;
|
|
64
|
+
* - `derived`: OpenCode, Pi, Codex. The worker's shell carries the host's own
|
|
65
|
+
* session id, which exporting WORKIT_SESSION_ID cannot replace;
|
|
66
|
+
* - `declared`: Cursor, CI, a plain terminal. Nothing vouches for an id, so
|
|
67
|
+
* the brief declares `<lead>-w-<slice>`.
|
|
68
|
+
*/
|
|
69
|
+
export type BriefIdentity = "issued" | "derived" | "declared";
|
|
70
|
+
|
|
71
|
+
const identityOf = (host: string | null): BriefIdentity => {
|
|
72
|
+
const family = host?.replace(/_(?:cli|desktop)$/, "") ?? "";
|
|
73
|
+
if (family === "claude_code") return "issued";
|
|
74
|
+
if (family === "opencode" || family === "pi" || family === "codex") return "derived";
|
|
75
|
+
return "declared";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const identityLines = (identity: BriefIdentity, session: string): string[] => {
|
|
79
|
+
if (identity === "issued")
|
|
80
|
+
return [
|
|
81
|
+
" Session: your env already has WORKIT_SESSION_ID (the lead's id; SubagentStart issues ids only to verifiers and reviewers). Use it as is, in your Workit-Session trailer and workit commands; never export or hand-mint one.",
|
|
82
|
+
];
|
|
83
|
+
if (identity === "derived")
|
|
84
|
+
return [
|
|
85
|
+
" Session: your host already sets WORKIT_SESSION_ID in your shell; export nothing and never hand-mint one.",
|
|
86
|
+
];
|
|
87
|
+
return [` export WORKIT_SESSION_ID=${session}`];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const rules = (
|
|
91
|
+
identity: BriefIdentity,
|
|
92
|
+
mode: BriefMode,
|
|
93
|
+
branch: string,
|
|
94
|
+
base: string,
|
|
95
|
+
): string[] => {
|
|
96
|
+
const plain = identity === "issued";
|
|
97
|
+
let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
|
|
98
|
+
if (mode === "resume")
|
|
99
|
+
first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
|
|
100
|
+
else if (plain)
|
|
101
|
+
first = ` 1. First command: \`git fetch origin && git switch -c ${branch} origin/${base}\` (plain git: worktree isolation refuses every \`workit git\` form).`;
|
|
102
|
+
return [
|
|
103
|
+
"RULES:",
|
|
104
|
+
first,
|
|
105
|
+
' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
|
|
106
|
+
plain
|
|
107
|
+
? ' 3. Commit with plain `git commit -m <msg> --trailer "Workit-Session: <the WORKIT_SESSION_ID in your env>" --trailer "<your host attribution trailer>" -- <paths in SCOPE>`; push only if this brief says so.'
|
|
108
|
+
: " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
|
|
109
|
+
" 4. Never record a verdict on your own work.",
|
|
110
|
+
];
|
|
111
|
+
};
|
|
61
112
|
|
|
62
113
|
/** A session id fragment that the ledger accepts ([A-Za-z0-9_.:@/+-]). */
|
|
63
114
|
const sessionSafe = (text: string): string => text.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
|
|
@@ -119,7 +170,9 @@ export function renderBrief(
|
|
|
119
170
|
id: row.id,
|
|
120
171
|
what: String(row.what),
|
|
121
172
|
}));
|
|
122
|
-
const
|
|
173
|
+
const identity = identityOf(input.actor.host);
|
|
174
|
+
const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
|
|
175
|
+
const session = identity === "declared" ? minted : null;
|
|
123
176
|
const fanIn =
|
|
124
177
|
plan.fanIn === "integration"
|
|
125
178
|
? [
|
|
@@ -156,8 +209,8 @@ export function renderBrief(
|
|
|
156
209
|
...(standing.length
|
|
157
210
|
? standing.map((order) => ` - ${order.what}`)
|
|
158
211
|
: [" - none recorded beyond this brief"]),
|
|
159
|
-
|
|
160
|
-
...rules(mode, branch, slice.base),
|
|
212
|
+
...identityLines(identity, minted),
|
|
213
|
+
...rules(identity, mode, branch, slice.base),
|
|
161
214
|
];
|
|
162
215
|
return {
|
|
163
216
|
ok: true,
|
package/src/forge/exec.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type ForgeRunner = (
|
|
|
31
31
|
timeoutMs: number;
|
|
32
32
|
/** Credential for this call (GH_TOKEN / GITLAB_TOKEN); never logged. */
|
|
33
33
|
token?: string;
|
|
34
|
+
/** Written to stdin (a JSON body for `api --input -`); argv has a size limit. */
|
|
35
|
+
input?: string;
|
|
34
36
|
},
|
|
35
37
|
) => CliRun;
|
|
36
38
|
|
|
@@ -90,7 +92,9 @@ export function systemRunner(
|
|
|
90
92
|
timeout: options.timeoutMs,
|
|
91
93
|
killSignal: "SIGKILL",
|
|
92
94
|
windowsHide: true,
|
|
93
|
-
|
|
95
|
+
...(options.input === undefined
|
|
96
|
+
? { stdio: ["ignore", "pipe", "pipe"] as const }
|
|
97
|
+
: { input: options.input, stdio: ["pipe", "pipe", "pipe"] as const }),
|
|
94
98
|
});
|
|
95
99
|
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
96
100
|
return {
|
|
@@ -248,8 +252,10 @@ export function apiWrite<T>(
|
|
|
248
252
|
args: readonly string[],
|
|
249
253
|
what: string,
|
|
250
254
|
timeoutMs: number = FORGE_TIMEOUTS.api,
|
|
255
|
+
/** A request body sent on stdin (pair it with `--input -`). */
|
|
256
|
+
input?: string,
|
|
251
257
|
): ForgeResult<T> {
|
|
252
|
-
const run = runner(bin, args, { timeoutMs });
|
|
258
|
+
const run = runner(bin, args, input === undefined ? { timeoutMs } : { timeoutMs, input });
|
|
253
259
|
if (run.status !== 0) {
|
|
254
260
|
const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
|
|
255
261
|
const reason = forgeReason(run);
|
package/src/forge/github.ts
CHANGED
|
@@ -528,6 +528,7 @@ export function createGitHubForge(options: {
|
|
|
528
528
|
},
|
|
529
529
|
|
|
530
530
|
merge(number, mergeOptions) {
|
|
531
|
+
const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
|
|
531
532
|
// sha= is GitHub's atomic head guard: 409 when the head moved.
|
|
532
533
|
const merged = apiWrite<{ sha?: string; merged?: boolean; message?: string }>(
|
|
533
534
|
runner,
|
|
@@ -538,12 +539,21 @@ export function createGitHubForge(options: {
|
|
|
538
539
|
"-X",
|
|
539
540
|
"PUT",
|
|
540
541
|
`repos/${repo}/pulls/${number}/merge`,
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
`merge_method=${mergeOptions.method}`,
|
|
542
|
+
...(squash
|
|
543
|
+
? ["--input", "-"]
|
|
544
|
+
: ["-f", `sha=${mergeOptions.sha}`, "-f", `merge_method=${mergeOptions.method}`]),
|
|
545
545
|
],
|
|
546
546
|
`merge of pull request #${number}`,
|
|
547
|
+
undefined,
|
|
548
|
+
// The squash message rides stdin: argv has a size limit (E2BIG).
|
|
549
|
+
squash
|
|
550
|
+
? JSON.stringify({
|
|
551
|
+
sha: mergeOptions.sha,
|
|
552
|
+
merge_method: mergeOptions.method,
|
|
553
|
+
commit_title: squash.subject,
|
|
554
|
+
commit_message: squash.body,
|
|
555
|
+
})
|
|
556
|
+
: undefined,
|
|
547
557
|
);
|
|
548
558
|
if (!merged.ok) return merged;
|
|
549
559
|
if (merged.data.merged === false)
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -556,6 +556,7 @@ export function createGitLabForge(options: {
|
|
|
556
556
|
},
|
|
557
557
|
|
|
558
558
|
merge(iid, mergeOptions) {
|
|
559
|
+
const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
|
|
559
560
|
// GitLab's merge method is a project setting; squash is per request.
|
|
560
561
|
if (mergeOptions.method === "rebase")
|
|
561
562
|
return failure(
|
|
@@ -573,12 +574,27 @@ export function createGitLabForge(options: {
|
|
|
573
574
|
"-X",
|
|
574
575
|
"PUT",
|
|
575
576
|
`${project}/merge_requests/${iid}/merge`,
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
577
|
+
...(squash
|
|
578
|
+
? ["--input", "-"]
|
|
579
|
+
: [
|
|
580
|
+
"-f",
|
|
581
|
+
`sha=${mergeOptions.sha}`,
|
|
582
|
+
"-F",
|
|
583
|
+
`squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
|
|
584
|
+
]),
|
|
580
585
|
],
|
|
581
586
|
`merge of merge request !${iid}`,
|
|
587
|
+
undefined,
|
|
588
|
+
// The squash message rides stdin: argv has a size limit (E2BIG).
|
|
589
|
+
squash
|
|
590
|
+
? JSON.stringify({
|
|
591
|
+
sha: mergeOptions.sha,
|
|
592
|
+
squash: true,
|
|
593
|
+
squash_commit_message: squash.body
|
|
594
|
+
? `${squash.subject}\n\n${squash.body}`
|
|
595
|
+
: squash.subject,
|
|
596
|
+
})
|
|
597
|
+
: undefined,
|
|
582
598
|
);
|
|
583
599
|
if (!merged.ok) return merged;
|
|
584
600
|
const value = merged.data;
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
|
|
17
17
|
// head that moves after the gates refuses instead of merging unverified code.
|
|
18
18
|
// --delete-branch deletes with a lease on that same SHA.
|
|
19
|
+
// A squash with --subject/--body, or in a workspace with `trailers: false`,
|
|
20
|
+
// sends its own message (the PR title and the branch's commits by default,
|
|
21
|
+
// on stdin) with `Workit-Session:` lines dropped from each trailer block.
|
|
19
22
|
import {
|
|
20
23
|
requireGrant,
|
|
21
24
|
resolveAutonomy,
|
|
@@ -24,8 +27,9 @@ import {
|
|
|
24
27
|
} from "../autonomy";
|
|
25
28
|
import { branchKindOf, flowRoute, gitFlowOf, isProtectedTarget } from "../core/branch";
|
|
26
29
|
import { vcsConfig } from "../core/vcs-config";
|
|
30
|
+
import { workspaceTrailers } from "../core/workspaces";
|
|
27
31
|
import { workspaceReleaseTracks } from "../core/release-tracks";
|
|
28
|
-
import { deleteRemoteBranch } from "../git/ops";
|
|
32
|
+
import { deleteRemoteBranch, stripSessionTrailers } from "../git/ops";
|
|
29
33
|
import { spawnSync } from "node:child_process";
|
|
30
34
|
import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
|
|
31
35
|
import {
|
|
@@ -343,6 +347,9 @@ export type MergeInput = {
|
|
|
343
347
|
expect?: { base: string; branch: string };
|
|
344
348
|
/** Merge without a verdict (`merge: true` only); the reason is recorded. */
|
|
345
349
|
unverified?: { reason: string };
|
|
350
|
+
/** Squash commit subject and body (`--subject`, `--body`); unset parts use the defaults. */
|
|
351
|
+
subject?: string;
|
|
352
|
+
body?: string;
|
|
346
353
|
};
|
|
347
354
|
|
|
348
355
|
export type MergeOutcome = {
|
|
@@ -469,6 +476,90 @@ export function nextHint(next: string, number: number): string | null {
|
|
|
469
476
|
}
|
|
470
477
|
}
|
|
471
478
|
|
|
479
|
+
/**
|
|
480
|
+
* The squash message workit sends, or null to leave the forge default: only a
|
|
481
|
+
* squash with --subject/--body or in a workspace with `trailers: false`.
|
|
482
|
+
* Defaults: the PR title (GitHub appends `(#n)` like its own default) and the
|
|
483
|
+
* branch's commits as GitHub lists them; Workit-Session lines never survive.
|
|
484
|
+
*/
|
|
485
|
+
function squashMessageFor(
|
|
486
|
+
cwd: string,
|
|
487
|
+
resolved: ResolvedForge,
|
|
488
|
+
input: MergeInput,
|
|
489
|
+
doc: PrStatusDoc,
|
|
490
|
+
): ForgeResult<{ subject: string; body: string }> | null {
|
|
491
|
+
if (input.method !== "squash") return null;
|
|
492
|
+
if (input.subject === undefined && input.body === undefined && workspaceTrailers(cwd).enabled)
|
|
493
|
+
return null;
|
|
494
|
+
let subject = input.subject?.trim() ?? "";
|
|
495
|
+
if (!subject) {
|
|
496
|
+
const meta = resolved.forge.prMeta(doc.number);
|
|
497
|
+
if (!meta.ok) return meta;
|
|
498
|
+
subject =
|
|
499
|
+
resolved.forge.kind === "github" ? `${meta.data.title} (#${doc.number})` : meta.data.title;
|
|
500
|
+
}
|
|
501
|
+
// An explicit body is kept byte for byte but for its trailer block.
|
|
502
|
+
const body =
|
|
503
|
+
input.body === undefined
|
|
504
|
+
? squashCommitList(cwd, resolved, doc.base, doc.head.sha)
|
|
505
|
+
: stripSessionTrailers(input.body, { subject: false });
|
|
506
|
+
return success({ subject, body });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Bounds of the generated squash body: a forge message, not an archive. */
|
|
510
|
+
const SQUASH_LIST_MAX_COMMITS = 100;
|
|
511
|
+
const SQUASH_LIST_MAX_CHARS = 60_000;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* `* subject` and body of each non-merge commit on `base..head`, oldest
|
|
515
|
+
* first, from the local clone ("" when absent); each commit loses its
|
|
516
|
+
* Workit-Session trailers, and past the bounds the rest is counted.
|
|
517
|
+
*/
|
|
518
|
+
function squashCommitList(
|
|
519
|
+
cwd: string,
|
|
520
|
+
resolved: ResolvedForge,
|
|
521
|
+
base: string,
|
|
522
|
+
head: string,
|
|
523
|
+
): string {
|
|
524
|
+
const from =
|
|
525
|
+
resolveRef(cwd, `refs/remotes/${resolved.baseRemote}/${base}`) ??
|
|
526
|
+
resolveRef(cwd, `refs/heads/${base}`);
|
|
527
|
+
if (!from) return "";
|
|
528
|
+
const log = spawnSync(
|
|
529
|
+
"git",
|
|
530
|
+
["log", "--reverse", "--no-merges", "--format=%B%x1e", `${from}..${head}`],
|
|
531
|
+
{
|
|
532
|
+
cwd,
|
|
533
|
+
encoding: "utf8",
|
|
534
|
+
timeout: GIT_TIMEOUTS.local,
|
|
535
|
+
killSignal: "SIGKILL",
|
|
536
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
537
|
+
},
|
|
538
|
+
);
|
|
539
|
+
if (log.status !== 0) return "";
|
|
540
|
+
const entries = log.stdout
|
|
541
|
+
.split("\x1e")
|
|
542
|
+
.map((message) => stripSessionTrailers(message.replace(/^\n+/u, ""), { subject: true }))
|
|
543
|
+
.filter((message) => message.trim())
|
|
544
|
+
.map((message) => {
|
|
545
|
+
const [subject = "", ...rest] = message.trimEnd().split("\n");
|
|
546
|
+
const body = rest.join("\n").replace(/^\n+/u, "");
|
|
547
|
+
return body ? `* ${subject}\n\n${body}` : `* ${subject}`;
|
|
548
|
+
});
|
|
549
|
+
const listed: string[] = [];
|
|
550
|
+
let size = 0;
|
|
551
|
+
for (const entry of entries) {
|
|
552
|
+
if (listed.length >= SQUASH_LIST_MAX_COMMITS || size + entry.length > SQUASH_LIST_MAX_CHARS)
|
|
553
|
+
break;
|
|
554
|
+
listed.push(entry);
|
|
555
|
+
size += entry.length + 2;
|
|
556
|
+
}
|
|
557
|
+
const more = entries.length - listed.length;
|
|
558
|
+
return [...listed, ...(more ? [`… and ${more} more commit${more === 1 ? "" : "s"}`] : [])].join(
|
|
559
|
+
"\n\n",
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
472
563
|
export async function mergePullRequest(
|
|
473
564
|
cwd: string,
|
|
474
565
|
resolved: ResolvedForge,
|
|
@@ -596,7 +687,13 @@ export async function mergePullRequest(
|
|
|
596
687
|
{ reason: "needs_verdict", verdict: summary },
|
|
597
688
|
);
|
|
598
689
|
|
|
599
|
-
const
|
|
690
|
+
const squashMessage = squashMessageFor(cwd, resolved, input, doc);
|
|
691
|
+
if (squashMessage && !squashMessage.ok) return squashMessage;
|
|
692
|
+
const merged = resolved.forge.merge(doc.number, {
|
|
693
|
+
sha: head,
|
|
694
|
+
method: input.method,
|
|
695
|
+
...(squashMessage ? { squashMessage: squashMessage.data } : {}),
|
|
696
|
+
});
|
|
600
697
|
if (!merged.ok) return merged;
|
|
601
698
|
// Post-verify: the forge must report the PR merged.
|
|
602
699
|
let state = "open";
|
package/src/forge/types.ts
CHANGED
|
@@ -140,6 +140,13 @@ export type CreatePrInput = {
|
|
|
140
140
|
draft: boolean;
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
+
export type MergeOptions = {
|
|
144
|
+
sha: string;
|
|
145
|
+
method: MergeMethod;
|
|
146
|
+
/** Squash only: the commit message the forge writes instead of its default. */
|
|
147
|
+
squashMessage?: { subject: string; body: string };
|
|
148
|
+
};
|
|
149
|
+
|
|
143
150
|
export type MergeResult = {
|
|
144
151
|
/** The merge (or squash/rebase tip) commit the forge reported, when it did. */
|
|
145
152
|
mergeSha: string | null;
|
|
@@ -187,7 +194,7 @@ export interface Forge {
|
|
|
187
194
|
* Merge with the head guard: the forge refuses (`blocked`, head_moved) when
|
|
188
195
|
* the PR head is no longer `sha`.
|
|
189
196
|
*/
|
|
190
|
-
merge(n: number, options:
|
|
197
|
+
merge(n: number, options: MergeOptions): ForgeResult<MergeResult>;
|
|
191
198
|
/** Retarget a PR/MR to another base branch (S12 stacks). */
|
|
192
199
|
updateBase(n: number, base: string): ForgeResult<void>;
|
|
193
200
|
/** Title, draft flag, base and state of one PR/MR. */
|
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
|
|
@@ -39,6 +41,7 @@ import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
|
|
|
39
41
|
import { pushTargetIsStable } from "../core/pr-create";
|
|
40
42
|
import { recordBranchBase, trackBranchName } from "../core/release-tracks";
|
|
41
43
|
import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
|
|
44
|
+
import { workspaceTrailers } from "../core/workspaces";
|
|
42
45
|
import { redactText } from "../forge/redact";
|
|
43
46
|
import { failure, success, type ForgeResult } from "../forge/types";
|
|
44
47
|
import { appendObserved, readLedger, type LedgerActor } from "../ledger";
|
|
@@ -395,6 +398,8 @@ export type CommitOutcome = {
|
|
|
395
398
|
/** Changes left uncommitted (not selected). */
|
|
396
399
|
leftDirty: number;
|
|
397
400
|
session: string | null;
|
|
401
|
+
/** Whether the commit carries the `Workit-Session:` trailer (workspace `trailers`). */
|
|
402
|
+
sessionTrailer: boolean;
|
|
398
403
|
/** With --amend: the commit this one replaced. */
|
|
399
404
|
amended: string | null;
|
|
400
405
|
/** Follow-ups the agent should know (an amended commit that was already pushed). */
|
|
@@ -430,6 +435,51 @@ export function lintCommitMessage(
|
|
|
430
435
|
}
|
|
431
436
|
|
|
432
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());
|
|
433
483
|
|
|
434
484
|
// Fixed trailer syntax, whatever the user's trailer.* config says.
|
|
435
485
|
const TRAILER_CONFIG = ["-c", "trailer.separators=:", "-c", "trailer.where=end"];
|
|
@@ -522,6 +572,8 @@ export function gitCommit(
|
|
|
522
572
|
amend?: boolean;
|
|
523
573
|
/** Record a commit even when it changes no file. */
|
|
524
574
|
allowEmpty?: boolean;
|
|
575
|
+
/** Extra `Key: value` trailers for this commit (`--trailer`, e.g. Co-Authored-By). */
|
|
576
|
+
trailers?: readonly string[];
|
|
525
577
|
actor: LedgerActor;
|
|
526
578
|
env?: NodeJS.ProcessEnv;
|
|
527
579
|
},
|
|
@@ -529,6 +581,19 @@ export function gitCommit(
|
|
|
529
581
|
const given = input.message?.trim() ?? "";
|
|
530
582
|
if (!given && !input.amend)
|
|
531
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
|
+
);
|
|
532
597
|
const paths = input.paths ?? [];
|
|
533
598
|
if (input.all && paths.length) return failure("invalid_input", "pass --all or paths, not both");
|
|
534
599
|
const progress = operationInProgress(cwd);
|
|
@@ -559,6 +624,7 @@ export function gitCommit(
|
|
|
559
624
|
}
|
|
560
625
|
let message = given;
|
|
561
626
|
let carried: string[] = [];
|
|
627
|
+
let previousTrailers: string[] = [];
|
|
562
628
|
if (given) {
|
|
563
629
|
const lint = lintCommitMessage(cwd, given);
|
|
564
630
|
if (!lint.ok) return failure("blocked", lint.error, lint.correction);
|
|
@@ -568,7 +634,8 @@ export function gitCommit(
|
|
|
568
634
|
if (!kept.ok)
|
|
569
635
|
return failure("failed", `could not read the message of ${branch}: ${gitError(kept)}`);
|
|
570
636
|
// --no-edit keeps the message whole; a new message carries the old trailers over.
|
|
571
|
-
|
|
637
|
+
previousTrailers = messageTrailers(cwd, kept.stdout.trim());
|
|
638
|
+
if (given) carried = previousTrailers;
|
|
572
639
|
else message = kept.stdout.trim();
|
|
573
640
|
}
|
|
574
641
|
const session = input.actor.session;
|
|
@@ -588,10 +655,36 @@ export function gitCommit(
|
|
|
588
655
|
const args = ["commit", "--no-edit"];
|
|
589
656
|
if (input.amend) args.push("--amend");
|
|
590
657
|
if (input.allowEmpty || emptyAmend) args.push("--allow-empty");
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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),
|
|
594
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);
|
|
595
688
|
if (paths.length) {
|
|
596
689
|
if (paths.some((value) => value.startsWith("-") || !value.trim()))
|
|
597
690
|
return failure("invalid_input", "paths must not be empty or start with -");
|
|
@@ -632,7 +725,8 @@ export function gitCommit(
|
|
|
632
725
|
}
|
|
633
726
|
const committed = git(cwd, args, {
|
|
634
727
|
timeoutMs: COMMIT_TIMEOUT_MS,
|
|
635
|
-
|
|
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" },
|
|
636
730
|
});
|
|
637
731
|
if (!committed.ok)
|
|
638
732
|
return failure(
|
|
@@ -688,6 +782,7 @@ export function gitCommit(
|
|
|
688
782
|
files,
|
|
689
783
|
leftDirty,
|
|
690
784
|
session,
|
|
785
|
+
sessionTrailer,
|
|
691
786
|
amended: previous,
|
|
692
787
|
notes,
|
|
693
788
|
recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
|