@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.
@@ -1,37 +1,165 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { inWorkitWorkspace, rawInvocations } from "../hooks/raw-git";
5
+ import type { ShellDialect } from "../hooks/shell-words";
1
6
  import { validateBranchNameFor, type BranchNamePolicyCheck } from "./branch";
2
7
 
3
- // ponytail: recognize direct literal Git forms only; expand coverage if users need shell AST support.
4
- export const shellBranchTarget = (command: string): string | null => {
5
- if (
6
- !command.trim() ||
7
- /[\r\n;&|<>`'"$()\\*?{}~!#]/u.test(command) ||
8
- command.includes("[") ||
9
- command.includes("]")
10
- )
11
- return null;
12
-
13
- const args = command.trim().split(/\s+/u);
14
- if (args[0] !== "git") return null;
15
- const option = args[2];
8
+ /** A name a shell cannot settle before running (`$X`, `` `cmd` ``): never judged. */
9
+ const UNKNOWABLE = /[$`*?[\]{}~]/u;
10
+
11
+ const SWITCH_CREATE = new Set(["-c", "--create", "-C", "--force-create", "--orphan"]);
12
+ const CHECKOUT_CREATE = new Set(["-b", "-B", "--orphan"]);
13
+ const WORKTREE_CREATE = new Set(["-b", "-B"]);
14
+ /** `git branch` options that list, delete or configure: no branch is created. */
15
+ const BRANCH_NO_CREATE = new Set(
16
+ ["-d", "-D", "--delete", "-l", "--list", "-a", "--all", "-r", "--remotes", "--show-current"]
17
+ .concat(["-v", "-vv", "--verbose", "--contains", "--no-contains", "--merged", "--no-merged"])
18
+ .concat(["--points-at", "--sort", "--format", "--column", "--no-column", "-u"])
19
+ .concat(["--set-upstream-to", "--unset-upstream", "--edit-description", "-i", "--ignore-case"]),
20
+ );
21
+ /** `git branch -m|-c [<old>] <new>`: a rename or copy creates `<new>`. */
22
+ const BRANCH_RENAME = new Set(["-m", "-M", "--move", "-c", "-C", "--copy"]);
23
+
24
+ /** The value of `flag` in `args` (`-c x`, `-cx`, `--create=x`), or null. */
25
+ const flagValue = (args: readonly string[], flags: ReadonlySet<string>): string | null => {
26
+ for (let index = 1; index < args.length; index++) {
27
+ const arg = args[index];
28
+ if (arg === "--") return null;
29
+ if (flags.has(arg)) return args[index + 1] ?? null;
30
+ const eq = arg.indexOf("=");
31
+ if (arg.startsWith("--") && eq > 0 && flags.has(arg.slice(0, eq))) return arg.slice(eq + 1);
32
+ // An attached short value: `-cchore/x`.
33
+ if (!arg.startsWith("--") && arg.length > 2 && flags.has(arg.slice(0, 2))) return arg.slice(2);
34
+ }
35
+ return null;
36
+ };
37
+
38
+ /** `git worktree add` options that take a value. */
39
+ const WORKTREE_VALUES = new Set(["--reason"]);
40
+
41
+ /**
42
+ * `git worktree add [-b|-B <b>] <path> [<commit-ish>]`: the -b name, or, with
43
+ * only a path (no -b, -B, --detach or commit-ish), the branch git names after
44
+ * the path's last component.
45
+ */
46
+ const worktreeTarget = (args: readonly string[]): string | null => {
47
+ const named = flagValue(args, WORKTREE_CREATE);
48
+ if (named !== null) return named;
49
+ const positionals: string[] = [];
50
+ for (let index = 1; index < args.length; index++) {
51
+ const arg = args[index];
52
+ if (arg === "--detach" || arg === "-d") return null;
53
+ if (WORKTREE_VALUES.has(arg)) index++;
54
+ else if (!arg.startsWith("-")) positionals.push(arg);
55
+ }
56
+ if (positionals.length !== 1) return null;
57
+ const base = positionals[0].split(/[\\/]/u).findLast(Boolean) ?? "";
58
+ return base && base !== "." && base !== ".." ? base : null;
59
+ };
60
+
61
+ /** The branch `git branch …` creates (plain, rename or copy), or null. */
62
+ const branchCommandTarget = (args: readonly string[]): string | null => {
63
+ let rename = false;
64
+ const positionals: string[] = [];
65
+ for (let index = 1; index < args.length; index++) {
66
+ const arg = args[index];
67
+ if (arg === "--") {
68
+ positionals.push(...args.slice(index + 1));
69
+ break;
70
+ }
71
+ const flag = arg.split("=", 1)[0];
72
+ if (BRANCH_NO_CREATE.has(flag)) return null;
73
+ if (BRANCH_RENAME.has(flag)) rename = true;
74
+ else if (!arg.startsWith("-")) positionals.push(arg);
75
+ }
76
+ if (positionals.length < 1 || positionals.length > 2) return null;
77
+ return rename ? (positionals.at(-1) ?? null) : positionals[0];
78
+ };
79
+
80
+ /**
81
+ * The branch one git invocation creates: `switch -c|-C|--create <b>`,
82
+ * `checkout -b|-B <b>`, `branch [-m|-c] <b> [start]` and `worktree add -b|-B
83
+ * <b>`, wherever the option stands. `args` start at the subcommand (after
84
+ * git's global options). Null when it creates none.
85
+ */
86
+ export const gitBranchCreation = (args: readonly string[]): string | null => {
87
+ const [sub, second] = args;
16
88
  const target =
17
- args[1] === "switch" &&
18
- ["-c", "--create", "-C", "--force-create"].includes(option ?? "") &&
19
- (args.length === 4 || args.length === 5)
20
- ? args[3]
21
- : args[1] === "checkout" &&
22
- ["-b", "-B"].includes(option ?? "") &&
23
- (args.length === 4 || args.length === 5)
24
- ? args[3]
25
- : args[1] === "branch" && (args.length === 3 || args.length === 4)
26
- ? args[2]
27
- : null;
28
- return target && !target.startsWith("-") ? target : null;
89
+ sub === "switch"
90
+ ? flagValue(args, SWITCH_CREATE)
91
+ : sub === "checkout"
92
+ ? flagValue(args, CHECKOUT_CREATE)
93
+ : sub === "branch"
94
+ ? branchCommandTarget(args)
95
+ : sub === "worktree" && second === "add"
96
+ ? worktreeTarget(args.slice(1))
97
+ : null;
98
+ return target && !target.startsWith("-") && !UNKNOWABLE.test(target) ? target : null;
99
+ };
100
+
101
+ export type ShellBranchTarget = {
102
+ branch: string;
103
+ /** The directory git runs in, relative to the hook's cwd; undefined when unknowable. */
104
+ dir: string | null | undefined;
105
+ };
106
+
107
+ /**
108
+ * Every branch a shell command creates, through the raw-git tokenizer: `&&`,
109
+ * `;` and `|` chains, `cd`/`pushd` and `git -C`, env prefixes and wrappers,
110
+ * quoted names and `bash -c` scripts.
111
+ */
112
+ export const shellBranchTargets = (
113
+ command: string,
114
+ dialect: ShellDialect = "posix",
115
+ ): ShellBranchTarget[] => {
116
+ if (!/\bgit\b/u.test(command)) return [];
117
+ const found: ShellBranchTarget[] = [];
118
+ for (const invocation of rawInvocations(command, dialect)) {
119
+ if (invocation.tool !== "git") continue;
120
+ const branch = gitBranchCreation(invocation.args);
121
+ if (branch) found.push({ branch, dir: invocation.dir });
122
+ }
123
+ return found;
29
124
  };
30
125
 
126
+ /** The first branch a shell command creates, or null. */
127
+ export const shellBranchTarget = (command: string): string | null =>
128
+ shellBranchTargets(command)[0]?.branch ?? null;
129
+
130
+ /** The branch already exists in `dir`: resetting or checking it out is not a new name. */
131
+ const localBranchExists = (dir: string, branch: string): boolean =>
132
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
133
+ cwd: dir,
134
+ stdio: "ignore",
135
+ timeout: 5_000,
136
+ }).status === 0;
137
+
138
+ /**
139
+ * The branch policy verdict for every new branch a shell command creates,
140
+ * each checked in the directory git runs in (a linked worktree resolves to
141
+ * its main checkout's workspace). A command that runs in the hook's cwd is
142
+ * judged there. One that changes directory is judged only when that
143
+ * directory is known, already exists and is in a Workit workspace: a scratch
144
+ * repository (`cd "$(mktemp -d)"`, `git init x && cd x`, a fresh clone) is
145
+ * not the cwd workspace's business. A name that already exists locally is
146
+ * skipped (`git branch -f legacy HEAD`). Null when no new branch is judged;
147
+ * the first violation otherwise.
148
+ */
31
149
  export const shellBranchPolicyViolation = (
32
150
  root: string,
33
151
  command: string,
152
+ dialect: ShellDialect = "posix",
34
153
  ): BranchNamePolicyCheck | null => {
35
- const target = shellBranchTarget(command);
36
- return target === null ? null : validateBranchNameFor(root, target);
154
+ let judged = false;
155
+ for (const target of shellBranchTargets(command, dialect)) {
156
+ if (target.dir === undefined) continue;
157
+ const dir = target.dir === null ? root : path.resolve(root, target.dir);
158
+ if (target.dir !== null && (!existsSync(dir) || !inWorkitWorkspace(dir))) continue;
159
+ if (localBranchExists(dir, target.branch)) continue;
160
+ judged = true;
161
+ const check = validateBranchNameFor(dir, target.branch);
162
+ if (!check.ok) return check;
163
+ }
164
+ return judged ? { ok: true } : null;
37
165
  };
