@senad-d/branchme 0.3.0 → 0.3.2
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 +69 -31
- package/SECURITY.md +31 -21
- package/docs/PROJECT_DEFINITION_BRIEF.md +36 -23
- package/docs/SMOKE_TEST.md +13 -4
- package/docs/STRUCTURE.md +22 -14
- package/docs/TUI_CAPTURE.md +41 -31
- package/package.json +11 -5
- package/src/commands/branchme-command.ts +13 -3
- package/src/constants.ts +13 -0
- package/src/git-integration.ts +27 -6
- package/src/git-landing.ts +63 -11
- package/src/git-workflow.ts +144 -0
- package/src/git.ts +319 -19
- package/src/github.ts +115 -0
- package/src/tools/branchme-tools.ts +84 -12
- 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,
|
|
@@ -759,6 +954,14 @@ export async function canonicalizePathAllowMissing(path: string): Promise<string
|
|
|
759
954
|
}
|
|
760
955
|
}
|
|
761
956
|
|
|
957
|
+
async function resolveWorktreeInventoryEntry(
|
|
958
|
+
record: ParsedWorktreeRecord,
|
|
959
|
+
index: number,
|
|
960
|
+
): Promise<WorktreeInventoryEntry> {
|
|
961
|
+
const canonicalPath = await canonicalizePathAllowMissing(record.rawPath);
|
|
962
|
+
return { record, index, canonicalPath };
|
|
963
|
+
}
|
|
964
|
+
|
|
762
965
|
export async function collectWorktreeInventory(
|
|
763
966
|
pi: Pick<ExtensionAPI, "exec">,
|
|
764
967
|
ctx: GitCommandContext,
|
|
@@ -776,10 +979,11 @@ export async function collectWorktreeInventory(
|
|
|
776
979
|
throw new Error("Unable to inspect worktrees: current repository path could not be resolved.");
|
|
777
980
|
}
|
|
778
981
|
|
|
779
|
-
const
|
|
982
|
+
const entryPromises: Promise<WorktreeInventoryEntry>[] = [];
|
|
780
983
|
for (const [index, record] of records.entries()) {
|
|
781
|
-
|
|
984
|
+
entryPromises.push(resolveWorktreeInventoryEntry(record, index));
|
|
782
985
|
}
|
|
986
|
+
const entries = await Promise.all(entryPromises);
|
|
783
987
|
return { repoRoot, canonicalCurrentPath, entries };
|
|
784
988
|
}
|
|
785
989
|
|
|
@@ -788,16 +992,20 @@ export function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string)
|
|
|
788
992
|
return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
|
|
789
993
|
}
|
|
790
994
|
|
|
791
|
-
async function
|
|
792
|
-
const parentPath = dirname(worktreePath);
|
|
793
|
-
let parentStats;
|
|
995
|
+
async function requireCanonicalCreationParent(parentPath: string): Promise<string> {
|
|
794
996
|
try {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
997
|
+
// lstat distinguishes a missing directory from a dangling symlink.
|
|
998
|
+
await lstat(parentPath);
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
if (filesystemErrorCode(error) === "ENOTDIR") {
|
|
1001
|
+
throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} must be a directory.`);
|
|
1002
|
+
}
|
|
1003
|
+
const ancestorPath = dirname(parentPath);
|
|
1004
|
+
if (filesystemErrorCode(error) !== "ENOENT" || ancestorPath === parentPath) {
|
|
1005
|
+
throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} could not be inspected.`);
|
|
1006
|
+
}
|
|
1007
|
+
// Resolve missing ancestors without writing; Git creates them after preflight.
|
|
1008
|
+
return join(await requireCanonicalCreationParent(ancestorPath), basename(parentPath));
|
|
801
1009
|
}
|
|
802
1010
|
|
|
803
1011
|
let canonicalParent: string;
|
|
@@ -806,12 +1014,25 @@ async function requireCanonicalCreationPath(worktreePath: string): Promise<strin
|
|
|
806
1014
|
} catch {
|
|
807
1015
|
throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} could not be resolved.`);
|
|
808
1016
|
}
|
|
1017
|
+
let parentStats;
|
|
1018
|
+
try {
|
|
1019
|
+
parentStats = await stat(canonicalParent);
|
|
1020
|
+
} catch {
|
|
1021
|
+
throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} could not be inspected.`);
|
|
1022
|
+
}
|
|
1023
|
+
if (!parentStats.isDirectory()) {
|
|
1024
|
+
throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} must be a directory.`);
|
|
1025
|
+
}
|
|
1026
|
+
return canonicalParent;
|
|
1027
|
+
}
|
|
809
1028
|
|
|
1029
|
+
async function requireCanonicalCreationPath(worktreePath: string): Promise<string> {
|
|
1030
|
+
const canonicalParent = await requireCanonicalCreationParent(dirname(worktreePath));
|
|
810
1031
|
const canonicalPath = join(canonicalParent, basename(worktreePath));
|
|
811
1032
|
try {
|
|
812
1033
|
await lstat(canonicalPath);
|
|
813
1034
|
} catch (error) {
|
|
814
|
-
if (
|
|
1035
|
+
if (filesystemErrorCode(error) === "ENOENT") return canonicalPath;
|
|
815
1036
|
throw new Error(`worktreePath destination ${safeWorktreePathLabel(canonicalPath)} could not be inspected.`);
|
|
816
1037
|
}
|
|
817
1038
|
throw new Error(`worktreePath destination ${safeWorktreePathLabel(canonicalPath)} already exists.`);
|
|
@@ -897,10 +1118,8 @@ export async function getGitOperationState(
|
|
|
897
1118
|
}
|
|
898
1119
|
|
|
899
1120
|
const active: GitOperationKind[] = [];
|
|
900
|
-
const presentMarkers
|
|
901
|
-
for (const [index,
|
|
902
|
-
const present = await gitOperationMarkerExists(path);
|
|
903
|
-
presentMarkers.push(present);
|
|
1121
|
+
const presentMarkers = await Promise.all(paths.map(gitOperationMarkerExists));
|
|
1122
|
+
for (const [index, present] of presentMarkers.entries()) {
|
|
904
1123
|
if (!present) continue;
|
|
905
1124
|
const operation = GIT_OPERATION_MARKERS[index][1];
|
|
906
1125
|
if (!active.includes(operation)) active.push(operation);
|
|
@@ -1000,6 +1219,85 @@ export async function validateWorktreeRemovalPath(
|
|
|
1000
1219
|
};
|
|
1001
1220
|
}
|
|
1002
1221
|
|
|
1222
|
+
const GIT_BRANCH_FORMAT = "%(refname)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(symref)%00%(upstream:track)";
|
|
1223
|
+
|
|
1224
|
+
function branchTrackingCounts(upstream: string, track: string): AheadBehindCount {
|
|
1225
|
+
if (!upstream || track === "[gone]") return { ahead: null, behind: null };
|
|
1226
|
+
if (track !== "" && !/^\[(?:ahead \d+(?:, behind \d+)?|behind \d+)\]$/u.test(track)) {
|
|
1227
|
+
throw new TypeError("Unable to parse branches: malformed upstream counts.");
|
|
1228
|
+
}
|
|
1229
|
+
const ahead = Number(/ahead (\d+)/u.exec(track)?.[1] ?? 0);
|
|
1230
|
+
const behind = Number(/behind (\d+)/u.exec(track)?.[1] ?? 0);
|
|
1231
|
+
if (!Number.isSafeInteger(ahead) || !Number.isSafeInteger(behind)) throw new TypeError("Unable to parse branches: invalid upstream counts.");
|
|
1232
|
+
return { ahead, behind };
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function parseBranchRecord(record: string): BranchEntry {
|
|
1236
|
+
const fields = record.split(NUL_SEPARATOR);
|
|
1237
|
+
if (fields.length !== 6) throw new TypeError("Unable to parse branches: malformed ref output.");
|
|
1238
|
+
const [fullRef, head, current, upstream, symbolicTarget, track] = fields;
|
|
1239
|
+
const kind = fullRef.startsWith("refs/heads/") ? "local" : "remote-tracking";
|
|
1240
|
+
const prefix = kind === "local" ? "refs/heads/" : "refs/remotes/";
|
|
1241
|
+
if (!fullRef.startsWith(prefix) || fullRef.length === prefix.length ||
|
|
1242
|
+
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(head) || !["*", " "].includes(current)) {
|
|
1243
|
+
throw new TypeError("Unable to parse branches: malformed ref output.");
|
|
1244
|
+
}
|
|
1245
|
+
return {
|
|
1246
|
+
name: safeGitContextValue(fullRef.slice(prefix.length)),
|
|
1247
|
+
fullRef: safeGitContextValue(fullRef),
|
|
1248
|
+
kind,
|
|
1249
|
+
head,
|
|
1250
|
+
current: current === "*",
|
|
1251
|
+
upstream: upstream ? safeGitContextValue(upstream) : null,
|
|
1252
|
+
...branchTrackingCounts(upstream, track),
|
|
1253
|
+
symbolicTarget: symbolicTarget ? safeGitContextValue(symbolicTarget) : null,
|
|
1254
|
+
worktreePaths: [],
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export function parseBranchRefs(output: string): Pick<ListBranchesDetails, "branches" | "omitted"> {
|
|
1259
|
+
if (Buffer.byteLength(output, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
1260
|
+
throw new TypeError("Unable to parse branches: ref output exceeded the safety limit.");
|
|
1261
|
+
}
|
|
1262
|
+
const records = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
|
|
1263
|
+
if (records.length === 1 && records[0] === "") return { branches: [], omitted: 0 };
|
|
1264
|
+
const branches: BranchEntry[] = [];
|
|
1265
|
+
const seen = new Set<string>();
|
|
1266
|
+
for (const record of records) {
|
|
1267
|
+
const branch = parseBranchRecord(record);
|
|
1268
|
+
const rawRef = record.split(NUL_SEPARATOR, 1)[0];
|
|
1269
|
+
if (seen.has(rawRef)) throw new TypeError("Unable to parse branches: duplicate ref output.");
|
|
1270
|
+
seen.add(rawRef);
|
|
1271
|
+
if (branches.length < GIT_BRANCH_ENTRY_LIMIT) branches.push(branch);
|
|
1272
|
+
}
|
|
1273
|
+
return { branches, omitted: records.length - branches.length };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
export async function listBranches(
|
|
1277
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1278
|
+
ctx: GitCommandContext,
|
|
1279
|
+
signal?: AbortSignal,
|
|
1280
|
+
): Promise<ListBranchesDetails> {
|
|
1281
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
1282
|
+
const rootCtx = { cwd: repoRoot };
|
|
1283
|
+
const result = await runGit(pi, rootCtx, [
|
|
1284
|
+
"for-each-ref", "--sort=refname", `--format=${GIT_BRANCH_FORMAT}`, "refs/heads/", "refs/remotes/",
|
|
1285
|
+
], { signal });
|
|
1286
|
+
const parsed = parseBranchRefs(result.stdout);
|
|
1287
|
+
const inventory = await collectWorktreeInventory(pi, rootCtx, signal);
|
|
1288
|
+
const rawRecords = result.stdout.split("\n");
|
|
1289
|
+
for (const [index, branch] of parsed.branches.entries()) {
|
|
1290
|
+
if (branch.kind !== "local") continue;
|
|
1291
|
+
const rawName = rawRecords[index].split(NUL_SEPARATOR, 1)[0].slice("refs/heads/".length);
|
|
1292
|
+
for (const entry of inventory.entries) {
|
|
1293
|
+
if (entry.record.branch === rawName) {
|
|
1294
|
+
branch.worktreePaths.push(safeWorktreeValue(entry.record.rawPath, GIT_WORKTREE_PATH_LIMIT_CHARS));
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return { action: "list_branches", repoRoot: safeGitContextValue(repoRoot), ...parsed };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1003
1301
|
export async function listWorktrees(
|
|
1004
1302
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1005
1303
|
ctx: GitCommandContext,
|
|
@@ -1487,6 +1785,7 @@ export async function removeWorktree(
|
|
|
1487
1785
|
ctx: GitCommandContext,
|
|
1488
1786
|
worktreePath: unknown,
|
|
1489
1787
|
signal?: AbortSignal,
|
|
1788
|
+
deleteIgnored = false,
|
|
1490
1789
|
): Promise<RemoveWorktreeDetails> {
|
|
1491
1790
|
const requestedWorktreePath = validateWorktreePathInput(worktreePath);
|
|
1492
1791
|
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
@@ -1494,11 +1793,11 @@ export async function removeWorktree(
|
|
|
1494
1793
|
|
|
1495
1794
|
return withRepositoryMutationQueue(
|
|
1496
1795
|
repoRoot,
|
|
1497
|
-
removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal),
|
|
1796
|
+
removeWorktreeWithinQueue.bind(undefined, pi, rootCtx, requestedWorktreePath, signal, deleteIgnored),
|
|
1498
1797
|
);
|
|
1499
1798
|
}
|
|
1500
1799
|
|
|
1501
|
-
// Caller must hold the repository mutation queue.
|
|
1800
|
+
// Caller must hold the repository mutation queue. Ignored residue requires explicit authorization.
|
|
1502
1801
|
export async function removeWorktreeWithinQueue(
|
|
1503
1802
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1504
1803
|
rootCtx: GitCommandContext,
|
|
@@ -1567,6 +1866,7 @@ export async function removeWorktreeWithinQueue(
|
|
|
1567
1866
|
repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1568
1867
|
request: {
|
|
1569
1868
|
worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1869
|
+
...(allowIgnored ? { deleteIgnored: true } : {}),
|
|
1570
1870
|
},
|
|
1571
1871
|
verified: {
|
|
1572
1872
|
before: {
|
package/src/github.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getCurrentBranch,
|
|
17
17
|
getGitRoot,
|
|
18
18
|
getOriginUrl,
|
|
19
|
+
requireLosslessWorktreeIdentity,
|
|
19
20
|
validateBranchNameInput,
|
|
20
21
|
type GitCommandContext,
|
|
21
22
|
} from "./git.ts";
|
|
@@ -23,6 +24,8 @@ import type {
|
|
|
23
24
|
GitHubRepository,
|
|
24
25
|
PullRequestDetails,
|
|
25
26
|
PullRequestInput,
|
|
27
|
+
PullRequestStatusDetails,
|
|
28
|
+
PullRequestToolDetails,
|
|
26
29
|
RelatedPullRequest,
|
|
27
30
|
RelatedPullRequestDetails,
|
|
28
31
|
} from "./types.ts";
|
|
@@ -984,6 +987,118 @@ export async function ensureGitHubBranchExists(
|
|
|
984
987
|
return readGitHubBranchDetails(response, options.signal, token, branchName, field, branchLabel);
|
|
985
988
|
}
|
|
986
989
|
|
|
990
|
+
function requirePullRequestCommit(value: unknown): string {
|
|
991
|
+
if (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(value)) {
|
|
992
|
+
throw new Error("GitHub pull request has an invalid commit identity.");
|
|
993
|
+
}
|
|
994
|
+
return value.toLowerCase();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function parsePullRequestStatus(repository: GitHubRepository, number: number, payload: Record<string, unknown>, token: string): PullRequestStatusDetails {
|
|
998
|
+
if (pullRequestNumberField(payload.number) !== number) throw new Error("GitHub returned a different pull request number.");
|
|
999
|
+
if (!["open", "closed"].includes(String(payload.state)) || typeof payload.merged !== "boolean" || typeof payload.draft !== "boolean") {
|
|
1000
|
+
throw new Error("GitHub pull request state is malformed.");
|
|
1001
|
+
}
|
|
1002
|
+
const head = requireGitHubResponseObject(payload.head, "head");
|
|
1003
|
+
const base = requireGitHubResponseObject(payload.base, "base");
|
|
1004
|
+
requireRelatedPullRequestRepository(head.repo, repository);
|
|
1005
|
+
requireRelatedPullRequestRepository(base.repo, repository);
|
|
1006
|
+
const headRef = stringField(head.ref, "head.ref");
|
|
1007
|
+
const baseRef = stringField(base.ref, "base.ref");
|
|
1008
|
+
validatePullRequestBranchRef(headRef, "headBranch");
|
|
1009
|
+
validatePullRequestBranchRef(baseRef, "baseBranch");
|
|
1010
|
+
requireLosslessWorktreeIdentity(headRef, "branch");
|
|
1011
|
+
requireLosslessWorktreeIdentity(baseRef, "branch");
|
|
1012
|
+
if (redactSecrets(headRef, [token]) !== headRef || redactSecrets(baseRef, [token]) !== baseRef) {
|
|
1013
|
+
throw new Error("GitHub branch identities cannot be returned safely.");
|
|
1014
|
+
}
|
|
1015
|
+
const mergedAt = payload.merged_at;
|
|
1016
|
+
if (mergedAt !== null && (typeof mergedAt !== "string" || mergedAt.length > 40 || !Number.isFinite(Date.parse(mergedAt)))) {
|
|
1017
|
+
throw new Error("GitHub pull request merge timestamp is malformed.");
|
|
1018
|
+
}
|
|
1019
|
+
const mergeCommitSha = payload.merge_commit_sha === null ? null : requirePullRequestCommit(payload.merge_commit_sha);
|
|
1020
|
+
if (payload.merged && (payload.state !== "closed" || mergedAt === null || mergeCommitSha === null)) {
|
|
1021
|
+
throw new Error("GitHub merged pull request evidence is incomplete.");
|
|
1022
|
+
}
|
|
1023
|
+
if (!payload.merged && mergedAt !== null) throw new Error("GitHub merge state is contradictory.");
|
|
1024
|
+
return {
|
|
1025
|
+
repository, number, url: requireRelatedPullRequestUrl(payload.html_url, repository, number),
|
|
1026
|
+
title: boundedRelatedPullRequestValue(redactSecrets(stringField(payload.title, "title"), [token])),
|
|
1027
|
+
state: payload.state as "open" | "closed", draft: payload.draft, merged: payload.merged,
|
|
1028
|
+
mergedAt: mergedAt as string | null, head: headRef, base: baseRef,
|
|
1029
|
+
headSha: requirePullRequestCommit(head.sha), baseSha: requirePullRequestCommit(base.sha), mergeCommitSha,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function requestPullRequestJson(repository: GitHubRepository, suffix: string, token: string, options: PullRequestFetchOptions): Promise<unknown> {
|
|
1034
|
+
// Bound both transport and body consumption; the same signal covers the complete request.
|
|
1035
|
+
const controller = new AbortController();
|
|
1036
|
+
const timer = setTimeout(controller.abort.bind(controller), 10_000);
|
|
1037
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
1038
|
+
try {
|
|
1039
|
+
signal.throwIfAborted();
|
|
1040
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch),
|
|
1041
|
+
`${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/pulls${suffix}`,
|
|
1042
|
+
{ method: "GET", headers: gitHubJsonHeaders(token), signal }, "GitHub pull request lookup failed", token);
|
|
1043
|
+
if (!response.ok) throw new Error(`GitHub pull request lookup returned HTTP ${response.status}.`);
|
|
1044
|
+
const body = await readGitHubResponseBody(response, signal, "GitHub pull request lookup response failed", token);
|
|
1045
|
+
if (body.truncated) throw new Error("GitHub pull request lookup exceeded the response byte limit.");
|
|
1046
|
+
return parseGitHubJson(body.text, "GitHub pull request lookup response", token);
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
throw new Error(truncate(redactSecrets(error instanceof Error ? error.message : String(error), [token])).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " "));
|
|
1049
|
+
} finally {
|
|
1050
|
+
clearTimeout(timer);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export async function getGitHubPullRequest(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestStatusDetails> {
|
|
1055
|
+
validateGitHubRepository(repository);
|
|
1056
|
+
pullRequestNumberField(number);
|
|
1057
|
+
const payload = await requestPullRequestJson(repository, `/${number}`, token, options);
|
|
1058
|
+
return parsePullRequestStatus(repository, number, requireGitHubResponseObject(payload, "Pull request"), token);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export async function findGitHubPullRequest(repository: GitHubRepository, headBranch: string, token: string, options: PullRequestFetchOptions = {}, state: "open" | "all" = "all"): Promise<PullRequestStatusDetails | null> {
|
|
1062
|
+
validateGitHubRepository(repository);
|
|
1063
|
+
validatePullRequestBranchRef(headBranch, "headBranch");
|
|
1064
|
+
requireLosslessWorktreeIdentity(headBranch, "branch");
|
|
1065
|
+
const query = new URLSearchParams({ state, head: `${repository.owner}:${headBranch}`, sort: "updated", direction: "desc", per_page: state === "open" ? "2" : "1" });
|
|
1066
|
+
const payload = requireGitHubResponseArray(await requestPullRequestJson(repository, `?${query}`, token, options), "Pull request lookup");
|
|
1067
|
+
if (payload.length === 0) return null;
|
|
1068
|
+
if (payload.length > 1) throw new Error("Multiple open pull requests use this head; select a pull request by number.");
|
|
1069
|
+
const row = requireGitHubResponseObject(payload[0], "Pull request lookup item");
|
|
1070
|
+
const result = await getGitHubPullRequest(repository, pullRequestNumberField(row.number), token, options);
|
|
1071
|
+
if (result.head !== headBranch || (state === "open" && result.state !== "open")) {
|
|
1072
|
+
throw new Error("Pull request lookup no longer matches the requested head/state; retry after inspection.");
|
|
1073
|
+
}
|
|
1074
|
+
return result;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function existingPullRequestDetails(existing: PullRequestStatusDetails, input: PullRequestInput, expectedHead: string): PullRequestToolDetails {
|
|
1078
|
+
if (existing.base !== input.baseBranch || existing.headSha !== expectedHead.toLowerCase()) {
|
|
1079
|
+
throw new Error("Existing pull request base or head commit does not match the requested branches; inspect it before retrying.");
|
|
1080
|
+
}
|
|
1081
|
+
const { repository, number, url, state, head, base, draft } = existing;
|
|
1082
|
+
return { repository, number, url, state, head, base, draft, outcome: "existing" };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
export async function createOrReuseGitHubPullRequest(repository: GitHubRepository, input: PullRequestInput, expectedHead: string, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestToolDetails> {
|
|
1086
|
+
validatePullRequestInput(input);
|
|
1087
|
+
requirePullRequestCommit(expectedHead);
|
|
1088
|
+
const existing = await findGitHubPullRequest(repository, input.headBranch, token, options, "open");
|
|
1089
|
+
if (existing) return existingPullRequestDetails(existing, input, expectedHead);
|
|
1090
|
+
try {
|
|
1091
|
+
return { ...await createGitHubPullRequest(repository, input, token, options), outcome: "created" };
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
// A concurrent creator may win after our lookup. Recheck only a validation conflict,
|
|
1094
|
+
// never hide network/authentication/cancellation errors or retry the POST.
|
|
1095
|
+
if (!(error instanceof Error) || !error.message.startsWith("GitHub pull request request failed with HTTP 422:")) throw error;
|
|
1096
|
+
const raced = await findGitHubPullRequest(repository, input.headBranch, token, options, "open");
|
|
1097
|
+
if (raced) return existingPullRequestDetails(raced, input, expectedHead);
|
|
1098
|
+
throw error;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
987
1102
|
export async function createGitHubPullRequest(
|
|
988
1103
|
repository: GitHubRepository,
|
|
989
1104
|
input: PullRequestInput,
|