@henryqw/pi-pr 4.0.3 → 4.0.5

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,8 +1,9 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, realpath } from "node:fs/promises";
3
- import { resolve } from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, realpath } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { extensionConfigDir } from "@henryqw/pi-config-store";
6
+ import { lock } from "proper-lockfile";
6
7
 
7
8
  export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
8
9
  export const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
@@ -13,10 +14,7 @@ export const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
13
14
  const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
14
15
  const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
15
16
  const PROCESS_TERMINATION_GRACE_MS = 250;
16
- const LOCK_OWNER_MAX_BYTES = 128;
17
- const LOCK_NONCE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
18
- const LOCK_OWNER_PAYLOAD = /^pid=([1-9][0-9]*)\nnonce=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/;
19
- const LOCK_REF_PREFIX = "refs/pi-pr/worktree-locks";
17
+ const WORKTREE_LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 } as const;
20
18
 
21
19
  export type ExecResult = {
22
20
  stdout: string;
@@ -265,135 +263,7 @@ export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise
265
263
  return status.stdout === "" ? "clean" : "dirty";
266
264
  }
267
265
 
268
- type WorktreeLockOwner = {
269
- oid: string;
270
- pid: number;
271
- };
272
-
273
- function gitFailure(args: string[], result: ExecResult): Error {
274
- const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
275
- return new Error(`${commandText("git", args)} failed: ${detail}`);
276
- }
277
-
278
- function oidLine(output: string, label: string): string {
279
- const normalized = output.replace(/\r\n/g, "\n");
280
- if (!normalized.endsWith("\n") || normalized.slice(0, -1).includes("\n")) {
281
- throw new Error(`${label} returned invalid output`);
282
- }
283
- return requiredOid(normalized.slice(0, -1), label);
284
- }
285
-
286
- function lockOwnerPid(payload: string): number {
287
- const match = LOCK_OWNER_PAYLOAD.exec(payload);
288
- if (!match || match[0] !== payload) throw new Error("Git worktree lock owner object is invalid");
289
- const pid = Number(match[1]);
290
- if (!Number.isSafeInteger(pid) || pid <= 0 || String(pid) !== match[1]) {
291
- throw new Error("Git worktree lock owner PID is invalid");
292
- }
293
- return pid;
294
- }
295
-
296
- function ownerIsDead(pid: number): boolean {
297
- positiveInteger(pid, "Git worktree lock owner PID");
298
- try {
299
- process.kill(pid, 0);
300
- return false;
301
- } catch (error) {
302
- return !!error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ESRCH";
303
- }
304
- }
305
-
306
- function zeroOid(oid: string): string {
307
- return requiredOid(oid, "Git worktree lock owner OID").replace(/[0-9a-f]/g, "0");
308
- }
309
-
310
- async function readWorktreeLock(cwd: string, lockRef: string, signal?: AbortSignal): Promise<WorktreeLockOwner | null> {
311
- const symbolicArgs = ["symbolic-ref", "-q", lockRef];
312
- const symbolic = await spawnBounded("git", symbolicArgs, { cwd, signal });
313
- if (symbolic.killed || (symbolic.code !== 0 && symbolic.code !== 1)) throw gitFailure(symbolicArgs, symbolic);
314
- if (symbolic.code === 0) throw new Error("Git worktree lock ref is symbolic");
315
- if (symbolic.stdout !== "" || symbolic.stderr !== "") throw new Error("Git worktree lock symbolic-ref check returned invalid output");
316
-
317
- const refArgs = ["rev-parse", "--verify", "--quiet", lockRef];
318
- const ref = await spawnBounded("git", refArgs, { cwd, signal });
319
- if (ref.killed || (ref.code !== 0 && ref.code !== 1)) throw gitFailure(refArgs, ref);
320
- if (ref.code === 1) {
321
- if (ref.stdout !== "" || ref.stderr !== "") throw new Error("Git worktree lock ref lookup returned invalid output");
322
- return null;
323
- }
324
- if (ref.stderr !== "") throw new Error("Git worktree lock ref lookup returned invalid output");
325
- const oid = oidLine(ref.stdout, "Git worktree lock ref");
326
-
327
- const objectArgs = ["cat-file", "blob", oid];
328
- const object = await runChecked(spawnBounded, "git", objectArgs, {
329
- cwd,
330
- signal,
331
- stdoutLimitBytes: LOCK_OWNER_MAX_BYTES,
332
- stderrLimitBytes: LOCK_OWNER_MAX_BYTES,
333
- });
334
- if (object.stderr !== "") throw new Error("Git worktree lock owner object returned invalid output");
335
- return { oid, pid: lockOwnerPid(object.stdout) };
336
- }
337
-
338
- async function updateWorktreeLock(cwd: string, args: string[]): Promise<boolean> {
339
- const result = await spawnBounded("git", args, { cwd });
340
- if (result.killed) throw gitFailure(args, result);
341
- return result.code === 0;
342
- }
343
-
344
- async function createWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<boolean> {
345
- return await updateWorktreeLock(cwd, ["update-ref", "--no-deref", lockRef, ownerOid, zeroOid(ownerOid)]);
346
- }
347
-
348
- async function deleteWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<boolean> {
349
- return await updateWorktreeLock(cwd, ["update-ref", "--no-deref", "-d", lockRef, ownerOid]);
350
- }
351
-
352
- async function releaseWorktreeLock(cwd: string, lockRef: string, ownerOid: string): Promise<void> {
353
- const args = ["update-ref", "--no-deref", "-d", lockRef, ownerOid];
354
- const result = await spawnBounded("git", args, { cwd });
355
- if (result.killed || result.code !== 0) throw gitFailure(args, result);
356
- }
357
-
358
- async function acquireWorktreeLock(
359
- cwd: string,
360
- canonical: string,
361
- lockRef: string,
362
- ownerOid: string,
363
- signal?: AbortSignal,
364
- ): Promise<void> {
365
- signal?.throwIfAborted();
366
- if (await createWorktreeLock(cwd, lockRef, ownerOid)) return;
367
- signal?.throwIfAborted();
368
- const observed = await readWorktreeLock(cwd, lockRef, signal);
369
- if (!observed) throw new Error("Git worktree lock acquisition failed without a competing owner");
370
- if (!ownerIsDead(observed.pid)) throw new Error(`Another pi-pr mutation is active for ${canonical}`);
371
- signal?.throwIfAborted();
372
- const reclaimed = await deleteWorktreeLock(cwd, lockRef, observed.oid);
373
- signal?.throwIfAborted();
374
- if (await createWorktreeLock(cwd, lockRef, ownerOid)) return;
375
- const replacement = await readWorktreeLock(cwd, lockRef, signal);
376
- if (!replacement) throw new Error("Git worktree lock disappeared during stale owner recovery");
377
- if (!ownerIsDead(replacement.pid)) throw new Error(`Another pi-pr mutation is active for ${canonical}`);
378
- if (replacement.oid !== observed.oid) throw new Error("Git worktree lock changed during stale owner recovery");
379
- if (!reclaimed) throw new Error("Git worktree stale lock owner could not be reclaimed");
380
- throw new Error("Git worktree stale lock owner remained after reclamation");
381
- }
382
-
383
- async function writeWorktreeLockOwner(cwd: string, signal?: AbortSignal): Promise<string> {
384
- const pid = positiveInteger(process.pid, "process PID");
385
- const nonce = randomUUID();
386
- if (!LOCK_NONCE.test(nonce)) throw new Error("Generated Git worktree lock nonce is invalid");
387
- const result = await runChecked(spawnBounded, "git", ["hash-object", "-w", "--stdin", "--no-filters"], {
388
- cwd,
389
- signal,
390
- stdin: `pid=${pid}\nnonce=${nonce}\n`,
391
- });
392
- if (result.stderr !== "") throw new Error("Git worktree lock owner creation returned invalid output");
393
- return oidLine(result.stdout, "Git worktree lock owner");
394
- }
395
-
396
- /** Exclude concurrent PR mutations for one canonical worktree and pi-pr namespace with a Git-ref lease. */
266
+ /** Exclude concurrent PR mutations for one canonical worktree and pi-pr namespace. */
397
267
  export async function withWorktreeLock<T>(
398
268
  cwd: string,
399
269
  operation: () => Promise<T>,
@@ -409,17 +279,29 @@ export async function withWorktreeLock<T>(
409
279
  if (rootLines.length !== 1 || !rootLines[0]) throw new Error("Git worktree root resolution returned invalid output");
410
280
  const canonical = requiredText(await realpath(rootLines[0]), "canonical Git worktree root");
411
281
  const lockNamespace = resolve(extensionConfigDir("pi-pr", options.agentDir));
412
- const identity = createHash("sha256").update(lockNamespace).update("\0").update(canonical).digest("hex");
413
- const lockRef = `${LOCK_REF_PREFIX}/${identity}`;
414
- const ownerOid = await writeWorktreeLockOwner(canonical, options.signal);
415
- let acquired = false;
282
+ const lockDirectory = join(lockNamespace, "worktree-locks");
283
+ const identity = createHash("sha256").update(canonical).digest("hex");
284
+ const lockPath = join(lockDirectory, identity);
285
+ options.signal?.throwIfAborted();
286
+ await mkdir(lockDirectory, { recursive: true, mode: 0o700 });
287
+ options.signal?.throwIfAborted();
288
+ let release: () => Promise<void>;
289
+ try {
290
+ release = await lock(lockPath, {
291
+ ...WORKTREE_LOCK_OPTIONS,
292
+ lockfilePath: `${lockPath}.lock`,
293
+ });
294
+ } catch (error) {
295
+ if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ELOCKED") {
296
+ throw new Error("Another pi-pr mutation is active", { cause: error });
297
+ }
298
+ throw error;
299
+ }
416
300
  try {
417
- await acquireWorktreeLock(canonical, canonical, lockRef, ownerOid, options.signal);
418
- acquired = true;
419
301
  options.signal?.throwIfAborted();
420
302
  return await operation();
421
303
  } finally {
422
- if (acquired) await releaseWorktreeLock(canonical, lockRef, ownerOid);
304
+ await release();
423
305
  }
424
306
  }
