@henryqw/pi-pr 4.0.3 → 4.0.4

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>;
@@ -225,11 +214,6 @@ type SearchPage = {
225
214
  endCursor: string | null;
226
215
  };
227
216
 
228
- type RulesetBranchPolicy = {
229
- requiresStrictStatusChecks: boolean;
230
- allowedMergeMethods: MergeMethod[] | null;
231
- };
232
-
233
217
  type CheckState = {
234
218
  state: string;
235
219
  diagnosableFailure: boolean;
@@ -894,7 +878,6 @@ function ciStatus(checks: CheckState[]): CiStatus {
894
878
  function conditions(
895
879
  candidate: ListedPullRequest,
896
880
  unresolvedThreads: number,
897
- requiresStrictStatusChecks: boolean,
898
881
  ): PullRequestConditions {
899
882
  if (
900
883
  (candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "DIRTY") ||
@@ -904,19 +887,17 @@ function conditions(
904
887
  ? "pending"
905
888
  : "ready";
906
889
  const behind = candidate.mergeStateStatus === "BEHIND";
907
- const policy: PolicyReadiness = candidate.mergeable === "MERGEABLE" &&
908
- (candidate.mergeStateStatus === "CLEAN" || (behind && !requiresStrictStatusChecks))
909
- ? "ready"
910
- : "pending";
911
890
  return {
912
891
  draft: candidate.isDraft,
913
- baseUpdateRequired: behind && requiresStrictStatusChecks,
892
+ baseUpdateRequired: behind,
914
893
  conflict: candidate.mergeable === "CONFLICTING" || candidate.mergeStateStatus === "DIRTY",
915
894
  changesRequested: candidate.reviewDecision === "CHANGES_REQUESTED",
916
895
  unresolvedThreads,
917
896
  ci: ciStatus(candidate.checkStates),
918
897
  review,
919
- policy,
898
+ policy: candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "CLEAN"
899
+ ? "ready"
900
+ : "pending",
920
901
  };
921
902
  }
922
903
 
@@ -983,100 +964,27 @@ function parseBaseRefOid(output: string, candidate: ListedPullRequest): string {
983
964
  return parseBaseRefAuthority(output, candidate.base);
984
965
  }
985
966
 
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 };
967
+ export async function hasLocalCommit(
968
+ pi: Pick<ExtensionAPI, "exec">,
969
+ context: PullRequestLoadContext,
970
+ ): Promise<boolean> {
971
+ const branch = singleLine(
972
+ (await execute(pi, context, "Read current branch", "git", ["branch", "--show-current"])).stdout,
973
+ "Read current branch",
974
+ "branch",
975
+ );
976
+ const output = (await execute(pi, context, "Read branch history", "git", [
977
+ "reflog",
978
+ "show",
979
+ "--format=%H",
980
+ `refs/heads/${branch}`,
981
+ ])).stdout.replace(/\r\n/g, "\n");
982
+ const entries = output.split("\n");
983
+ if (entries.at(-1) === "") entries.pop();
984
+ if (!entries.length) fail("Read branch history", "missing branch creation entry");
985
+ const commits = entries.map((entry) => oid(entry, "Read branch history", "commit"));
986
+ // ponytail: reflog expiry can hide old branch history; resolve the PR base if this becomes observable.
987
+ return commits[0] !== commits.at(-1);
1080
988
  }
1081
989
 
1082
990
  function validatedCreationTarget(target: PullRequestTarget): PullRequestTarget {
@@ -1690,66 +1598,6 @@ export async function readPullRequestBaseRefOid(
1690
1598
  return parseBaseRefAuthority(result.stdout, { repository, ref });
1691
1599
  }
1692
1600
 
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
1601
  async function loadPullRequestDetails(
1754
1602
  pi: Pick<ExtensionAPI, "exec">,
1755
1603
  context: PullRequestLoadContext,
@@ -1768,17 +1616,7 @@ async function loadPullRequestDetails(
1768
1616
  const liveBaseOid = candidate.lifecycle === "open"
1769
1617
  ? await readBaseRefOid(pi, context, candidate)
1770
1618
  : 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;
1619
+ const pullRequestConditions = conditions(candidate, unresolvedThreads);
1782
1620
  const inspected = inspectedLocal ?? await inspectLocalMergeSafety({
1783
1621
  exec: (command, args, options) => pi.exec(command, args, {
1784
1622
  ...options,
@@ -1803,7 +1641,6 @@ async function loadPullRequestDetails(
1803
1641
  head: candidate.head,
1804
1642
  headFetchSource: pushTarget.fetchSource,
1805
1643
  target: publicTarget(pushTarget),
1806
- merge,
1807
1644
  };
1808
1645
  }
1809
1646