@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/CHANGELOG.md +15 -3
- package/README.md +93 -21
- package/SECURITY.md +34 -15
- package/docs/PROJECT_DEFINITION_BRIEF.md +34 -18
- package/docs/SMOKE_TEST.md +13 -4
- package/docs/STRUCTURE.md +22 -10
- package/docs/TUI_CAPTURE.md +41 -31
- package/package.json +3 -2
- package/src/commands/branchme-command.ts +13 -3
- package/src/constants.ts +15 -0
- package/src/git-integration.ts +27 -6
- package/src/git-landing.ts +501 -0
- package/src/git-retirement.ts +34 -11
- package/src/git-workflow.ts +136 -0
- package/src/git.ts +353 -20
- package/src/github.ts +115 -0
- package/src/tools/branchme-tools.ts +115 -9
- package/src/tools/workflow-tools.ts +86 -0
- package/src/types.ts +55 -0
- package/src/ui/branchme-panel.ts +3 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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 === "!!")
|
|
1700
|
+
if (parsed.status === "!!") ignoredPaths.add(parsed.path.split("/")[0]);
|
|
1423
1701
|
}
|
|
1424
1702
|
|
|
1425
1703
|
return {
|
|
1426
1704
|
workingTree: parseWorkingTreeStatus(output).workingTree,
|
|
1427
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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",
|
|
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 =
|
|
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(
|
|
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,
|