425
307
 
@@ -535,24 +417,7 @@ export async function resolveRepositoryFetchSource(
535
417
  if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) throw new TypeError("repository must be OWNER/REPOSITORY");
536
418
  const protocol = (await runChecked(exec, "gh", ["config", "get", "git_protocol", "--host", host], options)).stdout.trim();
537
419
  if (protocol !== "https" && protocol !== "ssh") throw new Error("GitHub CLI git protocol must be https or ssh");
538
- const response = await runChecked(exec, "gh", [
539
- "api", "--hostname", host,
540
- "-H", "Accept: application/vnd.github+json",
541
- "-H", "X-GitHub-Api-Version: 2022-11-28",
542
- `repos/${repository}`,
543
- ], options);
544
- let value: unknown;
545
- try {
546
- value = JSON.parse(response.stdout);
547
- } catch {
548
- throw new Error("Read repository URLs returned invalid JSON");
549
- }
550
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Read repository URLs returned invalid JSON");
551
- const record = value as Record<string, unknown>;
552
- const expectedHttps = `https://${host}/${repository}.git`;
553
- const expectedSsh = `git@${host}:${repository}.git`;
554
- if (record.clone_url !== expectedHttps || record.ssh_url !== expectedSsh) {
555
- throw new Error("Read repository URLs did not match frozen repository authority");
556
- }
557
- return protocol === "https" ? expectedHttps : expectedSsh;
420
+ return protocol === "https"
421
+ ? `https://${host}/${repository}.git`
422
+ : `git@${host}:${repository}.git`;
558
423
  }
