@senad-d/branchme 0.1.6 → 0.1.8
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/.env.example +6 -2
- package/CHANGELOG.md +9 -5
- package/README.md +96 -19
- package/SECURITY.md +25 -7
- package/docs/PROJECT_DEFINITION_BRIEF.md +112 -77
- package/docs/SMOKE_TEST.md +21 -11
- package/docs/STRUCTURE.md +22 -17
- package/docs/TUI_CAPTURE.md +53 -22
- package/package.json +11 -7
- package/src/commands/branchme-command.ts +10 -1
- package/src/constants.ts +15 -0
- package/src/git-context.ts +22 -2
- package/src/git.ts +1124 -4
- package/src/github.ts +76 -27
- package/src/tools/branchme-tools.ts +340 -21
- package/src/types.ts +120 -0
- package/src/ui/branchme-panel.ts +14 -3
package/src/git.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
1
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import {
|
|
3
5
|
GIT_CONTEXT_CHANGE_LIMIT,
|
|
@@ -9,7 +11,15 @@ import {
|
|
|
9
11
|
GIT_PUSH_TIMEOUT_MS,
|
|
10
12
|
GIT_REBASE_TIMEOUT_MS,
|
|
11
13
|
GIT_STATUS_TIMEOUT_MS,
|
|
14
|
+
GIT_WORKTREE_ENTRY_LIMIT,
|
|
15
|
+
GIT_WORKTREE_MUTATION_TIMEOUT_MS,
|
|
16
|
+
GIT_WORKTREE_PATH_LIMIT_CHARS,
|
|
17
|
+
GIT_WORKTREE_RAW_OUTPUT_LIMIT_BYTES,
|
|
18
|
+
GIT_WORKTREE_REASON_LIMIT_CHARS,
|
|
19
|
+
GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
|
|
12
20
|
MAX_SUMMARY_OUTPUT_CHARS,
|
|
21
|
+
PULL_REQUEST_AUTOFILL_COMMIT_LIMIT,
|
|
22
|
+
PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS,
|
|
13
23
|
} from "./constants.ts";
|
|
14
24
|
import { redactSecrets } from "./redaction.ts";
|
|
15
25
|
import type {
|
|
@@ -17,16 +27,22 @@ import type {
|
|
|
17
27
|
BranchStatusDetails,
|
|
18
28
|
ChangeBranchDetails,
|
|
19
29
|
CreateBranchDetails,
|
|
30
|
+
CreateWorktreeDetails,
|
|
31
|
+
CreateWorktreeMode,
|
|
20
32
|
CurrentBranchInfo,
|
|
21
33
|
FetchBranchDetails,
|
|
22
34
|
GitExecResult,
|
|
23
35
|
GitFileChange,
|
|
24
36
|
GitFileChangeSummary,
|
|
37
|
+
ListWorktreesDetails,
|
|
25
38
|
PullBranchDetails,
|
|
26
39
|
PushBranchDetails,
|
|
27
40
|
RebaseBranchDetails,
|
|
41
|
+
RemoveWorktreeDetails,
|
|
28
42
|
RecentCommit,
|
|
43
|
+
VerifiedLinkedWorktreeEntry,
|
|
29
44
|
WorkingTreeDetails,
|
|
45
|
+
WorktreeEntry,
|
|
30
46
|
} from "./types.ts";
|
|
31
47
|
|
|
32
48
|
export interface GitCommandContext {
|
|
@@ -37,6 +53,7 @@ export interface GitRunOptions {
|
|
|
37
53
|
signal?: AbortSignal;
|
|
38
54
|
timeout?: number;
|
|
39
55
|
allowFailure?: boolean;
|
|
56
|
+
tokens?: readonly string[];
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
const repositoryMutationQueues = new Map<string, Promise<void>>();
|
|
@@ -114,11 +131,11 @@ export async function runGit(
|
|
|
114
131
|
});
|
|
115
132
|
|
|
116
133
|
if (result.killed) {
|
|
117
|
-
throw new Error(formatGitFailure(args, result));
|
|
134
|
+
throw new Error(formatGitFailure(args, result, options.tokens));
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
if (!options.allowFailure && result.code !== 0) {
|
|
121
|
-
throw new Error(formatGitFailure(args, result));
|
|
138
|
+
throw new Error(formatGitFailure(args, result, options.tokens));
|
|
122
139
|
}
|
|
123
140
|
|
|
124
141
|
return result;
|
|
@@ -333,6 +350,13 @@ const GIT_STATUS_CODE_PATTERN = /^[ MADRCUT?!]{2}$/u;
|
|
|
333
350
|
const GIT_LOG_FIELD_SEPARATOR = "\u001f";
|
|
334
351
|
const NUL_SEPARATOR = "\u0000";
|
|
335
352
|
const GIT_LOG_FORMAT = `%x00%H%x1f%h%x1f%ad%x1f%s`;
|
|
353
|
+
const GIT_WORKTREE_REMOVAL_IGNORED_STATUS_ARGS = [
|
|
354
|
+
"status",
|
|
355
|
+
"--porcelain=v1",
|
|
356
|
+
"-z",
|
|
357
|
+
"--untracked-files=normal",
|
|
358
|
+
"--ignored=matching",
|
|
359
|
+
];
|
|
336
360
|
|
|
337
361
|
function truncateGitContextValue(value: string): string {
|
|
338
362
|
if (value.length <= GIT_CONTEXT_VALUE_LIMIT_CHARS) return value;
|
|
@@ -354,6 +378,1010 @@ function safeGitContextValue(value: string): string {
|
|
|
354
378
|
return truncateGitContextValue(escaped);
|
|
355
379
|
}
|
|
356
380
|
|
|
381
|
+
interface ParsedWorktreeRecord {
|
|
382
|
+
rawPath: string;
|
|
383
|
+
head: string | null;
|
|
384
|
+
branch: string | null;
|
|
385
|
+
detached: boolean;
|
|
386
|
+
bare: boolean;
|
|
387
|
+
locked: boolean;
|
|
388
|
+
lockReason: string | null;
|
|
389
|
+
prunable: boolean;
|
|
390
|
+
pruneReason: string | null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
interface WorktreeAttribute {
|
|
394
|
+
key: string;
|
|
395
|
+
value: string | null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
interface WorktreeRecordState extends ParsedWorktreeRecord {
|
|
399
|
+
seen: Set<string>;
|
|
400
|
+
headSeen: boolean;
|
|
401
|
+
branchSeen: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function worktreeParseError(): TypeError {
|
|
405
|
+
return new TypeError("Unable to parse worktrees: malformed git worktree output.");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function truncateWorktreeValue(value: string, limit: number): string {
|
|
409
|
+
if (value.length <= limit) return value;
|
|
410
|
+
|
|
411
|
+
let end = limit - 1;
|
|
412
|
+
const lastCodePoint = value.codePointAt(end - 1);
|
|
413
|
+
if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
|
|
414
|
+
return `${value.slice(0, end)}…`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function safeWorktreeValue(value: string, limit: number): string {
|
|
418
|
+
const redacted = redactSecrets(value);
|
|
419
|
+
const escaped = redacted.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, escapeGitContextControlCharacter);
|
|
420
|
+
return truncateWorktreeValue(escaped, limit);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function splitWorktreeAttribute(line: string): WorktreeAttribute {
|
|
424
|
+
const separator = line.indexOf(" ");
|
|
425
|
+
if (separator < 0) return { key: line, value: null };
|
|
426
|
+
return { key: line.slice(0, separator), value: line.slice(separator + 1) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function assertUniqueWorktreeAttribute(seen: Set<string>, key: string): void {
|
|
430
|
+
if (seen.has(key)) throw worktreeParseError();
|
|
431
|
+
seen.add(key);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function createWorktreeRecordState(rawPath: string): WorktreeRecordState {
|
|
435
|
+
return {
|
|
436
|
+
rawPath,
|
|
437
|
+
head: null,
|
|
438
|
+
branch: null,
|
|
439
|
+
detached: false,
|
|
440
|
+
bare: false,
|
|
441
|
+
locked: false,
|
|
442
|
+
lockReason: null,
|
|
443
|
+
prunable: false,
|
|
444
|
+
pruneReason: null,
|
|
445
|
+
seen: new Set<string>(),
|
|
446
|
+
headSeen: false,
|
|
447
|
+
branchSeen: false,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function parseWorktreeHead(state: WorktreeRecordState, attribute: WorktreeAttribute): void {
|
|
452
|
+
assertUniqueWorktreeAttribute(state.seen, attribute.key);
|
|
453
|
+
if (attribute.value === null || !/^[0-9a-f]{40,64}$/iu.test(attribute.value)) {
|
|
454
|
+
throw worktreeParseError();
|
|
455
|
+
}
|
|
456
|
+
state.head = attribute.value;
|
|
457
|
+
state.headSeen = true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function parseWorktreeBranch(state: WorktreeRecordState, attribute: WorktreeAttribute): void {
|
|
461
|
+
assertUniqueWorktreeAttribute(state.seen, attribute.key);
|
|
462
|
+
const prefix = "refs/heads/";
|
|
463
|
+
if (!attribute.value?.startsWith(prefix)) throw worktreeParseError();
|
|
464
|
+
const branchName = attribute.value.slice(prefix.length);
|
|
465
|
+
if (!branchName) throw worktreeParseError();
|
|
466
|
+
state.branch = branchName;
|
|
467
|
+
state.branchSeen = true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function parseDetachedWorktree(state: WorktreeRecordState, attribute: WorktreeAttribute): void {
|
|
471
|
+
assertUniqueWorktreeAttribute(state.seen, attribute.key);
|
|
472
|
+
if (attribute.value !== null) throw worktreeParseError();
|
|
473
|
+
state.detached = true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function parseBareWorktree(state: WorktreeRecordState, attribute: WorktreeAttribute): void {
|
|
477
|
+
assertUniqueWorktreeAttribute(state.seen, attribute.key);
|
|
478
|
+
if (attribute.value !== null) throw worktreeParseError();
|
|
479
|
+
state.bare = true;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function parseLockedWorktree(state: WorktreeRecordState, attribute: WorktreeAttribute): void {
|
|
483
|
+
assertUniqueWorktreeAttribute(state.seen, attribute.key);
|
|
484
|
+
state.locked = true;
|
|
485
|
+
state.lockReason = attribute.value
|
|
486
|
+
? safeWorktreeValue(attribute.value, GIT_WORKTREE_REASON_LIMIT_CHARS)
|
|
487
|
+
: null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function parsePrunableWorktree(state: WorktreeRecordState, attribute: WorktreeAttribute): void {
|
|
491
|
+
assertUniqueWorktreeAttribute(state.seen, attribute.key);
|
|
492
|
+
state.prunable = true;
|
|
493
|
+
state.pruneReason = attribute.value
|
|
494
|
+
? safeWorktreeValue(attribute.value, GIT_WORKTREE_REASON_LIMIT_CHARS)
|
|
495
|
+
: null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function applyWorktreeAttribute(state: WorktreeRecordState, attribute: WorktreeAttribute): void {
|
|
499
|
+
switch (attribute.key) {
|
|
500
|
+
case "HEAD":
|
|
501
|
+
parseWorktreeHead(state, attribute);
|
|
502
|
+
break;
|
|
503
|
+
case "branch":
|
|
504
|
+
parseWorktreeBranch(state, attribute);
|
|
505
|
+
break;
|
|
506
|
+
case "detached":
|
|
507
|
+
parseDetachedWorktree(state, attribute);
|
|
508
|
+
break;
|
|
509
|
+
case "bare":
|
|
510
|
+
parseBareWorktree(state, attribute);
|
|
511
|
+
break;
|
|
512
|
+
case "locked":
|
|
513
|
+
parseLockedWorktree(state, attribute);
|
|
514
|
+
break;
|
|
515
|
+
case "prunable":
|
|
516
|
+
parsePrunableWorktree(state, attribute);
|
|
517
|
+
break;
|
|
518
|
+
case "worktree":
|
|
519
|
+
throw worktreeParseError();
|
|
520
|
+
default:
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function completeWorktreeRecord(state: WorktreeRecordState): ParsedWorktreeRecord {
|
|
526
|
+
if (state.bare) {
|
|
527
|
+
if (state.branchSeen || state.detached) throw worktreeParseError();
|
|
528
|
+
} else if (!state.headSeen || state.branchSeen === state.detached) {
|
|
529
|
+
throw worktreeParseError();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
rawPath: state.rawPath,
|
|
534
|
+
head: state.head,
|
|
535
|
+
branch: state.branch,
|
|
536
|
+
detached: state.detached,
|
|
537
|
+
bare: state.bare,
|
|
538
|
+
locked: state.locked,
|
|
539
|
+
lockReason: state.lockReason,
|
|
540
|
+
prunable: state.prunable,
|
|
541
|
+
pruneReason: state.pruneReason,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function parseWorktreeRecord(lines: string[]): ParsedWorktreeRecord {
|
|
546
|
+
const worktreeLine = lines[0];
|
|
547
|
+
if (!worktreeLine?.startsWith("worktree ")) throw worktreeParseError();
|
|
548
|
+
|
|
549
|
+
const rawPath = worktreeLine.slice("worktree ".length);
|
|
550
|
+
if (!rawPath || !isAbsolute(rawPath)) throw worktreeParseError();
|
|
551
|
+
|
|
552
|
+
const state = createWorktreeRecordState(rawPath);
|
|
553
|
+
for (const line of lines.slice(1)) {
|
|
554
|
+
applyWorktreeAttribute(state, splitWorktreeAttribute(line));
|
|
555
|
+
}
|
|
556
|
+
return completeWorktreeRecord(state);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function splitWorktreeRecords(output: string): string[][] {
|
|
560
|
+
if (Buffer.byteLength(output, "utf8") > GIT_WORKTREE_RAW_OUTPUT_LIMIT_BYTES) {
|
|
561
|
+
throw new TypeError("Unable to parse worktrees: git worktree output exceeded the safety limit.");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const records: string[][] = [];
|
|
565
|
+
let current: string[] = [];
|
|
566
|
+
for (const field of output.split(NUL_SEPARATOR)) {
|
|
567
|
+
if (field.length > 0) {
|
|
568
|
+
current.push(field);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (current.length > 0) {
|
|
572
|
+
records.push(current);
|
|
573
|
+
current = [];
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (current.length > 0) records.push(current);
|
|
577
|
+
if (records.length === 0) throw worktreeParseError();
|
|
578
|
+
return records;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function parseWorktreeRecords(output: string): ParsedWorktreeRecord[] {
|
|
582
|
+
const records: ParsedWorktreeRecord[] = [];
|
|
583
|
+
for (const lines of splitWorktreeRecords(output)) records.push(parseWorktreeRecord(lines));
|
|
584
|
+
return records;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function worktreeEntry(record: ParsedWorktreeRecord, index: number, current: boolean): WorktreeEntry {
|
|
588
|
+
return {
|
|
589
|
+
path: safeWorktreeValue(record.rawPath, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
590
|
+
head: record.head,
|
|
591
|
+
branch: record.branch === null ? null : safeWorktreeValue(record.branch, GIT_CONTEXT_VALUE_LIMIT_CHARS),
|
|
592
|
+
detached: record.detached,
|
|
593
|
+
bare: record.bare,
|
|
594
|
+
locked: record.locked,
|
|
595
|
+
lockReason: record.lockReason,
|
|
596
|
+
prunable: record.prunable,
|
|
597
|
+
pruneReason: record.pruneReason,
|
|
598
|
+
main: index === 0,
|
|
599
|
+
current,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function parseWorktreePorcelain(output: string): Pick<ListWorktreesDetails, "worktrees" | "omitted"> {
|
|
604
|
+
const records = parseWorktreeRecords(output);
|
|
605
|
+
const worktrees: WorktreeEntry[] = [];
|
|
606
|
+
const returnedCount = Math.min(records.length, GIT_WORKTREE_ENTRY_LIMIT);
|
|
607
|
+
for (let index = 0; index < returnedCount; index += 1) {
|
|
608
|
+
worktrees.push(worktreeEntry(records[index], index, false));
|
|
609
|
+
}
|
|
610
|
+
return { worktrees, omitted: records.length - returnedCount };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
interface WorktreeInventoryEntry {
|
|
614
|
+
record: ParsedWorktreeRecord;
|
|
615
|
+
index: number;
|
|
616
|
+
canonicalPath: string | null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
interface WorktreeInventory {
|
|
620
|
+
repoRoot: string;
|
|
621
|
+
canonicalCurrentPath: string;
|
|
622
|
+
entries: WorktreeInventoryEntry[];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
interface ResolvedWorktreeRemovalTarget {
|
|
626
|
+
repoRoot: string;
|
|
627
|
+
canonicalPath: string;
|
|
628
|
+
entry: WorktreeInventoryEntry;
|
|
629
|
+
current: boolean;
|
|
630
|
+
inventory: WorktreeInventory;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
interface PreparedWorktreeCreation {
|
|
634
|
+
inventory: WorktreeInventory;
|
|
635
|
+
commonGitDir: string;
|
|
636
|
+
canonicalPath: string;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export interface WorktreeCreationPathValidation {
|
|
640
|
+
repoRoot: string;
|
|
641
|
+
commonGitDir: string;
|
|
642
|
+
canonicalPath: string;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export interface WorktreeRemovalPathValidation {
|
|
646
|
+
repoRoot: string;
|
|
647
|
+
canonicalPath: string;
|
|
648
|
+
worktree: WorktreeEntry;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function filesystemErrorCode(error: unknown): string | null {
|
|
652
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return null;
|
|
653
|
+
const code = (error as { code?: unknown }).code;
|
|
654
|
+
return typeof code === "string" ? code : null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function isMissingFilesystemPath(error: unknown): boolean {
|
|
658
|
+
const code = filesystemErrorCode(error);
|
|
659
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function safeWorktreePathLabel(path: string): string {
|
|
663
|
+
return JSON.stringify(safeWorktreeValue(path, GIT_WORKTREE_PATH_LIMIT_CHARS));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function safeWorktreeBranchLabel(branchName: string): string {
|
|
667
|
+
return JSON.stringify(safeWorktreeValue(branchName, GIT_CONTEXT_VALUE_LIMIT_CHARS));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function requireLosslessWorktreeIdentity(
|
|
671
|
+
value: string,
|
|
672
|
+
identity: "cwd" | "branch",
|
|
673
|
+
): string {
|
|
674
|
+
const limit = identity === "cwd" ? GIT_WORKTREE_PATH_LIMIT_CHARS : GIT_CONTEXT_VALUE_LIMIT_CHARS;
|
|
675
|
+
if (safeWorktreeValue(value, limit) === value) return value;
|
|
676
|
+
|
|
677
|
+
const label = identity === "cwd" ? "canonical worktree path" : "local branch name";
|
|
678
|
+
throw new Error(
|
|
679
|
+
`The ${label} cannot be returned safely and losslessly. Use a value of at most ${limit} characters without credential-like token text or control/format characters.`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function validateWorktreePathInput(worktreePath: unknown): string {
|
|
684
|
+
if (typeof worktreePath !== "string") throw new TypeError("worktreePath must be a string.");
|
|
685
|
+
if (worktreePath.trim().length === 0) throw new Error("worktreePath is required and cannot be blank.");
|
|
686
|
+
if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(worktreePath)) {
|
|
687
|
+
throw new Error("worktreePath cannot contain NUL, newline, or control characters.");
|
|
688
|
+
}
|
|
689
|
+
if (!isAbsolute(worktreePath)) throw new Error("worktreePath must be an absolute path.");
|
|
690
|
+
|
|
691
|
+
const normalizedPath = normalize(worktreePath);
|
|
692
|
+
if (!basename(normalizedPath)) throw new Error("worktreePath must identify a destination below a parent directory.");
|
|
693
|
+
return normalizedPath;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function canonicalizePathAllowMissing(path: string): Promise<string | null> {
|
|
697
|
+
let candidate = normalize(path);
|
|
698
|
+
const suffix: string[] = [];
|
|
699
|
+
|
|
700
|
+
for (;;) {
|
|
701
|
+
try {
|
|
702
|
+
const canonicalAncestor = await realpath(candidate);
|
|
703
|
+
return suffix.length === 0 ? canonicalAncestor : join(canonicalAncestor, ...suffix);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
if (!isMissingFilesystemPath(error)) return null;
|
|
706
|
+
const parent = dirname(candidate);
|
|
707
|
+
if (parent === candidate) return null;
|
|
708
|
+
suffix.unshift(basename(candidate));
|
|
709
|
+
candidate = parent;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function collectWorktreeInventory(
|
|
715
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
716
|
+
ctx: GitCommandContext,
|
|
717
|
+
signal?: AbortSignal,
|
|
718
|
+
): Promise<WorktreeInventory> {
|
|
719
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
720
|
+
const args = ["worktree", "list", "--porcelain", "-z"];
|
|
721
|
+
const result = await runGit(pi, { cwd: repoRoot }, args, {
|
|
722
|
+
signal,
|
|
723
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
724
|
+
});
|
|
725
|
+
const records = parseWorktreeRecords(result.stdout);
|
|
726
|
+
const canonicalCurrentPath = await canonicalizePathAllowMissing(repoRoot);
|
|
727
|
+
if (!canonicalCurrentPath) {
|
|
728
|
+
throw new Error("Unable to inspect worktrees: current repository path could not be resolved.");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const entries: WorktreeInventoryEntry[] = [];
|
|
732
|
+
for (const [index, record] of records.entries()) {
|
|
733
|
+
entries.push({ record, index, canonicalPath: await canonicalizePathAllowMissing(record.rawPath) });
|
|
734
|
+
}
|
|
735
|
+
return { repoRoot, canonicalCurrentPath, entries };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function pathIsInsideOrEqual(candidatePath: string, boundaryPath: string): boolean {
|
|
739
|
+
const relation = relative(boundaryPath, candidatePath);
|
|
740
|
+
return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function requireCanonicalCreationPath(worktreePath: string): Promise<string> {
|
|
744
|
+
const parentPath = dirname(worktreePath);
|
|
745
|
+
let parentStats;
|
|
746
|
+
try {
|
|
747
|
+
parentStats = await stat(parentPath);
|
|
748
|
+
} catch {
|
|
749
|
+
throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} must exist as a directory.`);
|
|
750
|
+
}
|
|
751
|
+
if (!parentStats.isDirectory()) {
|
|
752
|
+
throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} must be a directory.`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
let canonicalParent: string;
|
|
756
|
+
try {
|
|
757
|
+
canonicalParent = await realpath(parentPath);
|
|
758
|
+
} catch {
|
|
759
|
+
throw new Error(`worktreePath parent ${safeWorktreePathLabel(parentPath)} could not be resolved.`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const canonicalPath = join(canonicalParent, basename(worktreePath));
|
|
763
|
+
try {
|
|
764
|
+
await lstat(canonicalPath);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (isMissingFilesystemPath(error)) return canonicalPath;
|
|
767
|
+
throw new Error(`worktreePath destination ${safeWorktreePathLabel(canonicalPath)} could not be inspected.`);
|
|
768
|
+
}
|
|
769
|
+
throw new Error(`worktreePath destination ${safeWorktreePathLabel(canonicalPath)} already exists.`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function stripSingleLineTerminator(value: string): string {
|
|
773
|
+
if (value.endsWith("\r\n")) return value.slice(0, -2);
|
|
774
|
+
if (value.endsWith("\n")) return value.slice(0, -1);
|
|
775
|
+
return value;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function getCommonGitDirectory(
|
|
779
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
780
|
+
repoRoot: string,
|
|
781
|
+
signal?: AbortSignal,
|
|
782
|
+
): Promise<string> {
|
|
783
|
+
const args = ["rev-parse", "--path-format=absolute", "--git-common-dir"];
|
|
784
|
+
const result = await runGit(pi, { cwd: repoRoot }, args, {
|
|
785
|
+
signal,
|
|
786
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
787
|
+
});
|
|
788
|
+
const commonGitDir = stripSingleLineTerminator(result.stdout);
|
|
789
|
+
if (
|
|
790
|
+
!commonGitDir ||
|
|
791
|
+
!isAbsolute(commonGitDir) ||
|
|
792
|
+
/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(commonGitDir)
|
|
793
|
+
) {
|
|
794
|
+
throw new Error("Unable to validate worktree path: Git returned an invalid common directory.");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
try {
|
|
798
|
+
return await realpath(commonGitDir);
|
|
799
|
+
} catch {
|
|
800
|
+
throw new Error("Unable to validate worktree path: Git common directory could not be resolved.");
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function prepareWorktreeCreation(
|
|
805
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
806
|
+
ctx: GitCommandContext,
|
|
807
|
+
worktreePath: unknown,
|
|
808
|
+
signal?: AbortSignal,
|
|
809
|
+
): Promise<PreparedWorktreeCreation> {
|
|
810
|
+
const normalizedPath = validateWorktreePathInput(worktreePath);
|
|
811
|
+
const canonicalPath = await requireCanonicalCreationPath(normalizedPath);
|
|
812
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
813
|
+
|
|
814
|
+
for (const entry of inventory.entries) {
|
|
815
|
+
if (!entry.canonicalPath) {
|
|
816
|
+
throw new Error("Unable to validate worktree path: a registered worktree path could not be resolved.");
|
|
817
|
+
}
|
|
818
|
+
if (pathIsInsideOrEqual(canonicalPath, entry.canonicalPath)) {
|
|
819
|
+
throw new Error("worktreePath destination cannot be inside a registered worktree.");
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const commonGitDir = await getCommonGitDirectory(pi, inventory.repoRoot, signal);
|
|
824
|
+
if (pathIsInsideOrEqual(canonicalPath, commonGitDir)) {
|
|
825
|
+
throw new Error("worktreePath destination cannot be inside the repository common Git directory.");
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return { inventory, commonGitDir, canonicalPath };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export async function validateWorktreeCreationPath(
|
|
832
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
833
|
+
ctx: GitCommandContext,
|
|
834
|
+
worktreePath: unknown,
|
|
835
|
+
signal?: AbortSignal,
|
|
836
|
+
): Promise<WorktreeCreationPathValidation> {
|
|
837
|
+
const prepared = await prepareWorktreeCreation(pi, ctx, worktreePath, signal);
|
|
838
|
+
return {
|
|
839
|
+
repoRoot: prepared.inventory.repoRoot,
|
|
840
|
+
commonGitDir: prepared.commonGitDir,
|
|
841
|
+
canonicalPath: prepared.canonicalPath,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function resolveWorktreeRemovalTarget(
|
|
846
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
847
|
+
ctx: GitCommandContext,
|
|
848
|
+
worktreePath: unknown,
|
|
849
|
+
signal?: AbortSignal,
|
|
850
|
+
): Promise<ResolvedWorktreeRemovalTarget> {
|
|
851
|
+
const normalizedPath = validateWorktreePathInput(worktreePath);
|
|
852
|
+
const canonicalPath = await canonicalizePathAllowMissing(normalizedPath);
|
|
853
|
+
if (!canonicalPath) throw new Error("worktreePath could not be resolved for removal.");
|
|
854
|
+
|
|
855
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
856
|
+
const matches: WorktreeInventoryEntry[] = [];
|
|
857
|
+
for (const entry of inventory.entries) {
|
|
858
|
+
if (entry.canonicalPath === canonicalPath) matches.push(entry);
|
|
859
|
+
}
|
|
860
|
+
if (matches.length !== 1) {
|
|
861
|
+
throw new Error("worktreePath must exactly match a registered worktree in the current repository.");
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const entry = matches[0];
|
|
865
|
+
return {
|
|
866
|
+
repoRoot: inventory.repoRoot,
|
|
867
|
+
canonicalPath,
|
|
868
|
+
entry,
|
|
869
|
+
current: canonicalPath === inventory.canonicalCurrentPath,
|
|
870
|
+
inventory,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export async function validateWorktreeRemovalPath(
|
|
875
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
876
|
+
ctx: GitCommandContext,
|
|
877
|
+
worktreePath: unknown,
|
|
878
|
+
signal?: AbortSignal,
|
|
879
|
+
): Promise<WorktreeRemovalPathValidation> {
|
|
880
|
+
const resolved = await resolveWorktreeRemovalTarget(pi, ctx, worktreePath, signal);
|
|
881
|
+
return {
|
|
882
|
+
repoRoot: resolved.repoRoot,
|
|
883
|
+
canonicalPath: resolved.canonicalPath,
|
|
884
|
+
worktree: worktreeEntry(resolved.entry.record, resolved.entry.index, resolved.current),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
export async function listWorktrees(
|
|
889
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
890
|
+
ctx: GitCommandContext,
|
|
891
|
+
signal?: AbortSignal,
|
|
892
|
+
): Promise<ListWorktreesDetails> {
|
|
893
|
+
const inventory = await collectWorktreeInventory(pi, ctx, signal);
|
|
894
|
+
const worktrees: WorktreeEntry[] = [];
|
|
895
|
+
const returnedCount = Math.min(inventory.entries.length, GIT_WORKTREE_ENTRY_LIMIT);
|
|
896
|
+
for (let index = 0; index < returnedCount; index += 1) {
|
|
897
|
+
const entry = inventory.entries[index];
|
|
898
|
+
worktrees.push(
|
|
899
|
+
worktreeEntry(entry.record, entry.index, entry.canonicalPath === inventory.canonicalCurrentPath),
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return {
|
|
904
|
+
action: "list_worktrees",
|
|
905
|
+
repoRoot: safeWorktreeValue(inventory.repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
906
|
+
worktrees,
|
|
907
|
+
omitted: inventory.entries.length - returnedCount,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function validateCreateWorktreeMode(branchMode: unknown): asserts branchMode is CreateWorktreeMode {
|
|
912
|
+
if (branchMode !== "new" && branchMode !== "existing") {
|
|
913
|
+
throw new Error("branchMode must be either 'new' or 'existing'.");
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function getCurrentHeadCommit(
|
|
918
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
919
|
+
ctx: GitCommandContext,
|
|
920
|
+
signal?: AbortSignal,
|
|
921
|
+
): Promise<string> {
|
|
922
|
+
const args = ["rev-parse", "--verify", "HEAD^{commit}"];
|
|
923
|
+
const result = await runGit(pi, ctx, args, {
|
|
924
|
+
signal,
|
|
925
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
926
|
+
allowFailure: true,
|
|
927
|
+
});
|
|
928
|
+
const head = trimOutput(result.stdout);
|
|
929
|
+
if (result.code !== 0 || !/^[0-9a-f]{40,64}$/iu.test(head)) {
|
|
930
|
+
throw new Error("Current HEAD must resolve to a valid commit before creating a worktree.");
|
|
931
|
+
}
|
|
932
|
+
return head;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function requireSourceWorktreeEntry(
|
|
936
|
+
inventory: WorktreeInventory,
|
|
937
|
+
source: CurrentBranchInfo,
|
|
938
|
+
sourceHead: string,
|
|
939
|
+
): WorktreeInventoryEntry {
|
|
940
|
+
const matches = inventory.entries.filter(
|
|
941
|
+
(entry) => entry.canonicalPath === inventory.canonicalCurrentPath,
|
|
942
|
+
);
|
|
943
|
+
if (matches.length !== 1) {
|
|
944
|
+
throw new Error("Unable to verify the source worktree in the current repository inventory.");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const sourceEntry = matches[0];
|
|
948
|
+
const record = sourceEntry.record;
|
|
949
|
+
const branchMatches = source.detached
|
|
950
|
+
? record.detached && record.branch === null
|
|
951
|
+
: !record.detached && record.branch === source.currentBranch;
|
|
952
|
+
if (
|
|
953
|
+
record.bare ||
|
|
954
|
+
record.head?.toLowerCase() !== sourceHead.toLowerCase() ||
|
|
955
|
+
!branchMatches
|
|
956
|
+
) {
|
|
957
|
+
throw new Error("Unable to verify the source branch and HEAD against the worktree inventory.");
|
|
958
|
+
}
|
|
959
|
+
return sourceEntry;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function branchIsCheckedOut(inventory: WorktreeInventory, branchName: string): boolean {
|
|
963
|
+
return inventory.entries.some((entry) => entry.record.branch === branchName);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function requireCreatedWorktreeEntry(
|
|
967
|
+
inventory: WorktreeInventory,
|
|
968
|
+
canonicalPath: string,
|
|
969
|
+
branchName: string,
|
|
970
|
+
expectedHead: string,
|
|
971
|
+
): VerifiedLinkedWorktreeEntry {
|
|
972
|
+
const matches = inventory.entries.filter((entry) => entry.canonicalPath === canonicalPath);
|
|
973
|
+
if (matches.length !== 1) {
|
|
974
|
+
throw new Error("The created destination was not registered exactly once in the worktree inventory.");
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const matched = matches[0];
|
|
978
|
+
const record = matched.record;
|
|
979
|
+
const current = canonicalPath === inventory.canonicalCurrentPath;
|
|
980
|
+
if (
|
|
981
|
+
matched.index === 0 ||
|
|
982
|
+
current ||
|
|
983
|
+
record.head?.toLowerCase() !== expectedHead.toLowerCase() ||
|
|
984
|
+
record.branch !== branchName ||
|
|
985
|
+
record.detached ||
|
|
986
|
+
record.bare ||
|
|
987
|
+
record.locked ||
|
|
988
|
+
record.prunable
|
|
989
|
+
) {
|
|
990
|
+
throw new Error("The created worktree did not match the requested path, local branch, and HEAD.");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
path: canonicalPath,
|
|
995
|
+
head: record.head,
|
|
996
|
+
branch: branchName,
|
|
997
|
+
detached: false,
|
|
998
|
+
bare: false,
|
|
999
|
+
locked: false,
|
|
1000
|
+
lockReason: null,
|
|
1001
|
+
prunable: false,
|
|
1002
|
+
pruneReason: null,
|
|
1003
|
+
main: false,
|
|
1004
|
+
current: false,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function inspectWorktreeState(
|
|
1009
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1010
|
+
canonicalPath: string,
|
|
1011
|
+
signal?: AbortSignal,
|
|
1012
|
+
): Promise<WorkingTreeDetails> {
|
|
1013
|
+
const args = ["status", "--porcelain=v1", "-z", "--untracked-files=normal"];
|
|
1014
|
+
const result = await runGit(pi, { cwd: canonicalPath }, args, {
|
|
1015
|
+
signal,
|
|
1016
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1017
|
+
});
|
|
1018
|
+
return parseWorkingTreeStatus(result.stdout).workingTree;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function inspectCreatedWorktree(
|
|
1022
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1023
|
+
canonicalPath: string,
|
|
1024
|
+
signal?: AbortSignal,
|
|
1025
|
+
): Promise<WorkingTreeDetails> {
|
|
1026
|
+
const workingTree = await inspectWorktreeState(pi, canonicalPath, signal);
|
|
1027
|
+
if (workingTree.state !== "clean") {
|
|
1028
|
+
throw new Error("The created worktree was not clean after checkout.");
|
|
1029
|
+
}
|
|
1030
|
+
return workingTree;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function worktreeCreationFailure(
|
|
1034
|
+
error: unknown,
|
|
1035
|
+
canonicalPath: string,
|
|
1036
|
+
branchName: string,
|
|
1037
|
+
): Error {
|
|
1038
|
+
const rawReason = error instanceof Error ? error.message : String(error);
|
|
1039
|
+
const reason = safeOutput(rawReason, [canonicalPath, branchName]);
|
|
1040
|
+
const reasonText = reason ? ` Reason: ${reason}` : "";
|
|
1041
|
+
return new Error(
|
|
1042
|
+
`Worktree creation at ${safeWorktreePathLabel(canonicalPath)} did not complete with verified postconditions.${reasonText} ` +
|
|
1043
|
+
"The repository and destination should be inspected; no automatic cleanup was attempted.",
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export async function createWorktree(
|
|
1048
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1049
|
+
ctx: GitCommandContext,
|
|
1050
|
+
worktreePath: unknown,
|
|
1051
|
+
branchName: string,
|
|
1052
|
+
branchMode: CreateWorktreeMode,
|
|
1053
|
+
signal?: AbortSignal,
|
|
1054
|
+
): Promise<CreateWorktreeDetails> {
|
|
1055
|
+
validateCreateWorktreeMode(branchMode);
|
|
1056
|
+
const requestedWorktreePath = validateWorktreePathInput(worktreePath);
|
|
1057
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
1058
|
+
const rootCtx = { cwd: repoRoot };
|
|
1059
|
+
|
|
1060
|
+
return withRepositoryMutationQueue(repoRoot, async () => {
|
|
1061
|
+
const source = await getCurrentBranch(pi, rootCtx, signal);
|
|
1062
|
+
const sourceHead = await getCurrentHeadCommit(pi, rootCtx, signal);
|
|
1063
|
+
const prepared = await prepareWorktreeCreation(pi, rootCtx, requestedWorktreePath, signal);
|
|
1064
|
+
if (prepared.inventory.repoRoot !== repoRoot) {
|
|
1065
|
+
throw new Error("The current repository changed while preparing worktree creation.");
|
|
1066
|
+
}
|
|
1067
|
+
const handoffCwd = requireLosslessWorktreeIdentity(prepared.canonicalPath, "cwd");
|
|
1068
|
+
validateBranchNameInput(branchName);
|
|
1069
|
+
const handoffBranch = requireLosslessWorktreeIdentity(branchName, "branch");
|
|
1070
|
+
requireSourceWorktreeEntry(prepared.inventory, source, sourceHead);
|
|
1071
|
+
|
|
1072
|
+
await validateBranchName(pi, rootCtx, branchName, signal);
|
|
1073
|
+
const branchExisted = await localBranchExists(pi, rootCtx, branchName, signal);
|
|
1074
|
+
if (branchMode === "new" && branchExisted) {
|
|
1075
|
+
throw new Error(`Local branch ${safeWorktreeBranchLabel(branchName)} already exists.`);
|
|
1076
|
+
}
|
|
1077
|
+
if (branchMode === "existing" && !branchExisted) {
|
|
1078
|
+
throw new Error(`Local branch ${safeWorktreeBranchLabel(branchName)} does not exist.`);
|
|
1079
|
+
}
|
|
1080
|
+
if (branchMode === "existing" && branchIsCheckedOut(prepared.inventory, branchName)) {
|
|
1081
|
+
throw new Error(`Local branch ${safeWorktreeBranchLabel(branchName)} is already checked out in a worktree.`);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const expectedHead = branchMode === "new"
|
|
1085
|
+
? sourceHead
|
|
1086
|
+
: await getLocalBranchCommit(pi, rootCtx, branchName, signal);
|
|
1087
|
+
const args = branchMode === "new"
|
|
1088
|
+
? ["worktree", "add", "-b", branchName, prepared.canonicalPath, "HEAD"]
|
|
1089
|
+
: ["worktree", "add", prepared.canonicalPath, branchName];
|
|
1090
|
+
|
|
1091
|
+
try {
|
|
1092
|
+
await runGit(pi, rootCtx, args, {
|
|
1093
|
+
signal,
|
|
1094
|
+
timeout: GIT_WORKTREE_MUTATION_TIMEOUT_MS,
|
|
1095
|
+
});
|
|
1096
|
+
const afterInventory = await collectWorktreeInventory(pi, rootCtx, signal);
|
|
1097
|
+
if (
|
|
1098
|
+
afterInventory.repoRoot !== repoRoot ||
|
|
1099
|
+
afterInventory.canonicalCurrentPath !== prepared.inventory.canonicalCurrentPath
|
|
1100
|
+
) {
|
|
1101
|
+
throw new Error("The source repository changed while verifying worktree creation.");
|
|
1102
|
+
}
|
|
1103
|
+
requireSourceWorktreeEntry(afterInventory, source, sourceHead);
|
|
1104
|
+
const worktree = requireCreatedWorktreeEntry(
|
|
1105
|
+
afterInventory,
|
|
1106
|
+
prepared.canonicalPath,
|
|
1107
|
+
branchName,
|
|
1108
|
+
expectedHead,
|
|
1109
|
+
);
|
|
1110
|
+
const workingTree = await inspectCreatedWorktree(pi, prepared.canonicalPath, signal);
|
|
1111
|
+
const displayCanonicalPath = safeWorktreeValue(handoffCwd, GIT_WORKTREE_PATH_LIMIT_CHARS);
|
|
1112
|
+
const displayBranchName = safeWorktreeValue(handoffBranch, GIT_CONTEXT_VALUE_LIMIT_CHARS);
|
|
1113
|
+
const summary = safeWorktreeValue(
|
|
1114
|
+
`Worktree ready at ${displayCanonicalPath} on branch ${displayBranchName} at ${worktree.head}.`,
|
|
1115
|
+
GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
action: "create_worktree",
|
|
1120
|
+
repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1121
|
+
request: {
|
|
1122
|
+
worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1123
|
+
branchName: handoffBranch,
|
|
1124
|
+
branchMode,
|
|
1125
|
+
},
|
|
1126
|
+
verified: {
|
|
1127
|
+
before: {
|
|
1128
|
+
sourcePath: safeWorktreeValue(
|
|
1129
|
+
prepared.inventory.canonicalCurrentPath,
|
|
1130
|
+
GIT_WORKTREE_PATH_LIMIT_CHARS,
|
|
1131
|
+
),
|
|
1132
|
+
sourceBranch: source.currentBranch === null
|
|
1133
|
+
? null
|
|
1134
|
+
: safeWorktreeValue(source.currentBranch, GIT_CONTEXT_VALUE_LIMIT_CHARS),
|
|
1135
|
+
sourceDetached: source.detached,
|
|
1136
|
+
sourceHead,
|
|
1137
|
+
canonicalWorktreePath: handoffCwd,
|
|
1138
|
+
branchExisted,
|
|
1139
|
+
destinationRegistered: false,
|
|
1140
|
+
},
|
|
1141
|
+
after: {
|
|
1142
|
+
worktreePresent: true,
|
|
1143
|
+
worktree,
|
|
1144
|
+
workingTree,
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
handoff: {
|
|
1148
|
+
cwd: handoffCwd,
|
|
1149
|
+
branch: handoffBranch,
|
|
1150
|
+
head: worktree.head,
|
|
1151
|
+
ready: true,
|
|
1152
|
+
summary,
|
|
1153
|
+
},
|
|
1154
|
+
};
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
throw worktreeCreationFailure(error, prepared.canonicalPath, branchName);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function requireRemovableWorktreeEntry(
|
|
1162
|
+
resolved: ResolvedWorktreeRemovalTarget,
|
|
1163
|
+
): VerifiedLinkedWorktreeEntry {
|
|
1164
|
+
const record = resolved.entry.record;
|
|
1165
|
+
if (resolved.entry.index === 0) throw new Error("The main worktree cannot be removed.");
|
|
1166
|
+
if (resolved.current) throw new Error("The current worktree cannot be removed.");
|
|
1167
|
+
if (record.bare) throw new Error("A bare worktree entry cannot be removed.");
|
|
1168
|
+
if (record.locked) throw new Error("A locked worktree cannot be removed.");
|
|
1169
|
+
if (record.prunable) throw new Error("A prunable or missing worktree cannot be removed.");
|
|
1170
|
+
if (record.detached || record.branch === null) {
|
|
1171
|
+
throw new Error("A detached worktree cannot be removed by this operation.");
|
|
1172
|
+
}
|
|
1173
|
+
if (record.head === null) throw new Error("The target worktree HEAD could not be verified.");
|
|
1174
|
+
|
|
1175
|
+
return {
|
|
1176
|
+
path: resolved.canonicalPath,
|
|
1177
|
+
head: record.head,
|
|
1178
|
+
branch: record.branch,
|
|
1179
|
+
detached: false,
|
|
1180
|
+
bare: false,
|
|
1181
|
+
locked: false,
|
|
1182
|
+
lockReason: null,
|
|
1183
|
+
prunable: false,
|
|
1184
|
+
pruneReason: null,
|
|
1185
|
+
main: false,
|
|
1186
|
+
current: false,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function requirePresentWorktreeDirectory(canonicalPath: string): Promise<void> {
|
|
1191
|
+
let targetStats;
|
|
1192
|
+
try {
|
|
1193
|
+
targetStats = await stat(canonicalPath);
|
|
1194
|
+
} catch {
|
|
1195
|
+
throw new Error(`Worktree ${safeWorktreePathLabel(canonicalPath)} is missing and cannot be removed.`);
|
|
1196
|
+
}
|
|
1197
|
+
if (!targetStats.isDirectory()) {
|
|
1198
|
+
throw new Error(`Worktree ${safeWorktreePathLabel(canonicalPath)} is not a directory.`);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
let resolvedPath: string;
|
|
1202
|
+
try {
|
|
1203
|
+
resolvedPath = await realpath(canonicalPath);
|
|
1204
|
+
} catch {
|
|
1205
|
+
throw new Error(`Worktree ${safeWorktreePathLabel(canonicalPath)} could not be resolved.`);
|
|
1206
|
+
}
|
|
1207
|
+
if (resolvedPath !== canonicalPath) {
|
|
1208
|
+
throw new Error("The target worktree path changed while preparing removal.");
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
interface WorktreeRemovalStatus {
|
|
1213
|
+
workingTree: WorkingTreeDetails;
|
|
1214
|
+
hasIgnoredEntries: boolean;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function parseWorktreeRemovalStatus(output: string): WorktreeRemovalStatus {
|
|
1218
|
+
if (Buffer.byteLength(output, "utf8") > GIT_WORKTREE_RAW_OUTPUT_LIMIT_BYTES) {
|
|
1219
|
+
throw new Error("Unable to inspect ignored worktree entries: git status output exceeded the safety limit.");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const records = output.split(NUL_SEPARATOR);
|
|
1223
|
+
let hasIgnoredEntries = false;
|
|
1224
|
+
let skipNextRecord = false;
|
|
1225
|
+
for (const [index, record] of records.entries()) {
|
|
1226
|
+
if (skipNextRecord) {
|
|
1227
|
+
skipNextRecord = false;
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
if (record.length === 0) continue;
|
|
1231
|
+
|
|
1232
|
+
const parsed = parsePorcelainChange(records, index);
|
|
1233
|
+
skipNextRecord = parsed.nextIndex > index;
|
|
1234
|
+
if (parsed.status === "!!") hasIgnoredEntries = true;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return {
|
|
1238
|
+
workingTree: parseWorkingTreeStatus(output).workingTree,
|
|
1239
|
+
hasIgnoredEntries,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async function inspectRemovableWorktree(
|
|
1244
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1245
|
+
canonicalPath: string,
|
|
1246
|
+
signal?: AbortSignal,
|
|
1247
|
+
): Promise<WorkingTreeDetails> {
|
|
1248
|
+
const workingTree = await inspectWorktreeState(pi, canonicalPath, signal);
|
|
1249
|
+
if (workingTree.state !== "clean") {
|
|
1250
|
+
throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const ignoredResult = await runGit(pi, { cwd: canonicalPath }, GIT_WORKTREE_REMOVAL_IGNORED_STATUS_ARGS, {
|
|
1254
|
+
signal,
|
|
1255
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1256
|
+
});
|
|
1257
|
+
const removalStatus = parseWorktreeRemovalStatus(ignoredResult.stdout);
|
|
1258
|
+
if (removalStatus.workingTree.state !== "clean") {
|
|
1259
|
+
throw new Error("The target worktree has staged, unstaged, untracked, or unmerged changes; clean it before removal.");
|
|
1260
|
+
}
|
|
1261
|
+
if (removalStatus.hasIgnoredEntries) {
|
|
1262
|
+
throw new Error(
|
|
1263
|
+
"The target worktree contains ignored files or directories; remove or preserve them outside the checkout before removal.",
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
return workingTree;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function assertWorktreeRemoved(
|
|
1270
|
+
inventory: WorktreeInventory,
|
|
1271
|
+
canonicalPath: string,
|
|
1272
|
+
): void {
|
|
1273
|
+
if (inventory.entries.some((entry) => entry.canonicalPath === canonicalPath)) {
|
|
1274
|
+
throw new Error("The target remained registered after git worktree remove.");
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function worktreeRemovalFailure(
|
|
1279
|
+
error: unknown,
|
|
1280
|
+
canonicalPath: string,
|
|
1281
|
+
branchName: string,
|
|
1282
|
+
): Error {
|
|
1283
|
+
const rawReason = error instanceof Error ? error.message : String(error);
|
|
1284
|
+
const reason = safeOutput(rawReason, [canonicalPath, branchName]);
|
|
1285
|
+
const reasonText = reason ? ` Reason: ${reason}` : "";
|
|
1286
|
+
return new Error(
|
|
1287
|
+
`Worktree removal at ${safeWorktreePathLabel(canonicalPath)} did not complete with verified postconditions.${reasonText} ` +
|
|
1288
|
+
"The repository and target should be inspected; no force or branch deletion was attempted.",
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
export async function removeWorktree(
|
|
1293
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1294
|
+
ctx: GitCommandContext,
|
|
1295
|
+
worktreePath: unknown,
|
|
1296
|
+
signal?: AbortSignal,
|
|
1297
|
+
): Promise<RemoveWorktreeDetails> {
|
|
1298
|
+
const requestedWorktreePath = validateWorktreePathInput(worktreePath);
|
|
1299
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
1300
|
+
const rootCtx = { cwd: repoRoot };
|
|
1301
|
+
|
|
1302
|
+
return withRepositoryMutationQueue(repoRoot, async () => {
|
|
1303
|
+
const resolved = await resolveWorktreeRemovalTarget(
|
|
1304
|
+
pi,
|
|
1305
|
+
rootCtx,
|
|
1306
|
+
requestedWorktreePath,
|
|
1307
|
+
signal,
|
|
1308
|
+
);
|
|
1309
|
+
if (resolved.repoRoot !== repoRoot) {
|
|
1310
|
+
throw new Error("The current repository changed while preparing worktree removal.");
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const worktree = requireRemovableWorktreeEntry(resolved);
|
|
1314
|
+
const branchName = resolved.entry.record.branch;
|
|
1315
|
+
const head = resolved.entry.record.head;
|
|
1316
|
+
if (branchName === null || head === null) {
|
|
1317
|
+
throw new Error("The target branch and HEAD could not be captured before removal.");
|
|
1318
|
+
}
|
|
1319
|
+
const verifiedCanonicalPath = requireLosslessWorktreeIdentity(resolved.canonicalPath, "cwd");
|
|
1320
|
+
const retainedBranch = requireLosslessWorktreeIdentity(branchName, "branch");
|
|
1321
|
+
await requirePresentWorktreeDirectory(verifiedCanonicalPath);
|
|
1322
|
+
const workingTree = await inspectRemovableWorktree(pi, verifiedCanonicalPath, signal);
|
|
1323
|
+
const branchHeadBefore = await getLocalBranchCommit(pi, rootCtx, retainedBranch, signal);
|
|
1324
|
+
if (branchHeadBefore.toLowerCase() !== head.toLowerCase()) {
|
|
1325
|
+
throw new Error("The target local branch did not match the worktree HEAD before removal.");
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const args = ["worktree", "remove", verifiedCanonicalPath];
|
|
1329
|
+
try {
|
|
1330
|
+
await runGit(pi, rootCtx, args, {
|
|
1331
|
+
signal,
|
|
1332
|
+
timeout: GIT_WORKTREE_MUTATION_TIMEOUT_MS,
|
|
1333
|
+
});
|
|
1334
|
+
const afterInventory = await collectWorktreeInventory(pi, rootCtx, signal);
|
|
1335
|
+
if (
|
|
1336
|
+
afterInventory.repoRoot !== repoRoot ||
|
|
1337
|
+
afterInventory.canonicalCurrentPath !== resolved.inventory.canonicalCurrentPath
|
|
1338
|
+
) {
|
|
1339
|
+
throw new Error("The source repository changed while verifying worktree removal.");
|
|
1340
|
+
}
|
|
1341
|
+
assertWorktreeRemoved(afterInventory, verifiedCanonicalPath);
|
|
1342
|
+
const branchHeadAfter = await getLocalBranchCommit(pi, rootCtx, retainedBranch, signal);
|
|
1343
|
+
if (branchHeadAfter.toLowerCase() !== head.toLowerCase()) {
|
|
1344
|
+
throw new Error("The retained local branch moved after worktree removal.");
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const displayCanonicalPath = safeWorktreeValue(verifiedCanonicalPath, GIT_WORKTREE_PATH_LIMIT_CHARS);
|
|
1348
|
+
const displayBranchName = safeWorktreeValue(retainedBranch, GIT_CONTEXT_VALUE_LIMIT_CHARS);
|
|
1349
|
+
const summary = safeWorktreeValue(
|
|
1350
|
+
`Worktree directory ${displayCanonicalPath} was removed; local branch ${displayBranchName} was retained at ${head}.`,
|
|
1351
|
+
GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
|
|
1352
|
+
);
|
|
1353
|
+
return {
|
|
1354
|
+
action: "remove_worktree",
|
|
1355
|
+
repoRoot: safeWorktreeValue(repoRoot, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1356
|
+
request: {
|
|
1357
|
+
worktreePath: safeWorktreeValue(requestedWorktreePath, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
1358
|
+
},
|
|
1359
|
+
verified: {
|
|
1360
|
+
before: {
|
|
1361
|
+
worktree,
|
|
1362
|
+
workingTree,
|
|
1363
|
+
},
|
|
1364
|
+
after: {
|
|
1365
|
+
worktreePresent: false,
|
|
1366
|
+
branchRetained: true,
|
|
1367
|
+
branch: retainedBranch,
|
|
1368
|
+
head,
|
|
1369
|
+
},
|
|
1370
|
+
},
|
|
1371
|
+
handoff: {
|
|
1372
|
+
cwd: null,
|
|
1373
|
+
branch: retainedBranch,
|
|
1374
|
+
head,
|
|
1375
|
+
ready: false,
|
|
1376
|
+
summary,
|
|
1377
|
+
},
|
|
1378
|
+
};
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
throw worktreeRemovalFailure(error, resolved.canonicalPath, branchName);
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
|
|
357
1385
|
function isRenameOrCopyStatus(status: string): boolean {
|
|
358
1386
|
return status.startsWith("R") || status.startsWith("C") || status.endsWith("R") || status.endsWith("C");
|
|
359
1387
|
}
|
|
@@ -568,6 +1596,98 @@ export async function getRecentCommits(
|
|
|
568
1596
|
throw new Error(formatGitFailure(args, result));
|
|
569
1597
|
}
|
|
570
1598
|
|
|
1599
|
+
function truncatePullRequestCommitSubject(value: string): string {
|
|
1600
|
+
if (value.length <= PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS) return value;
|
|
1601
|
+
|
|
1602
|
+
let end = PULL_REQUEST_AUTOFILL_SUBJECT_LIMIT_CHARS - 1;
|
|
1603
|
+
const lastCodePoint = value.codePointAt(end - 1);
|
|
1604
|
+
if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
|
|
1605
|
+
return `${value.slice(0, end).trimEnd()}…`;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function safePullRequestCommitSubject(value: string, tokens: readonly string[]): string {
|
|
1609
|
+
const normalized = redactSecrets(value, tokens)
|
|
1610
|
+
.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
|
|
1611
|
+
.replace(/\s+/gu, " ")
|
|
1612
|
+
.trim();
|
|
1613
|
+
return truncatePullRequestCommitSubject(normalized);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
async function getOriginDefaultBranch(
|
|
1617
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1618
|
+
ctx: GitCommandContext,
|
|
1619
|
+
signal?: AbortSignal,
|
|
1620
|
+
): Promise<string | null> {
|
|
1621
|
+
const args = ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"];
|
|
1622
|
+
const result = await runGit(pi, ctx, args, {
|
|
1623
|
+
signal,
|
|
1624
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1625
|
+
allowFailure: true,
|
|
1626
|
+
});
|
|
1627
|
+
if (result.code !== 0) return null;
|
|
1628
|
+
|
|
1629
|
+
const remoteBranch = trimOutput(result.stdout);
|
|
1630
|
+
const prefix = "origin/";
|
|
1631
|
+
if (!remoteBranch.startsWith(prefix)) return null;
|
|
1632
|
+
|
|
1633
|
+
const branch = remoteBranch.slice(prefix.length);
|
|
1634
|
+
try {
|
|
1635
|
+
validateBranchNameInput(branch, "Default base branch");
|
|
1636
|
+
} catch {
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
return branch;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
export async function inferPullRequestBaseBranch(
|
|
1643
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1644
|
+
ctx: GitCommandContext,
|
|
1645
|
+
headBranch: string,
|
|
1646
|
+
signal?: AbortSignal,
|
|
1647
|
+
): Promise<string> {
|
|
1648
|
+
validateBranchNameInput(headBranch, "headBranch");
|
|
1649
|
+
const originDefault = await getOriginDefaultBranch(pi, ctx, signal);
|
|
1650
|
+
if (originDefault) {
|
|
1651
|
+
if (originDefault === headBranch) {
|
|
1652
|
+
throw new Error("Unable to infer baseBranch because the current branch is the origin default branch.");
|
|
1653
|
+
}
|
|
1654
|
+
if (await localBranchExists(pi, ctx, originDefault, signal)) return originDefault;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
for (const candidate of ["main", "master", "trunk", "develop"]) {
|
|
1658
|
+
if (candidate !== headBranch && await localBranchExists(pi, ctx, candidate, signal)) return candidate;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
throw new Error(
|
|
1662
|
+
"Unable to infer baseBranch from origin/HEAD or a local main, master, trunk, or develop branch; provide baseBranch explicitly.",
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export async function getPullRequestCommitSubjects(
|
|
1667
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1668
|
+
ctx: GitCommandContext,
|
|
1669
|
+
headBranch: string,
|
|
1670
|
+
baseBranch: string,
|
|
1671
|
+
signal?: AbortSignal,
|
|
1672
|
+
tokens: readonly string[] = [],
|
|
1673
|
+
): Promise<string[]> {
|
|
1674
|
+
validateBranchNameInput(headBranch, "headBranch");
|
|
1675
|
+
validateBranchNameInput(baseBranch, "baseBranch");
|
|
1676
|
+
const revisionRange = `refs/heads/${baseBranch}..refs/heads/${headBranch}`;
|
|
1677
|
+
const args = [
|
|
1678
|
+
"log",
|
|
1679
|
+
`--max-count=${PULL_REQUEST_AUTOFILL_COMMIT_LIMIT}`,
|
|
1680
|
+
"--format=%s",
|
|
1681
|
+
revisionRange,
|
|
1682
|
+
"--",
|
|
1683
|
+
];
|
|
1684
|
+
const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, tokens });
|
|
1685
|
+
return result.stdout
|
|
1686
|
+
.split(/\r?\n/u)
|
|
1687
|
+
.map((subject) => safePullRequestCommitSubject(subject, tokens))
|
|
1688
|
+
.filter(Boolean);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
571
1691
|
export async function getAheadBehindCount(
|
|
572
1692
|
pi: Pick<ExtensionAPI, "exec">,
|
|
573
1693
|
ctx: GitCommandContext,
|
|
@@ -608,7 +1728,7 @@ export async function validateBranchName(
|
|
|
608
1728
|
const args = ["check-ref-format", "--branch", branchName];
|
|
609
1729
|
const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, allowFailure: true });
|
|
610
1730
|
if (result.code !== 0) {
|
|
611
|
-
throw new Error(`Invalid branch name '${
|
|
1731
|
+
throw new Error(`Invalid branch name '${safeGitContextValue(branchName)}': ${safeOutput(result.stderr || result.stdout) || "git rejected the ref"}`);
|
|
612
1732
|
}
|
|
613
1733
|
}
|
|
614
1734
|
|
|
@@ -639,7 +1759,7 @@ export async function getLocalBranchCommit(
|
|
|
639
1759
|
});
|
|
640
1760
|
const commit = trimOutput(result.stdout);
|
|
641
1761
|
if (!/^[0-9a-f]{40,64}$/iu.test(commit)) {
|
|
642
|
-
throw new Error(`Unable to resolve local branch '${
|
|
1762
|
+
throw new Error(`Unable to resolve local branch '${safeGitContextValue(branchName)}' to a commit: ${safeOutput(result.stdout) || "empty output"}`);
|
|
643
1763
|
}
|
|
644
1764
|
return commit;
|
|
645
1765
|
}
|