@@ -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
- ): { file: string; selected: Record<string, unknown> & { name: string } } | null => {
637
- const file = path.join(configDir(), "workspaces.json");
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,
@@ -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
- session: string;
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
- const rules = (mode: BriefMode, branch: string, base: string): string[] => [
53
- "RULES:",
54
- mode === "new"
55
- ? ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`
56
- : ` 1. First command: \`git switch ${branch}\`, then continue from its head.`,
57
- ' 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.',
58
- " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
59
- " 4. Never record a verdict on your own work.",
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 session = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
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
- ` export WORKIT_SESSION_ID=${session}`,
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
- stdio: ["ignore", "pipe", "pipe"],
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);
@@ -27,7 +27,7 @@ const CONTEXTS = `contexts(first: 100, after: $contexts) { pageInfo { hasNextPag
27
27
 
28
28
  export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threads: String, $contexts: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_FIELDS} ${THREADS} commits(last: 1) { nodes { commit { oid statusCheckRollup { state ${CONTEXTS} } } } } } } }`;
29
29
 
30
- export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state headRefName headRefOid headRepositoryOwner { login } } } } }`;
30
+ export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
31
31
 
32
32
  const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
33
33
  const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
@@ -351,6 +351,7 @@ export function createGitHubForge(options: {
351
351
  number: number;
352
352
  url: string;
353
353
  state: string;
354
+ baseRefName?: string;
354
355
  headRefName: string;
355
356
  headRefOid: string | null;
356
357
  headRepositoryOwner: { login: string } | null;
@@ -365,11 +366,21 @@ export function createGitHubForge(options: {
365
366
  const ours = nodes.filter(
366
367
  (node) =>
367
368
  node.headRefName === head &&
369
+ (!match.base || node.baseRefName === undefined || node.baseRefName === match.base) &&
368
370
  ((match.owner !== null &&
369
371
  node.headRepositoryOwner?.login.toLowerCase() === match.owner.toLowerCase()) ||
370
372
  (match.sha !== null && node.headRefOid === match.sha)),
371
373
  );
372
- const pick = ours.find((node) => node.state === "OPEN") ?? ours[0];
374
+ const open = ours.filter((node) => node.state === "OPEN");
375
+ // No base named and open PRs into several bases (a git-flow release
376
+ // into main and its back-merge into develop): never guess.
377
+ if (!match.base && new Set(open.map((node) => node.baseRefName)).size > 1)
378
+ return failure(
379
+ "blocked",
380
+ `ambiguous_pr: ${head} has open pull requests ${open.map((node) => `#${node.number} -> ${node.baseRefName}`).join(", ")}`,
381
+ `pass --pr <n>, e.g. --pr ${open[0].number}`,
382
+ );
383
+ const pick = open[0] ?? ours[0];
373
384
  if (!pick) return success(null);
374
385
  const ref: PrRef = {
375
386
  number: pick.number,
@@ -517,6 +528,7 @@ export function createGitHubForge(options: {
517
528
  },
518
529
 
519
530
  merge(number, mergeOptions) {
531
+ const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
520
532
  // sha= is GitHub's atomic head guard: 409 when the head moved.
521
533
  const merged = apiWrite<{ sha?: string; merged?: boolean; message?: string }>(
522
534
  runner,
@@ -527,12 +539,21 @@ export function createGitHubForge(options: {
527
539
  "-X",
528
540
  "PUT",
529
541
  `repos/${repo}/pulls/${number}/merge`,
530
- "-f",
531
- `sha=${mergeOptions.sha}`,
532
- "-f",
533
- `merge_method=${mergeOptions.method}`,
542
+ ...(squash
543
+ ? ["--input", "-"]
544
+ : ["-f", `sha=${mergeOptions.sha}`, "-f", `merge_method=${mergeOptions.method}`]),
534
545
  ],
535
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,
536
557
  );
537
558
  if (!merged.ok) return merged;
538
559
  if (merged.data.merged === false)
@@ -327,10 +327,18 @@ export function createGitLabForge(options: {
327
327
  const matches = list.data.filter(
328
328
  (mr) =>
329
329
  mr.source_branch === head &&
330
+ (!match.base || mr.target_branch === undefined || mr.target_branch === match.base) &&
330
331
  ((match.projectId !== null && mr.source_project_id === match.projectId) ||
331
332
  (match.sha !== null && mr.sha === match.sha)),
332
333
  );
333
- const pick = matches.find((mr) => mr.state === "opened") ?? matches[0];
334
+ const open = matches.filter((mr) => mr.state === "opened");
335
+ if (!match.base && new Set(open.map((mr) => mr.target_branch)).size > 1)
336
+ return failure(
337
+ "blocked",
338
+ `ambiguous_pr: ${head} has open merge requests ${open.map((mr) => `!${mr.iid} -> ${mr.target_branch}`).join(", ")}`,
339
+ `pass --pr <n>, e.g. --pr ${open[0].iid}`,
340
+ );
341
+ const pick = open[0] ?? matches[0];
334
342
  return success(
335
343
  pick
336
344
  ? {
@@ -548,6 +556,7 @@ export function createGitLabForge(options: {
548
556
  },
549
557
 
550
558
  merge(iid, mergeOptions) {
559
+ const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
551
560
  // GitLab's merge method is a project setting; squash is per request.
552
561
  if (mergeOptions.method === "rebase")
553
562
  return failure(
@@ -565,12 +574,27 @@ export function createGitLabForge(options: {
565
574
  "-X",
566
575
  "PUT",
567
576
  `${project}/merge_requests/${iid}/merge`,
568
- "-f",
569
- `sha=${mergeOptions.sha}`,
570
- "-F",
571
- `squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
577
+ ...(squash
578
+ ? ["--input", "-"]
579
+ : [
580
+ "-f",
581
+ `sha=${mergeOptions.sha}`,
582
+ "-F",
583
+ `squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
584
+ ]),
572
585
  ],
573
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,
574
598
  );
575
599
  if (!merged.ok) return merged;
576
600
  const value = merged.data;