@senad-d/branchme 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/git.ts CHANGED
@@ -33,6 +33,7 @@ import type {
33
33
  CreateWorktreeMode,
34
34
  CurrentBranchInfo,
35
35
  FetchBranchDetails,
36
+ FetchRemoteBranchDetails,
36
37
  GitExecResult,
37
38
  GitFileChange,
38
39
  GitFileChangeSummary,
@@ -740,7 +741,7 @@ export function validateWorktreePathInput(worktreePath: unknown): string {
740
741
  return normalizedPath;
741
742
  }
742
743
 
743
- async function canonicalizePathAllowMissing(path: string): Promise<string | null> {
744
+ export async function canonicalizePathAllowMissing(path: string): Promise<string | null> {
744
745
  let candidate = normalize(path);
745
746
  const suffix: string[] = [];
746
747
 
@@ -758,7 +759,7 @@ async function canonicalizePathAllowMissing(path: string): Promise<string | null
758
759
  }
759
760
  }
760
761
 
761
- async function collectWorktreeInventory(
762
+ export async function collectWorktreeInventory(
762
763
  pi: Pick<ExtensionAPI, "exec">,
763
764
  ctx: GitCommandContext,
764
765
  signal?: AbortSignal,
@@ -782,7 +783,7 @@ async function collectWorktreeInventory(
782
783
  return { repoRoot, canonicalCurrentPath, entries };
783
784
  }
784
785
 
785
- function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string): boolean {
786
+ export function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string): boolean {
786
787
  const relation = relative(boundaryPath, candidatePath);
787
788
  return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
788
789
  }
@@ -1185,6 +1186,39 @@ function worktreeCreationFailure(
1185
1186
  );
1186
1187
  }
1187
1188
 
1189
+ async function resolveWorktreeBaseCommit(
1190
+ pi: Pick<ExtensionAPI, "exec">,
1191
+ ctx: GitCommandContext,
1192
+ baseRef: string,
1193
+ signal?: AbortSignal,
1194
+ ): Promise<string> {
1195
+ // baseRef is a read-only start point: it is resolved to a commit before mutation and
1196
+ // is never checked out, reset, or otherwise modified.
1197
+ if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(baseRef)) {
1198
+ const result = await runGit(pi, ctx, ["rev-parse", "--verify", `${baseRef}^{commit}`], {
1199
+ signal,
1200
+ timeout: GIT_STATUS_TIMEOUT_MS,
1201
+ allowFailure: true,
1202
+ });
1203
+ const commit = trimOutput(result.stdout);
1204
+ if (result.code !== 0 || !/^[0-9a-f]{40,64}$/iu.test(commit)) {
1205
+ throw new Error(`baseRef ${safeWorktreeBranchLabel(baseRef)} does not resolve to an existing commit.`);
1206
+ }
1207
+ return commit;
1208
+ }
1209
+
1210
+ await validateBranchName(pi, ctx, baseRef, signal);
1211
+ if (await localBranchExists(pi, ctx, baseRef, signal)) {
1212
+ return getLocalBranchCommit(pi, ctx, baseRef, signal);
1213
+ }
1214
+ if (await remoteTrackingRefExists(pi, ctx, baseRef, signal)) {
1215
+ return getRemoteTrackingRefCommit(pi, ctx, baseRef, signal);
1216
+ }
1217
+ throw new Error(
1218
+ `baseRef ${safeWorktreeBranchLabel(baseRef)} does not name an existing local branch, remote-tracking ref, or full commit.`,
1219
+ );
1220
+ }
1221
+
1188
1222
  export async function createWorktree(
1189
1223
  pi: Pick<ExtensionAPI, "exec">,
1190
1224
  ctx: GitCommandContext,
@@ -1192,8 +1226,15 @@ export async function createWorktree(
1192
1226
  branchName: string,
1193
1227
  branchMode: CreateWorktreeMode,
1194
1228
  signal?: AbortSignal,
1229
+ baseRef?: string,
1195
1230
  ): Promise<CreateWorktreeDetails> {
1196
1231
  validateCreateWorktreeMode(branchMode);
1232
+ if (baseRef !== undefined) {
1233
+ if (branchMode !== "new") {
1234
+ throw new Error("baseRef is only supported with branchMode 'new'; an existing local branch keeps its own commit.");
1235
+ }
1236
+ validateBranchNameInput(baseRef, "baseRef");
1237
+ }
1197
1238
  const requestedWorktreePath = validateWorktreePathInput(worktreePath);
1198
1239
  const repoRoot = await getGitRoot(pi, ctx, signal);
1199
1240
  const rootCtx = { cwd: repoRoot };
@@ -1222,11 +1263,14 @@ export async function createWorktree(
1222
1263
  throw new Error(`Local branch ${safeWorktreeBranchLabel(branchName)} is already checked out in a worktree.`);
1223
1264
  }
1224
1265
 
1266
+ const baseCommit = baseRef === undefined
1267
+ ? null
1268
+ : await resolveWorktreeBaseCommit(pi, rootCtx, baseRef, signal);
1225
1269
  const expectedHead = branchMode === "new"
1226
- ? sourceHead
1270
+ ? baseCommit ?? sourceHead
1227
1271
  : await getLocalBranchCommit(pi, rootCtx, branchName, signal);
1228
1272
  const args = branchMode === "new"
1229
- ? ["worktree", "add", "-b", branchName, prepared.canonicalPath, "HEAD"]
1273
+ ? ["worktree", "add", "-b", branchName, prepared.canonicalPath, baseCommit ?? "HEAD"]
1230
1274
  : ["worktree", "add", prepared.canonicalPath, branchName];
1231
1275
 
1232
1276
  try {
@@ -1263,6 +1307,9 @@ export async function createWorktree(
1263
1307
  worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
1264
1308
  branchName: handoffBranch,
1265
1309
  branchMode,
1310
+ ...(baseRef === undefined
1311
+ ? {}
1312
+ : { baseRef: safeWorktreeValue(baseRef, GIT_CONTEXT_VALUE_LIMIT_CHARS) }),
1266
1313
  },
1267
1314
  verified: {
1268
1315
  before: {
@@ -1352,7 +1399,11 @@ async function requirePresentWorktreeDirectory(canonicalPath: string): Promise<v
1352
1399
 
1353
1400
  interface WorktreeRemovalStatus {
1354
1401
  workingTree: WorkingTreeDetails;
1355
- hasIgnoredEntries: boolean;
1402
+ ignoredPaths: string[];
1403
+ }
1404
+
1405
+ function compareIgnoredPaths(left: string, right: string): number {
1406
+ return left.localeCompare(right);
1356
1407
  }
1357
1408
 
1358
1409
  function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
@@ -1361,7 +1412,7 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
1361
1412
  }
1362
1413
 
1363
1414
  const records = output.split(NUL_SEPARATOR);
1364
- let hasIgnoredEntries = false;
1415
+ const ignoredPaths = new Set<string>();
1365
1416
  let skipNextRecord = false;
1366
1417
  for (const [index, record] of records.entries()) {
1367
1418
  if (skipNextRecord) {
@@ -1372,12 +1423,12 @@ function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
1372
1423
 
1373
1424
  const parsed = parsePorcelainChange(records, index);
1374
1425
  skipNextRecord = parsed.nextIndex > index;
1375
- if (parsed.status === "!!") hasIgnoredEntries = true;
1426
+ if (parsed.status === "!!") ignoredPaths.add(parsed.path.split("/")[0]);
1376
1427
  }
1377
1428
 
1378
1429
  return {
1379
1430
  workingTree: parseWorkingTreeStatus(output).workingTree,
1380
- hasIgnoredEntries,
1431
+ ignoredPaths: [...ignoredPaths].sort(compareIgnoredPaths),
1381
1432
  };
1382
1433
  }
1383
1434
 
@@ -1385,7 +1436,8 @@ async function inspectRemovableWorktree(
1385
1436
  pi: Pick<ExtensionAPI, "exec">,
1386
1437
  canonicalPath: string,
1387
1438
  signal?: AbortSignal,
1388
- ): Promise<WorkingTreeDetails> {
1439
+ allowIgnored = false,
1440
+ ): Promise<WorktreeRemovalStatus> {
1389
1441
  const workingTree = await inspectWorktreeState(pi, canonicalPath, signal);
1390
1442
  if (workingTree.state !== "clean") {
1391
1443
  throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
@@ -1399,12 +1451,12 @@ async function inspectRemovableWorktree(
1399
1451
  if (removalStatus.workingTree.state !== "clean") {
1400
1452
  throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
1401
1453
  }
1402
- if (removalStatus.hasIgnoredEntries) {
1454
+ if (!allowIgnored && removalStatus.ignoredPaths.length > 0) {
1403
1455
  throw new Error(
1404
1456
  "The target worktree contains ignored files or directories; remove or preserve them outside the checkout before removal.",
1405
1457
  );
1406
1458
  }
1407
- return workingTree;
1459
+ return removalStatus;
1408
1460
  }
1409
1461
 
1410
1462
  function assertWorktreeRemoved(
@@ -1440,7 +1492,22 @@ export async function removeWorktree(
1440
1492
  const repoRoot = await getGitRoot(pi, ctx, signal);
1441
1493
  const rootCtx = { cwd: repoRoot };
1442
1494
 
1443
- return withRepositoryMutationQueue(repoRoot, async () => {
1495
+ return withRepositoryMutationQueue(
1496
+ repoRoot,
1497
+ removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal),
1498
+ );
1499
+ }
1500
+
1501
+ // Caller must hold the repository mutation queue. Standalone removal still refuses ignored residue.
1502
+ export async function removeWorktreeWithinQueue(
1503
+ pi: Pick<ExtensionAPI, "exec">,
1504
+ rootCtx: GitCommandContext,
1505
+ requestedWorktreePath: string,
1506
+ signal?: AbortSignal,
1507
+ allowIgnored = false,
1508
+ expectedIdentity?: { branch: string; head: string },
1509
+ ): Promise<RemoveWorktreeDetails & { deletedIgnoredPaths: string[] }> {
1510
+ const repoRoot = rootCtx.cwd;
1444
1511
  const resolved = await resolveWorktreeRemovalTarget(
1445
1512
  pi,
1446
1513
  rootCtx,
@@ -1452,6 +1519,9 @@ export async function removeWorktree(
1452
1519
  }
1453
1520
 
1454
1521
  const worktree = requireRemovableWorktreeEntry(resolved);
1522
+ if (expectedIdentity && (worktree.branch !== expectedIdentity.branch || worktree.head !== expectedIdentity.head)) {
1523
+ throw new Error("The selected worktree branch or HEAD moved after the landing ancestry proof.");
1524
+ }
1455
1525
  const branchName = resolved.entry.record.branch;
1456
1526
  const head = resolved.entry.record.head;
1457
1527
  if (branchName === null || head === null) {
@@ -1460,7 +1530,7 @@ export async function removeWorktree(
1460
1530
  const verifiedCanonicalPath = requireLosslessWorktreeIdentity(resolved.canonicalPath, "cwd");
1461
1531
  const retainedBranch = requireLosslessWorktreeIdentity(branchName, "branch");
1462
1532
  await requirePresentWorktreeDirectory(verifiedCanonicalPath);
1463
- const workingTree = await inspectRemovableWorktree(pi, verifiedCanonicalPath, signal);
1533
+ const { workingTree, ignoredPaths } = await inspectRemovableWorktree(pi, verifiedCanonicalPath, signal, allowIgnored);
1464
1534
  const branchHeadBefore = await getLocalBranchCommit(pi, rootCtx, retainedBranch, signal);
1465
1535
  if (branchHeadBefore.toLowerCase() !== head.toLowerCase()) {
1466
1536
  throw new Error("The target local branch did not match the worktree HEAD before removal.");
@@ -1493,6 +1563,7 @@ export async function removeWorktree(
1493
1563
  );
1494
1564
  return {
1495
1565
  action: "remove_worktree",
1566
+ deletedIgnoredPaths: ignoredPaths.map((path) => redactSecrets(path)),
1496
1567
  repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
1497
1568
  request: {
1498
1569
  worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
@@ -1520,7 +1591,6 @@ export async function removeWorktree(
1520
1591
  } catch (error) {
1521
1592
  throw worktreeRemovalFailure(error, resolved.canonicalPath, branchName);
1522
1593
  }
1523
- });
1524
1594
  }
1525
1595
 
1526
1596
  function isRenameOrCopyStatus(status: string): boolean {
@@ -1833,8 +1903,9 @@ export async function getAheadBehindCount(
1833
1903
  pi: Pick<ExtensionAPI, "exec">,
1834
1904
  ctx: GitCommandContext,
1835
1905
  signal?: AbortSignal,
1906
+ comparison = "HEAD...@{u}",
1836
1907
  ): Promise<AheadBehindCount> {
1837
- const result = await runGit(pi, ctx, ["rev-list", "--left-right", "--count", "HEAD...@{u}"], {
1908
+ const result = await runGit(pi, ctx, ["rev-list", "--left-right", "--count", comparison], {
1838
1909
  signal,
1839
1910
  timeout: GIT_STATUS_TIMEOUT_MS,
1840
1911
  });
@@ -1887,9 +1958,9 @@ export async function localBranchExists(
1887
1958
  return result.code === 0;
1888
1959
  }
1889
1960
 
1890
- function requireLosslessLocalRef(branchName: string): string {
1961
+ function requireLosslessLocalRef(branchName: string, prefix = "refs/heads/"): string {
1891
1962
  requireLosslessWorktreeIdentity(branchName, "branch");
1892
- const fullRef = `refs/heads/${branchName}`;
1963
+ const fullRef = `${prefix}${branchName}`;
1893
1964
  if (!isLosslessGitMetadata(fullRef, GIT_CONTEXT_VALUE_LIMIT_CHARS)) {
1894
1965
  throw new Error(
1895
1966
  "The full local branch ref cannot be returned safely and losslessly. " +
@@ -1967,9 +2038,28 @@ export async function inspectDirectLocalBranchRef(
1967
2038
  ctx: GitCommandContext,
1968
2039
  branchName: string,
1969
2040
  signal?: AbortSignal,
2041
+ ): Promise<DirectLocalBranchRefInspection> {
2042
+ return inspectDirectBranchRef(pi, ctx, branchName, "refs/heads/", signal);
2043
+ }
2044
+
2045
+ export async function inspectDirectRemoteTrackingRef(
2046
+ pi: Pick<ExtensionAPI, "exec">,
2047
+ ctx: GitCommandContext,
2048
+ refName: string,
2049
+ signal?: AbortSignal,
2050
+ ): Promise<DirectLocalBranchRefInspection> {
2051
+ return inspectDirectBranchRef(pi, ctx, refName, "refs/remotes/", signal);
2052
+ }
2053
+
2054
+ async function inspectDirectBranchRef(
2055
+ pi: Pick<ExtensionAPI, "exec">,
2056
+ ctx: GitCommandContext,
2057
+ branchName: string,
2058
+ prefix: string,
2059
+ signal?: AbortSignal,
1970
2060
  ): Promise<DirectLocalBranchRefInspection> {
1971
2061
  validateBranchNameInput(branchName);
1972
- const fullRef = requireLosslessLocalRef(branchName);
2062
+ const fullRef = requireLosslessLocalRef(branchName, prefix);
1973
2063
  const listArgs = [
1974
2064
  "for-each-ref",
1975
2065
  "--count=1",
@@ -1994,24 +2084,58 @@ export async function inspectDirectLocalBranchRef(
1994
2084
  };
1995
2085
  }
1996
2086
 
1997
- export async function getLocalBranchCommit(
2087
+ async function getVerifiedRefCommit(
1998
2088
  pi: Pick<ExtensionAPI, "exec">,
1999
2089
  ctx: GitCommandContext,
2000
- branchName: string,
2090
+ revision: string,
2091
+ describedRef: string,
2001
2092
  signal?: AbortSignal,
2002
2093
  ): Promise<string> {
2003
- validateBranchNameInput(branchName);
2004
- const result = await runGit(pi, ctx, ["rev-parse", "--verify", `refs/heads/${branchName}^{commit}`], {
2094
+ const result = await runGit(pi, ctx, ["rev-parse", "--verify", `${revision}^{commit}`], {
2005
2095
  signal,
2006
2096
  timeout: GIT_STATUS_TIMEOUT_MS,
2007
2097
  });
2008
2098
  const commit = trimOutput(result.stdout);
2009
2099
  if (!/^[0-9a-f]{40,64}$/iu.test(commit)) {
2010
- throw new Error(`Unable to resolve local branch '${safeGitContextValue(branchName)}' to a commit: ${safeOutput(result.stdout) || "empty output"}`);
2100
+ throw new Error(`Unable to resolve ${describedRef} to a commit: ${safeOutput(result.stdout) || "empty output"}`);
2011
2101
  }
2012
2102
  return commit;
2013
2103
  }
2014
2104
 
2105
+ export async function getLocalBranchCommit(
2106
+ pi: Pick<ExtensionAPI, "exec">,
2107
+ ctx: GitCommandContext,
2108
+ branchName: string,
2109
+ signal?: AbortSignal,
2110
+ ): Promise<string> {
2111
+ validateBranchNameInput(branchName);
2112
+ return getVerifiedRefCommit(pi, ctx, `refs/heads/${branchName}`, `local branch '${safeGitContextValue(branchName)}'`, signal);
2113
+ }
2114
+
2115
+ export async function remoteTrackingRefExists(
2116
+ pi: Pick<ExtensionAPI, "exec">,
2117
+ ctx: GitCommandContext,
2118
+ refName: string,
2119
+ signal?: AbortSignal,
2120
+ ): Promise<boolean> {
2121
+ const result = await runGit(pi, ctx, ["show-ref", "--verify", "--quiet", `refs/remotes/${refName}`], {
2122
+ signal,
2123
+ timeout: GIT_STATUS_TIMEOUT_MS,
2124
+ allowFailure: true,
2125
+ });
2126
+ return result.code === 0;
2127
+ }
2128
+
2129
+ export async function getRemoteTrackingRefCommit(
2130
+ pi: Pick<ExtensionAPI, "exec">,
2131
+ ctx: GitCommandContext,
2132
+ refName: string,
2133
+ signal?: AbortSignal,
2134
+ ): Promise<string> {
2135
+ validateBranchNameInput(refName, "Remote-tracking ref");
2136
+ return getVerifiedRefCommit(pi, ctx, `refs/remotes/${refName}`, `remote-tracking ref '${safeGitContextValue(refName)}'`, signal);
2137
+ }
2138
+
2015
2139
  export async function requireExistingLocalBranch(
2016
2140
  pi: Pick<ExtensionAPI, "exec">,
2017
2141
  ctx: GitCommandContext,
@@ -2044,6 +2168,24 @@ export async function isCommitAncestor(
2044
2168
  return result.code === 0;
2045
2169
  }
2046
2170
 
2171
+ async function resolveAncestryRefCommit(
2172
+ pi: Pick<ExtensionAPI, "exec">,
2173
+ ctx: GitCommandContext,
2174
+ refName: string,
2175
+ label: "Source" | "Target",
2176
+ signal?: AbortSignal,
2177
+ ): Promise<string> {
2178
+ // Read-only comparison endpoints: local branches keep precedence, remote-tracking refs
2179
+ // (for example origin/main) are accepted as read baselines and are never mutated.
2180
+ if (await localBranchExists(pi, ctx, refName, signal)) {
2181
+ return getLocalBranchCommit(pi, ctx, refName, signal);
2182
+ }
2183
+ if (await remoteTrackingRefExists(pi, ctx, refName, signal)) {
2184
+ return getRemoteTrackingRefCommit(pi, ctx, refName, signal);
2185
+ }
2186
+ throw new Error(`${label} local branch or remote-tracking ref '${safeGitContextValue(refName)}' does not exist.`);
2187
+ }
2188
+
2047
2189
  export async function getLocalBranchAncestry(
2048
2190
  pi: Pick<ExtensionAPI, "exec">,
2049
2191
  ctx: GitCommandContext,
@@ -2054,11 +2196,9 @@ export async function getLocalBranchAncestry(
2054
2196
  validateBranchNameInput(query.targetBranch, "Target branch");
2055
2197
  await validateBranchName(pi, ctx, query.sourceBranch, signal);
2056
2198
  await validateBranchName(pi, ctx, query.targetBranch, signal);
2057
- await requireExistingLocalBranch(pi, ctx, query.sourceBranch, "Source", signal);
2058
- await requireExistingLocalBranch(pi, ctx, query.targetBranch, "Target", signal);
2059
2199
 
2060
- const sourceHead = await getLocalBranchCommit(pi, ctx, query.sourceBranch, signal);
2061
- const targetHead = await getLocalBranchCommit(pi, ctx, query.targetBranch, signal);
2200
+ const sourceHead = await resolveAncestryRefCommit(pi, ctx, query.sourceBranch, "Source", signal);
2201
+ const targetHead = await resolveAncestryRefCommit(pi, ctx, query.targetBranch, "Target", signal);
2062
2202
  const isAncestor = await isCommitAncestor(pi, ctx, sourceHead, targetHead, signal);
2063
2203
 
2064
2204
  return {
@@ -2181,6 +2321,88 @@ export async function fetchCurrentBranch(
2181
2321
  });
2182
2322
  }
2183
2323
 
2324
+ function validateRequestedRemoteName(remote: string): void {
2325
+ if (!remote || /[\u0000-\u001f\u007f]/u.test(remote) || /\s/u.test(remote)) {
2326
+ throw new Error("Unable to fetch: remote cannot be blank or contain whitespace or control characters.");
2327
+ }
2328
+ if (remote === ".") throw new Error("Unable to fetch: remote must name a configured Git remote, not '.'.");
2329
+ if (remote.startsWith("-")) throw new Error("Unable to fetch: remote cannot start with '-'.");
2330
+ if (remote.includes(":") || remote.includes("@")) {
2331
+ throw new Error("Unable to fetch: remote cannot be a URL or user-prefixed target.");
2332
+ }
2333
+ }
2334
+
2335
+ async function requireConfiguredRemote(
2336
+ pi: Pick<ExtensionAPI, "exec">,
2337
+ ctx: GitCommandContext,
2338
+ remote: string,
2339
+ signal?: AbortSignal,
2340
+ ): Promise<void> {
2341
+ const result = await runGit(pi, ctx, ["remote", "get-url", remote], {
2342
+ signal,
2343
+ timeout: GIT_STATUS_TIMEOUT_MS,
2344
+ allowFailure: true,
2345
+ });
2346
+ if (result.code !== 0) {
2347
+ throw new Error(`Unable to fetch: remote '${safeGitContextValue(remote)}' is not a configured Git remote.`);
2348
+ }
2349
+ }
2350
+
2351
+ export async function fetchRemoteBranch(
2352
+ pi: Pick<ExtensionAPI, "exec">,
2353
+ ctx: GitCommandContext,
2354
+ remote: string,
2355
+ branch: string,
2356
+ signal?: AbortSignal,
2357
+ ): Promise<FetchRemoteBranchDetails> {
2358
+ validateRequestedRemoteName(remote);
2359
+ validateBranchNameInput(branch, "branch");
2360
+ const repoRoot = await getGitRoot(pi, ctx, signal);
2361
+ const rootCtx = { cwd: repoRoot };
2362
+
2363
+ return withRepositoryMutationQueue(
2364
+ repoRoot,
2365
+ fetchRemoteBranchWithinQueue.bind(undefined, pi, rootCtx, remote, branch, signal),
2366
+ );
2367
+ }
2368
+
2369
+ // Caller must hold the repository mutation queue.
2370
+ export async function fetchRemoteBranchWithinQueue(
2371
+ pi: Pick<ExtensionAPI, "exec">,
2372
+ rootCtx: GitCommandContext,
2373
+ remote: string,
2374
+ branch: string,
2375
+ signal?: AbortSignal,
2376
+ ): Promise<FetchRemoteBranchDetails> {
2377
+ const repoRoot = rootCtx.cwd;
2378
+ validateRequestedRemoteName(remote);
2379
+ await validateBranchName(pi, rootCtx, branch, signal);
2380
+ await requireConfiguredRemote(pi, rootCtx, remote, signal);
2381
+
2382
+ const remoteRef = `refs/heads/${branch}`;
2383
+ const trackingRef = `refs/remotes/${remote}/${branch}`;
2384
+ const refspec = `${remoteRef}:${trackingRef}`;
2385
+ const result = await runGit(
2386
+ pi,
2387
+ rootCtx,
2388
+ ["fetch", "--no-tags", "--no-recurse-submodules", remote, refspec],
2389
+ {
2390
+ signal,
2391
+ timeout: GIT_FETCH_TIMEOUT_MS,
2392
+ },
2393
+ );
2394
+
2395
+ return {
2396
+ repoRoot,
2397
+ remote: safeDetail(remote),
2398
+ branch: safeDetail(branch),
2399
+ remoteRef: safeDetail(remoteRef),
2400
+ remoteTrackingRef: safeDetail(trackingRef),
2401
+ refspec: safeDetail(refspec),
2402
+ output: safeOutput(result.stdout || result.stderr),
2403
+ };
2404
+ }
2405
+
2184
2406
  export async function pullCurrentBranch(
2185
2407
  pi: Pick<ExtensionAPI, "exec">,
2186
2408
  ctx: GitCommandContext,