@@ -15,7 +15,6 @@ import type {
15
15
  PullRequestLifecycle,
16
16
  PullRequestTarget,
17
17
  ReviewReadiness,
18
- PolicyReadiness,
19
18
  } from "./pr-routing.ts";
20
19
 
21
20
  const EXEC_TIMEOUT_MS = 10_000;
@@ -27,7 +26,6 @@ const PR_PUBLICATION_FIELDS = "number,url,state,baseRefName,headRefName,headRefO
27
26
  const PR_DISCOVERY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!,$endCursor:String){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name associatedPullRequests(first:100,after:$endCursor){totalCount edges{cursor node{__typename number url state baseRepository{nameWithOwner}headRepository{nameWithOwner}headRefName headRefOid}}pageInfo{hasNextPage startCursor endCursor}}}}}";
28
27
  const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
29
28
  const BASE_REF_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name target{oid}}}}";
30
- const BASE_BRANCH_POLICY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name branchProtectionRule{requiresStrictStatusChecks}}}}";
31
29
  const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
32
30
  const FAILED_CHECK_STATES = new Set([
33
31
  "ACTION_REQUIRED",
@@ -60,7 +58,6 @@ const MERGE_STATE_VALUES = new Set([
60
58
  "UNSTABLE",
61
59
  ]);
62
60
  const REVIEW_DECISION_VALUES = new Set(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]);
63
- const MERGE_METHODS: MergeMethod[] = ["merge", "rebase", "squash"];
64
61
 
65
62
  export class PullRequestLoadError extends Error {
66
63
  constructor(message: string) {
@@ -75,13 +72,6 @@ export type PullRequestRef = {
75
72
  oid: string;
76
73
  };
77
74
 
78
- export type MergeMethod = "merge" | "rebase" | "squash";
79
-
80
- export type PullRequestMerge = {
81
- allowedMergeMethods: MergeMethod[];
82
- viewerDefaultMergeMethod: MergeMethod;
83
- };
84
-
85
75
  export type CurrentPullRequest = PullRequest & {
86
76
  id: string;
87
77
  number: number;
@@ -92,7 +82,6 @@ export type CurrentPullRequest = PullRequest & {
92
82
  head: PullRequestRef;
93
83
  headFetchSource: string;
94
84
  target: PullRequestTarget;
95
- merge: PullRequestMerge | null;
96
85
  };
97
86
 
98
87
  export type CurrentPullRequestDiscovery = PullRequestDiscovery<CurrentPullRequest>;
@@ -118,6 +107,10 @@ export type PullRequestCreationPreflight = {
118
107
  ahead: number;
119
108
  };
120
109
 
110
+ type CreationPreflightResult =
111
+ | { kind: "same-ref" }
112
+ | { kind: "distinct-ref"; preflight: PullRequestCreationPreflight };
113
+
121
114
  type CreationIdentity = {
122
115
  target: PullRequestTarget;
123
116
  head: string;
@@ -225,11 +218,6 @@ type SearchPage = {
225
218
  endCursor: string | null;
226
219
  };
227
220
 
228
- type RulesetBranchPolicy = {
229
- requiresStrictStatusChecks: boolean;
230
- allowedMergeMethods: MergeMethod[] | null;
231
- };
232
-
233
221
  type CheckState = {
234
222
  state: string;
235
223
  diagnosableFailure: boolean;
@@ -894,7 +882,6 @@ function ciStatus(checks: CheckState[]): CiStatus {
894
882
  function conditions(
895
883
  candidate: ListedPullRequest,
896
884
  unresolvedThreads: number,
897
- requiresStrictStatusChecks: boolean,
898
885
  ): PullRequestConditions {
899
886
  if (
900
887
  (candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "DIRTY") ||
@@ -904,19 +891,17 @@ function conditions(
904
891
  ? "pending"
905
892
  : "ready";
906
893
  const behind = candidate.mergeStateStatus === "BEHIND";
907
- const policy: PolicyReadiness = candidate.mergeable === "MERGEABLE" &&
908
- (candidate.mergeStateStatus === "CLEAN" || (behind && !requiresStrictStatusChecks))
909
- ? "ready"
910
- : "pending";
911
894
  return {
912
895
  draft: candidate.isDraft,
913
- baseUpdateRequired: behind && requiresStrictStatusChecks,
896
+ baseUpdateRequired: behind,
914
897
  conflict: candidate.mergeable === "CONFLICTING" || candidate.mergeStateStatus === "DIRTY",
915
898
  changesRequested: candidate.reviewDecision === "CHANGES_REQUESTED",
916
899
  unresolvedThreads,
917
900
  ci: ciStatus(candidate.checkStates),
918
901
  review,
919
- policy,
902
+ policy: candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "CLEAN"
903
+ ? "ready"
904
+ : "pending",
920
905
  };
921
906
  }
922
907
 
@@ -983,100 +968,27 @@ function parseBaseRefOid(output: string, candidate: ListedPullRequest): string {
983
968
  return parseBaseRefAuthority(output, candidate.base);
984
969
  }
985
970
 
986
- function parseLegacyBaseBranchPolicy(output: string, candidate: ListedPullRequest): boolean {
987
- const value = parseJson(output, "Read base branch policy");
988
- if (!isRecord(value)) fail("Read base branch policy", "invalid GitHub CLI output");
989
- if (value.errors !== undefined) {
990
- if (!Array.isArray(value.errors)) fail("Read base branch policy", "invalid GitHub CLI output");
991
- if (value.errors.length) fail("Read base branch policy", "GitHub GraphQL returned errors");
992
- }
993
- const repository = isRecord(value.data) ? value.data.repository : undefined;
994
- if (!isRecord(repository) || !isRecord(repository.ref)) {
995
- fail("Read base branch policy", "invalid GitHub CLI output");
996
- }
997
- if (
998
- normalizeRepository(repositoryName(repository.nameWithOwner, "Read base branch policy", "repository")) !==
999
- normalizeRepository(candidate.base.repository) ||
1000
- text(repository.ref.name, "Read base branch policy", "ref") !== candidate.base.ref
1001
- ) fail("Read base branch policy", "response does not match pull request base");
1002
- const rule = repository.ref.branchProtectionRule;
1003
- if (rule === null) return false;
1004
- if (!isRecord(rule) || typeof rule.requiresStrictStatusChecks !== "boolean") {
1005
- fail("Read base branch policy", "invalid GitHub CLI output");
1006
- }
1007
- return rule.requiresStrictStatusChecks;
1008
- }
1009
-
1010
- function parseRulesetBaseBranchPolicy(output: string): RulesetBranchPolicy {
1011
- const pages = parseJson(output, "Read base branch rulesets");
1012
- if (!Array.isArray(pages) || !pages.length) fail("Read base branch rulesets", "invalid GitHub CLI output");
1013
- let requiresStrictStatusChecks = false;
1014
- let allowedMergeMethods: Set<MergeMethod> | null = null;
1015
- for (const page of pages) {
1016
- if (!Array.isArray(page)) fail("Read base branch rulesets", "invalid GitHub CLI output");
1017
- for (const rule of page) {
1018
- if (!isRecord(rule)) fail("Read base branch rulesets", "invalid GitHub CLI output");
1019
- const type = text(rule.type, "Read base branch rulesets", "rule type");
1020
- if (type === "required_status_checks") {
1021
- if (!isRecord(rule.parameters) || typeof rule.parameters.strict_required_status_checks_policy !== "boolean") {
1022
- fail("Read base branch rulesets", "invalid GitHub CLI output");
1023
- }
1024
- requiresStrictStatusChecks ||= rule.parameters.strict_required_status_checks_policy;
1025
- }
1026
- if (type === "pull_request") {
1027
- if (!isRecord(rule.parameters) || !Array.isArray(rule.parameters.allowed_merge_methods)) {
1028
- fail("Read base branch rulesets", "invalid GitHub CLI output");
1029
- }
1030
- const methods = rule.parameters.allowed_merge_methods;
1031
- if (
1032
- methods.some((method) => typeof method !== "string" || !MERGE_METHODS.includes(method as MergeMethod)) ||
1033
- new Set(methods).size !== methods.length
1034
- ) fail("Read base branch rulesets", "invalid GitHub CLI output");
1035
- const restriction = new Set<MergeMethod>(methods as MergeMethod[]);
1036
- allowedMergeMethods = allowedMergeMethods === null
1037
- ? restriction
1038
- : new Set<MergeMethod>([...allowedMergeMethods].filter((method: MergeMethod) => restriction.has(method)));
1039
- }
1040
- }
1041
- }
1042
- return {
1043
- requiresStrictStatusChecks,
1044
- allowedMergeMethods: allowedMergeMethods === null
1045
- ? null
1046
- : MERGE_METHODS.filter((method) => allowedMergeMethods.has(method)),
1047
- };
1048
- }
1049
-
1050
- function parseMergeMethodSettings(output: string, rulesetMethods: MergeMethod[] | null): PullRequestMerge {
1051
- const value = parseJson(output, "Read merge methods");
1052
- if (!isRecord(value)) fail("Read merge methods", "invalid GitHub CLI output");
1053
- const { mergeCommitAllowed, rebaseMergeAllowed, squashMergeAllowed } = value;
1054
- if (
1055
- typeof mergeCommitAllowed !== "boolean" || typeof rebaseMergeAllowed !== "boolean" ||
1056
- typeof squashMergeAllowed !== "boolean"
1057
- ) fail("Read merge methods", "invalid GitHub CLI output");
1058
- let allowedMergeMethods: MergeMethod[] = [];
1059
- if (mergeCommitAllowed) allowedMergeMethods.push("merge");
1060
- if (rebaseMergeAllowed) allowedMergeMethods.push("rebase");
1061
- if (squashMergeAllowed) allowedMergeMethods.push("squash");
1062
- if (!allowedMergeMethods.length) fail("Read merge methods", "repository allows no merge method");
1063
- const viewerDefaultMergeMethod = value.viewerDefaultMergeMethod === "MERGE"
1064
- ? "merge"
1065
- : value.viewerDefaultMergeMethod === "REBASE"
1066
- ? "rebase"
1067
- : value.viewerDefaultMergeMethod === "SQUASH"
1068
- ? "squash"
1069
- : fail("Read merge methods", "invalid viewerDefaultMergeMethod");
1070
- if (!allowedMergeMethods.includes(viewerDefaultMergeMethod)) {
1071
- fail("Read merge methods", "viewerDefaultMergeMethod is not allowed");
1072
- }
1073
- if (rulesetMethods !== null) {
1074
- allowedMergeMethods = allowedMergeMethods.filter((method) => rulesetMethods.includes(method));
1075
- }
1076
- if (!allowedMergeMethods.length) {
1077
- fail("Read merge methods", "repository and applicable rules allow no common merge method");
1078
- }
1079
- return { allowedMergeMethods, viewerDefaultMergeMethod };
971
+ export async function hasLocalCommit(
972
+ pi: Pick<ExtensionAPI, "exec">,
973
+ context: PullRequestLoadContext,
974
+ ): Promise<boolean> {
975
+ const branch = singleLine(
976
+ (await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])).stdout,
977
+ "Read current branch",
978
+ "branch",
979
+ );
980
+ const output = (await execute(pi, context, "Read branch history", "git", [
981
+ "reflog",
982
+ "show",
983
+ "--format=%H",
984
+ `refs/heads/${branch}`,
985
+ ])).stdout.replace(/\r\n/g, "\n");
986
+ const entries = output.split("\n");
987
+ if (entries.at(-1) === "") entries.pop();
988
+ if (!entries.length) fail("Read branch history", "missing branch creation entry");
989
+ const commits = entries.map((entry) => oid(entry, "Read branch history", "commit"));
990
+ // ponytail: reflog expiry can hide old branch history; resolve the PR base if this becomes observable.
991
+ return commits[0] !== commits.at(-1);
1080
992
  }
1081
993
 
1082
994
  function validatedCreationTarget(target: PullRequestTarget): PullRequestTarget {
@@ -1196,19 +1108,18 @@ function parseCreationRepositoryLineage(output: string, expected: PushRepository
1196
1108
  return normalizeRepository(sourceName);
1197
1109
  }
1198
1110
 
1199
- async function assertCreationRepositoryRelation(
1111
+ async function inspectCreationRepositoryRelation(
1200
1112
  pi: Pick<ExtensionAPI, "exec">,
1201
1113
  context: PullRequestLoadContext,
1202
1114
  target: PullRequestTarget,
1203
1115
  origin: { repository: PushRepository },
1204
1116
  baseRef: string,
1205
- ): Promise<void> {
1117
+ ): Promise<"same-ref" | "distinct-ref"> {
1206
1118
  if (target.host !== origin.repository.host) {
1207
1119
  fail("Read creation repository", "base and head hosts do not match");
1208
1120
  }
1209
1121
  if (normalizeRepository(target.repository) === origin.repository.normalizedName) {
1210
- if (target.ref === baseRef) fail("Read creation repository", "head and base refs match");
1211
- return;
1122
+ return target.ref === baseRef ? "same-ref" : "distinct-ref";
1212
1123
  }
1213
1124
  const read = async (repository: PushRepository): Promise<string> => {
1214
1125
  const [owner, name] = repository.nameWithOwner.split("/");
@@ -1225,6 +1136,7 @@ async function assertCreationRepositoryRelation(
1225
1136
  host: target.host,
1226
1137
  });
1227
1138
  if (originSource !== targetSource) fail("Read creation repository", "base and head are unrelated");
1139
+ return "distinct-ref";
1228
1140
  }
1229
1141
 
1230
1142
  function parseCreationAhead(output: string): number {
@@ -1241,7 +1153,7 @@ async function preflightCreation(
1241
1153
  target: PullRequestTarget,
1242
1154
  explicitBaseRef: string | undefined,
1243
1155
  identity: CreationIdentity,
1244
- ): Promise<PullRequestCreationPreflight> {
1156
+ ): Promise<CreationPreflightResult> {
1245
1157
  const validatedTarget = validatedCreationTarget(target);
1246
1158
  if (!sameCreationTarget(identity.target, validatedTarget)) {
1247
1159
  fail("Read creation target", "target changed");
@@ -1252,7 +1164,8 @@ async function preflightCreation(
1252
1164
  ? await readConfiguredCreationBaseRef(pi, context, identity.target.branch)
1253
1165
  : await validateCreationRef(pi, context, explicitBaseRef);
1254
1166
  const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
1255
- await assertCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
1167
+ const relation = await inspectCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
1168
+ if (relation === "same-ref") return { kind: relation };
1256
1169
  const trackingRef = `refs/remotes/origin/${baseRef}`;
1257
1170
  await execute(pi, context, "Fetch creation base", "git", [
1258
1171
  "fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", "--",
@@ -1272,16 +1185,19 @@ async function preflightCreation(
1272
1185
  "rev-list", "--count", `${mergeBase}..${identity.head}`,
1273
1186
  ])).stdout);
1274
1187
  return {
1275
- head: identity.head,
1276
- base: {
1277
- host: origin.repository.host,
1278
- repository: origin.repository.nameWithOwner,
1279
- fetchSource: origin.fetchSource,
1280
- ref: baseRef,
1281
- oid: baseOid,
1282
- mergeBase,
1188
+ kind: relation,
1189
+ preflight: {
1190
+ head: identity.head,
1191
+ base: {
1192
+ host: origin.repository.host,
1193
+ repository: origin.repository.nameWithOwner,
1194
+ fetchSource: origin.fetchSource,
1195
+ ref: baseRef,
1196
+ oid: baseOid,
1197
+ mergeBase,
1198
+ },
1199
+ ahead,
1283
1200
  },
1284
- ahead,
1285
1201
  };
1286
1202
  }
1287
1203
 
@@ -1292,7 +1208,9 @@ export async function preflightPullRequestCreation(
1292
1208
  explicitBaseRef?: string,
1293
1209
  ): Promise<PullRequestCreationPreflight> {
1294
1210
  const identity = await captureCreationIdentity(pi, context, target);
1295
- return await preflightCreation(pi, context, target, explicitBaseRef, identity);
1211
+ const result = await preflightCreation(pi, context, target, explicitBaseRef, identity);
1212
+ if (result.kind === "same-ref") fail("Read creation repository", "head and base refs match");
1213
+ return result.preflight;
1296
1214
  }
1297
1215
 
1298
1216
  async function creationDiscovery(
@@ -1303,8 +1221,12 @@ async function creationDiscovery(
1303
1221
  identity?: CreationIdentity,
1304
1222
  ): Promise<CurrentPullRequestDiscovery> {
1305
1223
  const captured = identity ?? await captureCreationIdentity(pi, context, target);
1306
- const preflight = await preflightCreation(pi, context, target, explicitBaseRef, captured);
1307
- return { kind: "none", creationTarget: target, branch: { ahead: preflight.ahead } };
1224
+ const result = await preflightCreation(pi, context, target, explicitBaseRef, captured);
1225
+ return {
1226
+ kind: "none",
1227
+ creationTarget: target,
1228
+ branch: { ahead: result.kind === "same-ref" ? 0 : result.preflight.ahead },
1229
+ };
1308
1230
  }
1309
1231
 
1310
1232
  async function readRemoteAuthority(
@@ -1690,66 +1612,6 @@ export async function readPullRequestBaseRefOid(
1690
1612
  return parseBaseRefAuthority(result.stdout, { repository, ref });
1691
1613
  }
1692
1614
 
1693
- async function readLegacyBaseBranchPolicy(
1694
- pi: Pick<ExtensionAPI, "exec">,
1695
- context: PullRequestLoadContext,
1696
- candidate: ListedPullRequest,
1697
- ): Promise<boolean> {
1698
- const [owner, name] = candidate.base.repository.split("/");
1699
- const result = await execute(pi, context, "Read base branch policy", "gh", [
1700
- "api",
1701
- "graphql",
1702
- "--hostname",
1703
- candidate.url.hostname,
1704
- "-f",
1705
- `query=${BASE_BRANCH_POLICY_QUERY}`,
1706
- "-F",
1707
- `owner=${owner}`,
1708
- "-F",
1709
- `name=${name}`,
1710
- "-F",
1711
- `qualifiedName=refs/heads/${candidate.base.ref}`,
1712
- ]);
1713
- return parseLegacyBaseBranchPolicy(result.stdout, candidate);
1714
- }
1715
-
1716
- async function readRulesetBaseBranchPolicy(
1717
- pi: Pick<ExtensionAPI, "exec">,
1718
- context: PullRequestLoadContext,
1719
- candidate: ListedPullRequest,
1720
- ): Promise<RulesetBranchPolicy> {
1721
- const [owner, name] = candidate.base.repository.split("/");
1722
- const result = await execute(pi, context, "Read base branch rulesets", "gh", [
1723
- "api",
1724
- "--hostname",
1725
- candidate.url.hostname,
1726
- "--paginate",
1727
- "--slurp",
1728
- "-H",
1729
- "Accept: application/vnd.github+json",
1730
- "-H",
1731
- "X-GitHub-Api-Version: 2022-11-28",
1732
- `repos/${owner}/${name}/rules/branches/${encodeURIComponent(candidate.base.ref)}`,
1733
- ]);
1734
- return parseRulesetBaseBranchPolicy(result.stdout);
1735
- }
1736
-
1737
- async function readMergeMethods(
1738
- pi: Pick<ExtensionAPI, "exec">,
1739
- context: PullRequestLoadContext,
1740
- candidate: ListedPullRequest,
1741
- rulesetMethods: MergeMethod[] | null,
1742
- ): Promise<PullRequestMerge> {
1743
- const result = await execute(pi, context, "Read merge methods", "gh", [
1744
- "repo",
1745
- "view",
1746
- `${candidate.url.hostname}/${candidate.base.repository}`,
1747
- "--json",
1748
- "mergeCommitAllowed,rebaseMergeAllowed,squashMergeAllowed,viewerDefaultMergeMethod",
1749
- ]);
1750
- return parseMergeMethodSettings(result.stdout, rulesetMethods);
1751
- }
1752
-
1753
1615
  async function loadPullRequestDetails(
1754
1616
  pi: Pick<ExtensionAPI, "exec">,
1755
1617
  context: PullRequestLoadContext,
@@ -1768,17 +1630,7 @@ async function loadPullRequestDetails(
1768
1630
  const liveBaseOid = candidate.lifecycle === "open"
1769
1631
  ? await readBaseRefOid(pi, context, candidate)
1770
1632
  : null;
1771
- const rulesetPolicy = candidate.lifecycle === "open"
1772
- ? await readRulesetBaseBranchPolicy(pi, context, candidate)
1773
- : null;
1774
- const legacyStrict = candidate.lifecycle === "open" && candidate.mergeStateStatus === "BEHIND"
1775
- ? await readLegacyBaseBranchPolicy(pi, context, candidate)
1776
- : false;
1777
- const requiresStrictStatusChecks = legacyStrict || (rulesetPolicy?.requiresStrictStatusChecks ?? false);
1778
- const pullRequestConditions = conditions(candidate, unresolvedThreads, requiresStrictStatusChecks);
1779
- const merge = candidate.lifecycle === "open"
1780
- ? await readMergeMethods(pi, context, candidate, rulesetPolicy?.allowedMergeMethods ?? null)
1781
- : null;
1633
+ const pullRequestConditions = conditions(candidate, unresolvedThreads);
1782
1634
  const inspected = inspectedLocal ?? await inspectLocalMergeSafety({
1783
1635
  exec: (command, args, options) => pi.exec(command, args, {
1784
1636
  ...options,
@@ -1803,7 +1655,6 @@ async function loadPullRequestDetails(
1803
1655
  head: candidate.head,
1804
1656
  headFetchSource: pushTarget.fetchSource,
1805
1657
  target: publicTarget(pushTarget),
1806
- merge,
1807
1658
  };
1808
1659
  }
1809
1660