@senad-d/branchme 0.2.2 → 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,
@@ -741,7 +936,7 @@ export function validateWorktreePathInput(worktreePath: unknown): string {
741
936
  return normalizedPath;
742
937
  }
743
938
 
744
- async function canonicalizePathAllowMissing(path: string): Promise<string | null> {
939
+ export async function canonicalizePathAllowMissing(path: string): Promise<string | null> {
745
940
  let candidate = normalize(path);
746
941
  const suffix: string[] = [];
747
942
 
@@ -759,7 +954,7 @@ async function canonicalizePathAllowMissing(path: string): Promise<string | null
759
954
  }
760
955
  }
761
956
 
762
- async function collectWorktreeInventory(
957
+ export async function collectWorktreeInventory(
763
958
  pi: Pick<ExtensionAPI, "exec">,
764
959
  ctx: GitCommandContext,
765
960
  signal?: AbortSignal,
@@ -783,7 +978,7 @@ async function collectWorktreeInventory(
783
978
  return { repoRoot, canonicalCurrentPath, entries };
784
979
  }
785
980
 
786
- function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string): boolean {
981
+ export function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string): boolean {
787
982
  const relation = relative(boundaryPath, candidatePath);
788
983
  return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
789
984
  }
@@ -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,
@@ -1399,7 +1673,11 @@ async function requirePresentWorktreeDirectory(canonicalPath: string): Promise<v
1399
1673
 
1400
1674
  interface WorktreeRemovalStatus {
1401
1675
  workingTree: WorkingTreeDetails;
1402
- hasIgnoredEntries: boolean;
1676
+ ignoredPaths: string[];
1677
+ }
1678
+
1679
+ function compareIgnoredPaths(left: string, right: string): number {
1680
+ return left.localeCompare(right);
1403
1681
  }
1404
1682
 
1405
1683
  function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
@@ -1408,7 +1686,7 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
1408
1686
  }
1409
1687
 
1410
1688
  const records = output.split(NUL_SEPARATOR);
1411
- let hasIgnoredEntries = false;
1689
+ const ignoredPaths = new Set<string>();
1412
1690
  let skipNextRecord = false;
1413
1691
  for (const [index, record] of records.entries()) {
1414
1692
  if (skipNextRecord) {
@@ -1419,12 +1697,12 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
1419
1697
 
1420
1698
  const parsed = parsePorcelainChange(records, index);
1421
1699
  skipNextRecord = parsed.nextIndex > index;
1422
- if (parsed.status === "!!") hasIgnoredEntries = true;
1700
+ if (parsed.status === "!!") ignoredPaths.add(parsed.path.split("/")[0]);
1423
1701
  }
1424
1702
 
1425
1703
  return {
1426
1704
  workingTree: parseWorkingTreeStatus(output).workingTree,
1427
- hasIgnoredEntries,
1705
+ ignoredPaths: [...ignoredPaths].sort(compareIgnoredPaths),
1428
1706
  };
1429
1707
  }
1430
1708
 
