@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/CHANGELOG.md +9 -2
- package/README.md +47 -13
- package/SECURITY.md +14 -5
- package/docs/PROJECT_DEFINITION_BRIEF.md +8 -3
- package/docs/SMOKE_TEST.md +3 -3
- package/docs/STRUCTURE.md +10 -6
- package/docs/TUI_CAPTURE.md +2 -2
- package/package.json +1 -1
- package/src/commands/branchme-command.ts +2 -2
- package/src/constants.ts +2 -0
- package/src/git-landing.ts +449 -0
- package/src/git-retirement.ts +34 -11
- package/src/git.ts +250 -28
- package/src/tools/branchme-tools.ts +88 -18
- package/src/types.ts +12 -0
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
|
-
|
|
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
|
-
|
|
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 === "!!")
|
|
1426
|
+
if (parsed.status === "!!") ignoredPaths.add(parsed.path.split("/")[0]);
|
|
1376
1427
|
}
|
|
1377
1428
|
|
|
1378
1429
|
return {
|
|
1379
1430
|
workingTree: parseWorkingTreeStatus(output).workingTree,
|
|
1380
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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",
|
|
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 =
|
|
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
|
-
|
|
2087
|
+
async function getVerifiedRefCommit(
|
|
1998
2088
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1999
2089
|
ctx: GitCommandContext,
|
|
2000
|
-
|
|
2090
|
+
revision: string,
|
|
2091
|
+
describedRef: string,
|
|
2001
2092
|
signal?: AbortSignal,
|
|
2002
2093
|
): Promise<string> {
|
|
2003
|
-
|
|
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
|
|
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
|
|
2061
|
-
const targetHead = await
|
|
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,
|