@senad-d/branchme 0.3.0 → 0.3.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/src/git.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { lstat, realpath, stat } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
+ GIT_BRANCH_ENTRY_LIMIT,
6
+ GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES,
5
7
  GIT_CONTEXT_CHANGE_LIMIT,
6
8
  GIT_CONTEXT_RECENT_COMMIT_LIMIT,
7
9
  GIT_CONTEXT_VALUE_LIMIT_CHARS,
@@ -24,6 +26,7 @@ import {
24
26
  import { redactSecrets } from "./redaction.ts";
25
27
  import type {
26
28
  AheadBehindCount,
29
+ BranchEntry,
27
30
  BranchAncestryDetails,
28
31
  BranchStatusAncestryQuery,
29
32
  BranchStatusDetails,
@@ -37,6 +40,8 @@ import type {
37
40
  GitExecResult,
38
41
  GitFileChange,
39
42
  GitFileChangeSummary,
43
+ InitRepositoryDetails,
44
+ ListBranchesDetails,
40
45
  ListWorktreesDetails,
41
46
  PullBranchDetails,
42
47
  PushBranchDetails,
@@ -144,6 +149,196 @@ export async function runGit(
144
149
  return result;
145
150
  }
146
151
 
152
+ function requireSafeInitializationEnvironment(): void {
153
+ for (const key of [
154
+ "GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_OBJECT_DIRECTORY",
155
+ "GIT_INDEX_FILE", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_IMPLICIT_WORK_TREE",
156
+ "GIT_CEILING_DIRECTORIES", "GIT_DISCOVERY_ACROSS_FILESYSTEM",
157
+ ]) {
158
+ if (process.env[key] !== undefined) {
159
+ throw new Error(`Git environment override ${key} must be unset before repository initialization.`);
160
+ }
161
+ }
162
+ }
163
+
164
+ function requireLosslessRepositoryPath(value: string, label: string): string {
165
+ if (isLosslessGitMetadata(value, GIT_WORKTREE_PATH_LIMIT_CHARS)) return value;
166
+ throw new Error(
167
+ `The canonical ${label} cannot be returned safely and losslessly. ` +
168
+ `Use a path of at most ${GIT_WORKTREE_PATH_LIMIT_CHARS} characters without credential-like token text or control/format characters.`,
169
+ );
170
+ }
171
+
172
+ async function resolveRepositoryInitializationDirectory(ctx: GitCommandContext): Promise<string> {
173
+ let canonicalPath: string;
174
+ try {
175
+ canonicalPath = await realpath(ctx.cwd);
176
+ } catch {
177
+ throw new Error("The current working directory does not exist or cannot be resolved.");
178
+ }
179
+
180
+ let directoryStats;
181
+ try {
182
+ directoryStats = await stat(canonicalPath);
183
+ } catch {
184
+ throw new Error("The current working directory cannot be inspected.");
185
+ }
186
+ if (!directoryStats.isDirectory()) throw new Error("The current working directory is not a directory.");
187
+ if (dirname(canonicalPath) === canonicalPath) throw new Error("The filesystem root cannot be initialized as a repository.");
188
+ return requireLosslessRepositoryPath(canonicalPath, "repository path");
189
+ }
190
+
191
+ async function gitMetadataEntryExists(path: string): Promise<boolean> {
192
+ try {
193
+ await lstat(path);
194
+ return true;
195
+ } catch (error) {
196
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
197
+ throw new Error("The target directory's .git entry cannot be inspected.");
198
+ }
199
+ }
200
+
201
+ async function requireUninitializedRepositoryDirectory(
202
+ pi: Pick<ExtensionAPI, "exec">,
203
+ ctx: GitCommandContext,
204
+ canonicalPath: string,
205
+ signal?: AbortSignal,
206
+ ): Promise<void> {
207
+ requireSafeInitializationEnvironment();
208
+ const currentPath = await resolveRepositoryInitializationDirectory(ctx);
209
+ if (currentPath !== canonicalPath) throw new Error("The current working directory changed while preparing git init.");
210
+ if (await gitMetadataEntryExists(join(canonicalPath, ".git"))) {
211
+ throw new Error("The current working directory already contains a .git entry; reinitialization is not allowed.");
212
+ }
213
+
214
+ // Do not let failed discovery (including invalid metadata or filesystem
215
+ // boundaries) hide an enclosing checkout.
216
+ for (let parent = dirname(canonicalPath); ; parent = dirname(parent)) {
217
+ if (await gitMetadataEntryExists(join(parent, ".git"))) {
218
+ throw new Error("The current working directory is already inside a Git repository; nested initialization is not allowed.");
219
+ }
220
+ if (dirname(parent) === parent) break;
221
+ }
222
+
223
+ const probe = await runGit(pi, { cwd: canonicalPath }, ["rev-parse", "--git-dir"], {
224
+ signal,
225
+ timeout: GIT_STATUS_TIMEOUT_MS,
226
+ allowFailure: true,
227
+ });
228
+ if (probe.code === 0) {
229
+ throw new Error("The current working directory is already inside a Git repository; nested initialization is not allowed.");
230
+ }
231
+ // Only Git's explicit absence diagnostic permits initialization. Ownership,
232
+ // permission, configuration and unrecognized/localized failures stay closed.
233
+ if (probe.code !== 128 || probe.stdout ||
234
+ !/^fatal: not a git repository \(or any (?:of the parent directories\): \.git|parent up to mount point [^\r\n]+\))/u.test(probe.stderr)) {
235
+ throw new Error("Git repository discovery failed; inspect the current directory and Git configuration before initialization.");
236
+ }
237
+ }
238
+
239
+ async function verifyInitializedRepository(
240
+ pi: Pick<ExtensionAPI, "exec">,
241
+ canonicalPath: string,
242
+ initialBranch: string,
243
+ ): Promise<InitRepositoryDetails> {
244
+ const verificationCtx = { cwd: canonicalPath };
245
+ const repoRoot = await getCanonicalGitWorktreeRoot(pi, verificationCtx);
246
+ if (repoRoot !== canonicalPath) throw new Error("git init produced an unexpected repository root.");
247
+
248
+ const current = await getCurrentBranch(pi, verificationCtx);
249
+ if (current.detached || current.currentBranch !== initialBranch) {
250
+ throw new Error("git init did not create the requested unborn initial branch.");
251
+ }
252
+
253
+ const gitDirectoryResult = await runGit(
254
+ pi,
255
+ verificationCtx,
256
+ ["rev-parse", "--path-format=absolute", "--git-dir"],
257
+ );
258
+ const gitDirectoryOutput = trimOutput(gitDirectoryResult.stdout);
259
+ let gitDirectory: string;
260
+ try {
261
+ gitDirectory = await realpath(gitDirectoryOutput);
262
+ } catch {
263
+ throw new Error("The initialized Git directory could not be resolved.");
264
+ }
265
+ if (gitDirectory !== join(canonicalPath, ".git")) {
266
+ throw new Error("git init produced an unexpected or separate Git directory.");
267
+ }
268
+
269
+ const bareResult = await runGit(pi, verificationCtx, ["rev-parse", "--is-bare-repository"]);
270
+ if (trimOutput(bareResult.stdout) !== "false") throw new Error("git init produced an unexpected bare repository.");
271
+
272
+ const headResult = await runGit(pi, verificationCtx, ["rev-parse", "--verify", "HEAD"], {
273
+ allowFailure: true,
274
+ });
275
+ if (headResult.code === 0) throw new Error("The initialized repository unexpectedly contains a commit.");
276
+
277
+ return {
278
+ action: "init_repository",
279
+ request: { initialBranch },
280
+ repoRoot,
281
+ gitDirectory: requireLosslessRepositoryPath(gitDirectory, "Git directory path"),
282
+ initialBranch,
283
+ bare: false,
284
+ unborn: true,
285
+ };
286
+ }
287
+
288
+ async function initializeRepositoryWithinQueue(
289
+ pi: Pick<ExtensionAPI, "exec">,
290
+ targetCtx: GitCommandContext,
291
+ canonicalPath: string,
292
+ initialBranch: string,
293
+ signal?: AbortSignal,
294
+ ): Promise<InitRepositoryDetails> {
295
+ await requireUninitializedRepositoryDirectory(pi, targetCtx, canonicalPath, signal);
296
+ await validateBranchName(pi, targetCtx, initialBranch, signal);
297
+ try {
298
+ await runGit(pi, targetCtx, ["init", "--no-template", "--initial-branch", initialBranch], {
299
+ signal,
300
+ timeout: GIT_MUTATION_TIMEOUT_MS,
301
+ });
302
+ return await verifyInitializedRepository(pi, canonicalPath, initialBranch);
303
+ } catch (error) {
304
+ const reason = error instanceof Error ? error.message : String(error);
305
+ throw new Error(
306
+ `Repository initialization did not complete with verified postconditions. ${safeOutput(reason)} ` +
307
+ "Inspect the current directory before retrying; no automatic cleanup was attempted.",
308
+ );
309
+ }
310
+ }
311
+
312
+ async function queueRepositoryInitialization(
313
+ canonicalPath: string,
314
+ initialize: () => Promise<InitRepositoryDetails>,
315
+ ): Promise<InitRepositoryDetails> {
316
+ return withRepositoryMutationQueue(canonicalPath, initialize);
317
+ }
318
+
319
+ export async function initializeRepository(
320
+ pi: Pick<ExtensionAPI, "exec">,
321
+ ctx: GitCommandContext,
322
+ initialBranch: unknown = "main",
323
+ signal?: AbortSignal,
324
+ ): Promise<InitRepositoryDetails> {
325
+ requireSafeInitializationEnvironment();
326
+ validateBranchNameInput(initialBranch, "Initial branch name");
327
+ requireLosslessWorktreeIdentity(initialBranch, "branch");
328
+ const canonicalPath = await resolveRepositoryInitializationDirectory(ctx);
329
+ const targetCtx = { cwd: canonicalPath };
330
+ const initialize: () => Promise<InitRepositoryDetails> = initializeRepositoryWithinQueue.bind(
331
+ undefined,
332
+ pi,
333
+ targetCtx,
334
+ canonicalPath,
335
+ initialBranch,
336
+ signal,
337
+ );
338
+ const queueRepositoryMutation = queueRepositoryInitialization.bind(undefined, canonicalPath, initialize);
339
+ return withFileMutationQueue<InitRepositoryDetails>(join(canonicalPath, ".git"), queueRepositoryMutation);
340
+ }
341
+
147
342
  export async function getGitRoot(
148
343
  pi: Pick<ExtensionAPI, "exec">,
149
344
  ctx: GitCommandContext,
@@ -1000,6 +1195,85 @@ export async function validateWorktreeRemovalPath(
1000
1195
  };
1001
1196
  }
1002
1197
 
1198
+ const GIT_BRANCH_FORMAT = "%(refname)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(symref)%00%(upstream:track)";
1199
+
1200
+ function branchTrackingCounts(upstream: string, track: string): AheadBehindCount {
1201
+ if (!upstream || track === "[gone]") return { ahead: null, behind: null };
1202
+ if (track !== "" && !/^\[(?:ahead \d+(?:, behind \d+)?|behind \d+)\]$/u.test(track)) {
1203
+ throw new TypeError("Unable to parse branches: malformed upstream counts.");
1204
+ }
1205
+ const ahead = Number(/ahead (\d+)/u.exec(track)?.[1] ?? 0);
1206
+ const behind = Number(/behind (\d+)/u.exec(track)?.[1] ?? 0);
1207
+ if (!Number.isSafeInteger(ahead) || !Number.isSafeInteger(behind)) throw new TypeError("Unable to parse branches: invalid upstream counts.");
1208
+ return { ahead, behind };
1209
+ }
1210
+
1211
+ function parseBranchRecord(record: string): BranchEntry {
1212
+ const fields = record.split(NUL_SEPARATOR);
1213
+ if (fields.length !== 6) throw new TypeError("Unable to parse branches: malformed ref output.");
1214
+ const [fullRef, head, current, upstream, symbolicTarget, track] = fields;
1215
+ const kind = fullRef.startsWith("refs/heads/") ? "local" : "remote-tracking";
1216
+ const prefix = kind === "local" ? "refs/heads/" : "refs/remotes/";
1217
+ if (!fullRef.startsWith(prefix) || fullRef.length === prefix.length ||
1218
+ !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(head) || !["*", " "].includes(current)) {
1219
+ throw new TypeError("Unable to parse branches: malformed ref output.");
1220
+ }
1221
+ return {
1222
+ name: safeGitContextValue(fullRef.slice(prefix.length)),
1223
+ fullRef: safeGitContextValue(fullRef),
1224
+ kind,
1225
+ head,
1226
+ current: current === "*",
1227
+ upstream: upstream ? safeGitContextValue(upstream) : null,
1228
+ ...branchTrackingCounts(upstream, track),
1229
+ symbolicTarget: symbolicTarget ? safeGitContextValue(symbolicTarget) : null,
1230
+ worktreePaths: [],
1231
+ };
1232
+ }
1233
+
1234
+ export function parseBranchRefs(output: string): Pick<ListBranchesDetails, "branches" | "omitted"> {
1235
+ if (Buffer.byteLength(output, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
1236
+ throw new TypeError("Unable to parse branches: ref output exceeded the safety limit.");
1237
+ }
1238
+ const records = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
1239
+ if (records.length === 1 && records[0] === "") return { branches: [], omitted: 0 };
1240
+ const branches: BranchEntry[] = [];
1241
+ const seen = new Set<string>();
1242
+ for (const record of records) {
1243
+ const branch = parseBranchRecord(record);
1244
+ const rawRef = record.split(NUL_SEPARATOR, 1)[0];
1245
+ if (seen.has(rawRef)) throw new TypeError("Unable to parse branches: duplicate ref output.");
1246
+ seen.add(rawRef);
1247
+ if (branches.length < GIT_BRANCH_ENTRY_LIMIT) branches.push(branch);
1248
+ }
1249
+ return { branches, omitted: records.length - branches.length };
1250
+ }
1251
+
1252
+ export async function listBranches(
1253
+ pi: Pick<ExtensionAPI, "exec">,
1254
+ ctx: GitCommandContext,
1255
+ signal?: AbortSignal,
1256
+ ): Promise<ListBranchesDetails> {
1257
+ const repoRoot = await getGitRoot(pi, ctx, signal);
1258
+ const rootCtx = { cwd: repoRoot };
1259
+ const result = await runGit(pi, rootCtx, [
1260
+ "for-each-ref", "--sort=refname", `--format=${GIT_BRANCH_FORMAT}`, "refs/heads/", "refs/remotes/",
1261
+ ], { signal });
1262
+ const parsed = parseBranchRefs(result.stdout);
1263
+ const inventory = await collectWorktreeInventory(pi, rootCtx, signal);
1264
+ const rawRecords = result.stdout.split("\n");
1265
+ for (const [index, branch] of parsed.branches.entries()) {
1266
+ if (branch.kind !== "local") continue;
1267
+ const rawName = rawRecords[index].split(NUL_SEPARATOR, 1)[0].slice("refs/heads/".length);
1268
+ for (const entry of inventory.entries) {
1269
+ if (entry.record.branch === rawName) {
1270
+ branch.worktreePaths.push(safeWorktreeValue(entry.record.rawPath, GIT_WORKTREE_PATH_LIMIT_CHARS));
1271
+ }
1272
+ }
1273
+ }
1274
+ return { action: "list_branches", repoRoot: safeGitContextValue(repoRoot), ...parsed };
1275
+ }
1276
+
1003
1277
  export async function listWorktrees(
1004
1278
  pi: Pick<ExtensionAPI, "exec">,
1005
1279
  ctx: GitCommandContext,
@@ -1487,6 +1761,7 @@ export async function removeWorktree(
1487
1761
  ctx: GitCommandContext,
1488
1762
  worktreePath: unknown,
1489
1763
  signal?: AbortSignal,
1764
+ deleteIgnored = false,
1490
1765
  ): Promise<RemoveWorktreeDetails> {
1491
1766
  const requestedWorktreePath = validateWorktreePathInput(worktreePath);
1492
1767
  const repoRoot = await getGitRoot(pi, ctx, signal);
@@ -1494,11 +1769,11 @@ export async function removeWorktree(
1494
1769
 
1495
1770
  return withRepositoryMutationQueue(
1496
1771
  repoRoot,
1497
- removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal),
1772
+ removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal, deleteIgnored),
1498
1773
  );
1499
1774
  }
1500
1775
 
1501
- // Caller must hold the repository mutation queue. Standalone removal still refuses ignored residue.
1776
+ // Caller must hold the repository mutation queue. Ignored residue requires explicit authorization.
1502
1777
  export async function removeWorktreeWithinQueue(
1503
1778
  pi: Pick<ExtensionAPI, "exec">,
1504
1779
  rootCtx: GitCommandContext,
@@ -1567,6 +1842,7 @@ export async function removeWorktreeWithinQueue(
1567
1842
  repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
1568
1843
  request: {
1569
1844
  worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
1845
+ ...(allowIgnored ? { deleteIgnored: true } : {}),
1570
1846
  },
1571
1847
  verified: {
1572
1848
  before: {
package/src/github.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  getCurrentBranch,
17
17
  getGitRoot,
18
18
  getOriginUrl,
19
+ requireLosslessWorktreeIdentity,
19
20
  validateBranchNameInput,
20
21
  type GitCommandContext,
21
22
  } from "./git.ts";
@@ -23,6 +24,8 @@ import type {
23
24
  GitHubRepository,
24
25
  PullRequestDetails,
25
26
  PullRequestInput,
27
+ PullRequestStatusDetails,
28
+ PullRequestToolDetails,
26
29
  RelatedPullRequest,
27
30
  RelatedPullRequestDetails,
28
31
  } from "./types.ts";
@@ -984,6 +987,118 @@ export async function ensureGitHubBranchExists(
984
987
  return readGitHubBranchDetails(response, options.signal, token, branchName, field, branchLabel);
985
988
  }
986
989
 
990
+ function requirePullRequestCommit(value: unknown): string {
991
+ if (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(value)) {
992
+ throw new Error("GitHub pull request has an invalid commit identity.");
993
+ }
994
+ return value.toLowerCase();
995
+ }
996
+
997
+ function parsePullRequestStatus(repository: GitHubRepository, number: number, payload: Record<string, unknown>, token: string): PullRequestStatusDetails {
998
+ if (pullRequestNumberField(payload.number) !== number) throw new Error("GitHub returned a different pull request number.");
999
+ if (!["open", "closed"].includes(String(payload.state)) || typeof payload.merged !== "boolean" || typeof payload.draft !== "boolean") {
1000
+ throw new Error("GitHub pull request state is malformed.");
1001
+ }
1002
+ const head = requireGitHubResponseObject(payload.head, "head");
1003
+ const base = requireGitHubResponseObject(payload.base, "base");
1004
+ requireRelatedPullRequestRepository(head.repo, repository);
1005
+ requireRelatedPullRequestRepository(base.repo, repository);
1006
+ const headRef = stringField(head.ref, "head.ref");
1007
+ const baseRef = stringField(base.ref, "base.ref");
1008
+ validatePullRequestBranchRef(headRef, "headBranch");
1009
+ validatePullRequestBranchRef(baseRef, "baseBranch");
1010
+ requireLosslessWorktreeIdentity(headRef, "branch");
1011
+ requireLosslessWorktreeIdentity(baseRef, "branch");
1012
+ if (redactSecrets(headRef, [token]) !== headRef || redactSecrets(baseRef, [token]) !== baseRef) {
1013
+ throw new Error("GitHub branch identities cannot be returned safely.");
1014
+ }
1015
+ const mergedAt = payload.merged_at;
1016
+ if (mergedAt !== null && (typeof mergedAt !== "string" || mergedAt.length > 40 || !Number.isFinite(Date.parse(mergedAt)))) {
1017
+ throw new Error("GitHub pull request merge timestamp is malformed.");
1018
+ }
1019
+ const mergeCommitSha = payload.merge_commit_sha === null ? null : requirePullRequestCommit(payload.merge_commit_sha);
1020
+ if (payload.merged && (payload.state !== "closed" || mergedAt === null || mergeCommitSha === null)) {
1021
+ throw new Error("GitHub merged pull request evidence is incomplete.");
1022
+ }
1023
+ if (!payload.merged && mergedAt !== null) throw new Error("GitHub merge state is contradictory.");
1024
+ return {
1025
+ repository, number, url: requireRelatedPullRequestUrl(payload.html_url, repository, number),
1026
+ title: boundedRelatedPullRequestValue(redactSecrets(stringField(payload.title, "title"), [token])),
1027
+ state: payload.state as "open" | "closed", draft: payload.draft, merged: payload.merged,
1028
+ mergedAt: mergedAt as string | null, head: headRef, base: baseRef,
1029
+ headSha: requirePullRequestCommit(head.sha), baseSha: requirePullRequestCommit(base.sha), mergeCommitSha,
1030
+ };
1031
+ }
1032
+
1033
+ async function requestPullRequestJson(repository: GitHubRepository, suffix: string, token: string, options: PullRequestFetchOptions): Promise<unknown> {
1034
+ // Bound both transport and body consumption; the same signal covers the complete request.
1035
+ const controller = new AbortController();
1036
+ const timer = setTimeout(controller.abort.bind(controller), 10_000);
1037
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
1038
+ try {
1039
+ signal.throwIfAborted();
1040
+ const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch),
1041
+ `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/pulls${suffix}`,
1042
+ { method: "GET", headers: gitHubJsonHeaders(token), signal }, "GitHub pull request lookup failed", token);
1043
+ if (!response.ok) throw new Error(`GitHub pull request lookup returned HTTP ${response.status}.`);
1044
+ const body = await readGitHubResponseBody(response, signal, "GitHub pull request lookup response failed", token);
1045
+ if (body.truncated) throw new Error("GitHub pull request lookup exceeded the response byte limit.");
1046
+ return parseGitHubJson(body.text, "GitHub pull request lookup response", token);
1047
+ } catch (error) {
1048
+ throw new Error(truncate(redactSecrets(error instanceof Error ? error.message : String(error), [token])).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " "));
1049
+ } finally {
1050
+ clearTimeout(timer);
1051
+ }
1052
+ }
1053
+
1054
+ export async function getGitHubPullRequest(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestStatusDetails> {
1055
+ validateGitHubRepository(repository);
1056
+ pullRequestNumberField(number);
1057
+ const payload = await requestPullRequestJson(repository, `/${number}`, token, options);
1058
+ return parsePullRequestStatus(repository, number, requireGitHubResponseObject(payload, "Pull request"), token);
1059
+ }
1060
+
1061
+ export async function findGitHubPullRequest(repository: GitHubRepository, headBranch: string, token: string, options: PullRequestFetchOptions = {}, state: "open" | "all" = "all"): Promise<PullRequestStatusDetails | null> {
1062
+ validateGitHubRepository(repository);
1063
+ validatePullRequestBranchRef(headBranch, "headBranch");
1064
+ requireLosslessWorktreeIdentity(headBranch, "branch");
1065
+ const query = new URLSearchParams({ state, head: `${repository.owner}:${headBranch}`, sort: "updated", direction: "desc", per_page: state === "open" ? "2" : "1" });
1066
+ const payload = requireGitHubResponseArray(await requestPullRequestJson(repository, `?${query}`, token, options), "Pull request lookup");
1067
+ if (payload.length === 0) return null;
1068
+ if (payload.length > 1) throw new Error("Multiple open pull requests use this head; select a pull request by number.");
1069
+ const row = requireGitHubResponseObject(payload[0], "Pull request lookup item");
1070
+ const result = await getGitHubPullRequest(repository, pullRequestNumberField(row.number), token, options);
1071
+ if (result.head !== headBranch || (state === "open" && result.state !== "open")) {
1072
+ throw new Error("Pull request lookup no longer matches the requested head/state; retry after inspection.");
1073
+ }
1074
+ return result;
1075
+ }
1076
+
1077
+ function existingPullRequestDetails(existing: PullRequestStatusDetails, input: PullRequestInput, expectedHead: string): PullRequestToolDetails {
1078
+ if (existing.base !== input.baseBranch || existing.headSha !== expectedHead.toLowerCase()) {
1079
+ throw new Error("Existing pull request base or head commit does not match the requested branches; inspect it before retrying.");
1080
+ }
1081
+ const { repository, number, url, state, head, base, draft } = existing;
1082
+ return { repository, number, url, state, head, base, draft, outcome: "existing" };
1083
+ }
1084
+
1085
+ export async function createOrReuseGitHubPullRequest(repository: GitHubRepository, input: PullRequestInput, expectedHead: string, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestToolDetails> {
1086
+ validatePullRequestInput(input);
1087
+ requirePullRequestCommit(expectedHead);
1088
+ const existing = await findGitHubPullRequest(repository, input.headBranch, token, options, "open");
1089
+ if (existing) return existingPullRequestDetails(existing, input, expectedHead);
1090
+ try {
1091
+ return { ...await createGitHubPullRequest(repository, input, token, options), outcome: "created" };
1092
+ } catch (error) {
1093
+ // A concurrent creator may win after our lookup. Recheck only a validation conflict,
1094
+ // never hide network/authentication/cancellation errors or retry the POST.
1095
+ if (!(error instanceof Error) || !error.message.startsWith("GitHub pull request request failed with HTTP 422:")) throw error;
1096
+ const raced = await findGitHubPullRequest(repository, input.headBranch, token, options, "open");
1097
+ if (raced) return existingPullRequestDetails(raced, input, expectedHead);
1098
+ throw error;
1099
+ }
1100
+ }
1101
+
987
1102
  export async function createGitHubPullRequest(
988
1103
  repository: GitHubRepository,
989
1104
  input: PullRequestInput,