@@ -1432,7 +1710,8 @@ async function inspectRemovableWorktree(
1432
1710
  pi: Pick<ExtensionAPI, "exec">,
1433
1711
  canonicalPath: string,
1434
1712
  signal?: AbortSignal,
1435
- ): Promise<WorkingTreeDetails> {
1713
+ allowIgnored = false,
1714
+ ): Promise<WorktreeRemovalStatus> {
1436
1715
  const workingTree = await inspectWorktreeState(pi, canonicalPath, signal);
1437
1716
  if (workingTree.state !== "clean") {
1438
1717
  throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
@@ -1446,12 +1725,12 @@ async function inspectRemovableWorktree(
1446
1725
  if (removalStatus.workingTree.state !== "clean") {
1447
1726
  throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
1448
1727
  }
1449
- if (removalStatus.hasIgnoredEntries) {
1728
+ if (!allowIgnored && removalStatus.ignoredPaths.length > 0) {
1450
1729
  throw new Error(
1451
1730
  "The target worktree contains ignored files or directories; remove or preserve them outside the checkout before removal.",
1452
1731
  );
1453
1732
  }
1454
- return workingTree;
1733
+ return removalStatus;
1455
1734
  }
1456
1735
 
1457
1736
  function assertWorktreeRemoved(
@@ -1482,12 +1761,28 @@ export async function removeWorktree(
1482
1761
  ctx: GitCommandContext,
1483
1762
  worktreePath: unknown,
1484
1763
  signal?: AbortSignal,
1764
+ deleteIgnored = false,
1485
1765
  ): Promise<RemoveWorktreeDetails> {
1486
1766
  const requestedWorktreePath = validateWorktreePathInput(worktreePath);
1487
1767
  const repoRoot = await getGitRoot(pi, ctx, signal);
1488
1768
  const rootCtx = { cwd: repoRoot };
1489
1769
 
1490
- return withRepositoryMutationQueue(repoRoot, async () => {
1770
+ return withRepositoryMutationQueue(
1771
+ repoRoot,
1772
+ removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal, deleteIgnored),
1773
+ );
1774
+ }
1775
+
1776
+ // Caller must hold the repository mutation queue. Ignored residue requires explicit authorization.
1777
+ export async function removeWorktreeWithinQueue(
1778
+ pi: Pick<ExtensionAPI, "exec">,
1779
+ rootCtx: GitCommandContext,
1780
+ requestedWorktreePath: string,
1781
+ signal?: AbortSignal,
1782
+ allowIgnored = false,
1783
+ expectedIdentity?: { branch: string; head: string },
1784
+ ): Promise<RemoveWorktreeDetails & { deletedIgnoredPaths: string[] }> {
1785
+ const repoRoot = rootCtx.cwd;
1491
1786
  const resolved = await resolveWorktreeRemovalTarget(
1492
1787
  pi,
1493
1788
  rootCtx,
@@ -1499,6 +1794,9 @@ export async function removeWorktree(
1499
1794
  }
1500
1795
 
1501
1796
  const worktree = requireRemovableWorktreeEntry(resolved);
1797
+ if (expectedIdentity && (worktree.branch !== expectedIdentity.branch || worktree.head !== expectedIdentity.head)) {
1798
+ throw new Error("The selected worktree branch or HEAD moved after the landing ancestry proof.");
1799
+ }
1502
1800
  const branchName = resolved.entry.record.branch;
1503
1801
  const head = resolved.entry.record.head;
1504
1802
  if (branchName === null || head === null) {
@@ -1507,7 +1805,7 @@ export async function removeWorktree(
1507
1805
  const verifiedCanonicalPath = requireLosslessWorktreeIdentity(resolved.canonicalPath, "cwd");
1508
1806
  const retainedBranch = requireLosslessWorktreeIdentity(branchName, "branch");
1509
1807
  await requirePresentWorktreeDirectory(verifiedCanonicalPath);
1510
- const workingTree = await inspectRemovableWorktree(pi, verifiedCanonicalPath, signal);
1808
+ const { workingTree, ignoredPaths } = await inspectRemovableWorktree(pi, verifiedCanonicalPath, signal, allowIgnored);
1511
1809
  const branchHeadBefore = await getLocalBranchCommit(pi, rootCtx, retainedBranch, signal);
1512
1810
  if (branchHeadBefore.toLowerCase() !== head.toLowerCase()) {
1513
1811
  throw new Error("The target local branch did not match the worktree HEAD before removal.");
@@ -1540,9 +1838,11 @@ export async function removeWorktree(
1540
1838
  );
1541
1839
  return {
1542
1840
  action: "remove_worktree",
1841
+ deletedIgnoredPaths: ignoredPaths.map((path) => redactSecrets(path)),
1543
1842
  repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
1544
1843
  request: {
1545
1844
  worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
1845
+ ...(allowIgnored ? { deleteIgnored: true } : {}),
1546
1846
  },
1547
1847
  verified: {
1548
1848
  before: {
@@ -1567,7 +1867,6 @@ export async function removeWorktree(
1567
1867
  } catch (error) {
1568
1868
  throw worktreeRemovalFailure(error, resolved.canonicalPath, branchName);
1569
1869
  }
1570
- });
1571
1870
  }
1572
1871
 
1573
1872
  function isRenameOrCopyStatus(status: string): boolean {
@@ -1880,8 +2179,9 @@ export async function getAheadBehindCount(
1880
2179
  pi: Pick<ExtensionAPI, "exec">,
1881
2180
  ctx: GitCommandContext,
1882
2181
  signal?: AbortSignal,
2182
+ comparison = "HEAD...@{u}",
1883
2183
  ): Promise<AheadBehindCount> {
1884
- const result = await runGit(pi, ctx, ["rev-list", "--left-right", "--count", "HEAD...@{u}"], {
2184
+ const result = await runGit(pi, ctx, ["rev-list", "--left-right", "--count", comparison], {
1885
2185
  signal,
1886
2186
  timeout: GIT_STATUS_TIMEOUT_MS,
1887
2187
  });
@@ -1934,9 +2234,9 @@ export async function localBranchExists(
1934
2234
  return result.code === 0;
1935
2235
  }
1936
2236
 
1937
- function requireLosslessLocalRef(branchName: string): string {
2237
+ function requireLosslessLocalRef(branchName: string, prefix = "refs/heads/"): string {
1938
2238
  requireLosslessWorktreeIdentity(branchName, "branch");
1939
- const fullRef = `refs/heads/${branchName}`;
2239
+ const fullRef = `${prefix}${branchName}`;
1940
2240
  if (!isLosslessGitMetadata(fullRef, GIT_CONTEXT_VALUE_LIMIT_CHARS)) {
1941
2241
  throw new Error(
1942
2242
  "The full local branch ref cannot be returned safely and losslessly. " +
@@ -2014,9 +2314,28 @@ export async function inspectDirectLocalBranchRef(
2014
2314
  ctx: GitCommandContext,
2015
2315
  branchName: string,
2016
2316
  signal?: AbortSignal,
2317
+ ): Promise<DirectLocalBranchRefInspection> {
2318
+ return inspectDirectBranchRef(pi, ctx, branchName, "refs/heads/", signal);
2319
+ }
2320
+
2321
+ export async function inspectDirectRemoteTrackingRef(
2322
+ pi: Pick<ExtensionAPI, "exec">,
2323
+ ctx: GitCommandContext,
2324
+ refName: string,
2325
+ signal?: AbortSignal,
2326
+ ): Promise<DirectLocalBranchRefInspection> {
2327
+ return inspectDirectBranchRef(pi, ctx, refName, "refs/remotes/", signal);
2328
+ }
2329
+
2330
+ async function inspectDirectBranchRef(
2331
+ pi: Pick<ExtensionAPI, "exec">,
2332
+ ctx: GitCommandContext,
2333
+ branchName: string,
2334
+ prefix: string,
2335
+ signal?: AbortSignal,
2017
2336
  ): Promise<DirectLocalBranchRefInspection> {
2018
2337
  validateBranchNameInput(branchName);
2019
- const fullRef = requireLosslessLocalRef(branchName);
2338
+ const fullRef = requireLosslessLocalRef(branchName, prefix);
2020
2339
  const listArgs = [
2021
2340
  "for-each-ref",
2022
2341
  "--count=1",
@@ -2317,7 +2636,22 @@ export async function fetchRemoteBranch(
2317
2636
  const repoRoot = await getGitRoot(pi, ctx, signal);
2318
2637
  const rootCtx = { cwd: repoRoot };
2319
2638
 
2320
- return withRepositoryMutationQueue(repoRoot, async () => {
2639
+ return withRepositoryMutationQueue(
2640
+ repoRoot,
2641
+ fetchRemoteBranchWithinQueue.bind(undefined, pi, rootCtx, remote, branch, signal),
2642
+ );
2643
+ }
2644
+
2645
+ // Caller must hold the repository mutation queue.
2646
+ export async function fetchRemoteBranchWithinQueue(
2647
+ pi: Pick<ExtensionAPI, "exec">,
2648
+ rootCtx: GitCommandContext,
2649
+ remote: string,
2650
+ branch: string,
2651
+ signal?: AbortSignal,
2652
+ ): Promise<FetchRemoteBranchDetails> {
2653
+ const repoRoot = rootCtx.cwd;
2654
+ validateRequestedRemoteName(remote);
2321
2655
  await validateBranchName(pi, rootCtx, branch, signal);
2322
2656
  await requireConfiguredRemote(pi, rootCtx, remote, signal);
2323
2657
 
@@ -2343,7 +2677,6 @@ export async function fetchRemoteBranch(
2343
2677
  refspec: safeDetail(refspec),
2344
2678
  output: safeOutput(result.stdout || result.stderr),
2345
2679
  };
2346
- });
2347
2680
  }
2348
2681
 
2349
2682
  export async function pullCurrentBranch(
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,