@senad-d/branchme 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -5
- package/README.md +87 -14
- package/SECURITY.md +38 -4
- package/docs/PROJECT_DEFINITION_BRIEF.md +51 -17
- package/docs/SMOKE_TEST.md +10 -7
- package/docs/STRUCTURE.md +30 -18
- package/docs/TUI_CAPTURE.md +91 -32
- package/package.json +2 -2
- package/src/commands/branchme-command.ts +16 -2
- package/src/constants.ts +11 -0
- package/src/git-context.ts +81 -16
- package/src/git-integration.ts +673 -0
- package/src/git-retirement.ts +681 -0
- package/src/git.ts +313 -7
- package/src/tools/branchme-tools.ts +159 -4
- package/src/types.ts +191 -0
- package/src/ui/branchme-panel.ts +19 -3
package/src/git.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
import { redactSecrets } from "./redaction.ts";
|
|
25
25
|
import type {
|
|
26
26
|
AheadBehindCount,
|
|
27
|
+
BranchAncestryDetails,
|
|
28
|
+
BranchStatusAncestryQuery,
|
|
27
29
|
BranchStatusDetails,
|
|
28
30
|
ChangeBranchDetails,
|
|
29
31
|
CreateBranchDetails,
|
|
@@ -157,6 +159,23 @@ export async function getGitRoot(
|
|
|
157
159
|
return root;
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
export async function getCanonicalGitWorktreeRoot(
|
|
163
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
164
|
+
ctx: GitCommandContext,
|
|
165
|
+
signal?: AbortSignal,
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
168
|
+
if (!isAbsolute(repoRoot) || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(repoRoot)) {
|
|
169
|
+
throw new Error("Unable to verify the active worktree root: Git returned an invalid path.");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
return await realpath(repoRoot);
|
|
174
|
+
} catch {
|
|
175
|
+
throw new Error("Unable to verify the active worktree root: the path could not be resolved.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
export async function getCurrentBranch(
|
|
161
180
|
pi: Pick<ExtensionAPI, "exec">,
|
|
162
181
|
ctx: GitCommandContext,
|
|
@@ -610,6 +629,30 @@ export function parseWorktreePorcelain(output: string): Pick<ListWorktreesDetail
|
|
|
610
629
|
return { worktrees, omitted: records.length - returnedCount };
|
|
611
630
|
}
|
|
612
631
|
|
|
632
|
+
export interface LocalBranchWorktreeOccupancy {
|
|
633
|
+
branchName: string;
|
|
634
|
+
completeInventoryInspected: true;
|
|
635
|
+
occupied: boolean;
|
|
636
|
+
matchingWorktreeCount: number;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export interface PresentDirectLocalBranchRef {
|
|
640
|
+
status: "present";
|
|
641
|
+
branchName: string;
|
|
642
|
+
fullRef: string;
|
|
643
|
+
objectId: string;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export interface AbsentDirectLocalBranchRef {
|
|
647
|
+
status: "absent";
|
|
648
|
+
branchName: string;
|
|
649
|
+
fullRef: string;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export type DirectLocalBranchRefInspection =
|
|
653
|
+
| PresentDirectLocalBranchRef
|
|
654
|
+
| AbsentDirectLocalBranchRef;
|
|
655
|
+
|
|
613
656
|
interface WorktreeInventoryEntry {
|
|
614
657
|
record: ParsedWorktreeRecord;
|
|
615
658
|
index: number;
|
|
@@ -667,12 +710,16 @@ function safeWorktreeBranchLabel(branchName: string): string {
|
|
|
667
710
|
return JSON.stringify(safeWorktreeValue(branchName, GIT_CONTEXT_VALUE_LIMIT_CHARS));
|
|
668
711
|
}
|
|
669
712
|
|
|
713
|
+
export function isLosslessGitMetadata(value: string, limit: number): boolean {
|
|
714
|
+
return safeWorktreeValue(value, limit) === value;
|
|
715
|
+
}
|
|
716
|
+
|
|
670
717
|
export function requireLosslessWorktreeIdentity(
|
|
671
718
|
value: string,
|
|
672
719
|
identity: "cwd" | "branch",
|
|
673
720
|
): string {
|
|
674
721
|
const limit = identity === "cwd" ? GIT_WORKTREE_PATH_LIMIT_CHARS : GIT_CONTEXT_VALUE_LIMIT_CHARS;
|
|
675
|
-
if (
|
|
722
|
+
if (isLosslessGitMetadata(value, limit)) return value;
|
|
676
723
|
|
|
677
724
|
const label = identity === "cwd" ? "canonical worktree path" : "local branch name";
|
|
678
725
|
throw new Error(
|
|
@@ -775,13 +822,13 @@ function stripSingleLineTerminator(value: string): string {
|
|
|
775
822
|
return value;
|
|
776
823
|
}
|
|
777
824
|
|
|
778
|
-
async function
|
|
825
|
+
export async function getCanonicalCommonGitDirectory(
|
|
779
826
|
pi: Pick<ExtensionAPI, "exec">,
|
|
780
|
-
|
|
827
|
+
ctx: GitCommandContext,
|
|
781
828
|
signal?: AbortSignal,
|
|
782
829
|
): Promise<string> {
|
|
783
830
|
const args = ["rev-parse", "--path-format=absolute", "--git-common-dir"];
|
|
784
|
-
const result = await runGit(pi,
|
|
831
|
+
const result = await runGit(pi, ctx, args, {
|
|
785
832
|
signal,
|
|
786
833
|
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
787
834
|
});
|
|
@@ -791,14 +838,77 @@ async function getCommonGitDirectory(
|
|
|
791
838
|
!isAbsolute(commonGitDir) ||
|
|
792
839
|
/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(commonGitDir)
|
|
793
840
|
) {
|
|
794
|
-
throw new Error("Unable to
|
|
841
|
+
throw new Error("Unable to verify repository identity: Git returned an invalid common directory.");
|
|
795
842
|
}
|
|
796
843
|
|
|
797
844
|
try {
|
|
798
845
|
return await realpath(commonGitDir);
|
|
799
846
|
} catch {
|
|
800
|
-
throw new Error("Unable to
|
|
847
|
+
throw new Error("Unable to verify repository identity: the common Git directory could not be resolved.");
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export type GitOperationKind = "merge" | "rebase" | "cherry-pick" | "revert" | "sequencer";
|
|
852
|
+
|
|
853
|
+
export interface GitOperationState {
|
|
854
|
+
active: GitOperationKind[];
|
|
855
|
+
mergeHeadPresent: boolean;
|
|
856
|
+
autoMergePresent: boolean;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const GIT_OPERATION_MARKERS: ReadonlyArray<readonly [path: string, operation: GitOperationKind]> = [
|
|
860
|
+
["MERGE_HEAD", "merge"],
|
|
861
|
+
["AUTO_MERGE", "merge"],
|
|
862
|
+
["rebase-merge", "rebase"],
|
|
863
|
+
["rebase-apply", "rebase"],
|
|
864
|
+
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
865
|
+
["REVERT_HEAD", "revert"],
|
|
866
|
+
["sequencer", "sequencer"],
|
|
867
|
+
];
|
|
868
|
+
|
|
869
|
+
async function gitOperationMarkerExists(path: string): Promise<boolean> {
|
|
870
|
+
try {
|
|
871
|
+
await lstat(path);
|
|
872
|
+
return true;
|
|
873
|
+
} catch (error) {
|
|
874
|
+
if (isMissingFilesystemPath(error)) return false;
|
|
875
|
+
throw new Error("Unable to inspect repository operation state: a Git state path could not be inspected.");
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export async function getGitOperationState(
|
|
880
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
881
|
+
ctx: GitCommandContext,
|
|
882
|
+
signal?: AbortSignal,
|
|
883
|
+
): Promise<GitOperationState> {
|
|
884
|
+
const args = ["rev-parse", "--path-format=absolute"];
|
|
885
|
+
for (const [path] of GIT_OPERATION_MARKERS) args.push("--git-path", path);
|
|
886
|
+
const result = await runGit(pi, ctx, args, {
|
|
887
|
+
signal,
|
|
888
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
889
|
+
});
|
|
890
|
+
const paths = stripSingleLineTerminator(result.stdout).split(/\r?\n/u);
|
|
891
|
+
if (
|
|
892
|
+
paths.length !== GIT_OPERATION_MARKERS.length ||
|
|
893
|
+
paths.some((path) => !isAbsolute(path) || /[\p{Cc}\p{Cf}\u2028\u2029]/u.test(path))
|
|
894
|
+
) {
|
|
895
|
+
throw new Error("Unable to inspect repository operation state: Git returned invalid state paths.");
|
|
801
896
|
}
|
|
897
|
+
|
|
898
|
+
const active: GitOperationKind[] = [];
|
|
899
|
+
const presentMarkers: boolean[] = [];
|
|
900
|
+
for (const [index, path] of paths.entries()) {
|
|
901
|
+
const present = await gitOperationMarkerExists(path);
|
|
902
|
+
presentMarkers.push(present);
|
|
903
|
+
if (!present) continue;
|
|
904
|
+
const operation = GIT_OPERATION_MARKERS[index][1];
|
|
905
|
+
if (!active.includes(operation)) active.push(operation);
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
active,
|
|
909
|
+
mergeHeadPresent: presentMarkers[0] === true,
|
|
910
|
+
autoMergePresent: presentMarkers[1] === true,
|
|
911
|
+
};
|
|
802
912
|
}
|
|
803
913
|
|
|
804
914
|
async function prepareWorktreeCreation(
|
|
@@ -820,7 +930,11 @@ async function prepareWorktreeCreation(
|
|
|
820
930
|
}
|
|
821
931
|
}
|
|
822
932
|
|
|
823
|
-
const commonGitDir = await
|
|
933
|
+
const commonGitDir = await getCanonicalCommonGitDirectory(
|
|
934
|
+
pi,
|
|
935
|
+
{ cwd: inventory.repoRoot },
|
|
936
|
+
signal,
|
|
937
|
+
);
|
|
824
938
|
if (pathIsInsideOrEqual(canonicalPath, commonGitDir)) {
|
|
825
939
|
throw new Error("worktreePath destination cannot be inside the repository common Git directory.");
|
|
826
940
|
}
|
|
@@ -908,6 +1022,33 @@ export async function listWorktrees(
|
|
|
908
1022
|
};
|
|
909
1023
|
}
|
|
910
1024
|
|
|
1025
|
+
export async function inspectLocalBranchWorktreeOccupancy(
|
|
1026
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1027
|
+
ctx: GitCommandContext,
|
|
1028
|
+
branchName: string,
|
|
1029
|
+
signal?: AbortSignal,
|
|
1030
|
+
): Promise<LocalBranchWorktreeOccupancy> {
|
|
1031
|
+
validateBranchNameInput(branchName);
|
|
1032
|
+
requireLosslessWorktreeIdentity(branchName, "branch");
|
|
1033
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
1034
|
+
const args = ["worktree", "list", "--porcelain", "-z"];
|
|
1035
|
+
const result = await runGit(pi, { cwd: repoRoot }, args, {
|
|
1036
|
+
signal,
|
|
1037
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1038
|
+
});
|
|
1039
|
+
const records = parseWorktreeRecords(result.stdout);
|
|
1040
|
+
let matchingWorktreeCount = 0;
|
|
1041
|
+
for (const record of records) {
|
|
1042
|
+
if (record.branch === branchName) matchingWorktreeCount += 1;
|
|
1043
|
+
}
|
|
1044
|
+
return {
|
|
1045
|
+
branchName,
|
|
1046
|
+
completeInventoryInspected: true,
|
|
1047
|
+
occupied: matchingWorktreeCount > 0,
|
|
1048
|
+
matchingWorktreeCount,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
911
1052
|
function validateCreateWorktreeMode(branchMode: unknown): asserts branchMode is CreateWorktreeMode {
|
|
912
1053
|
if (branchMode !== "new" && branchMode !== "existing") {
|
|
913
1054
|
throw new Error("branchMode must be either 'new' or 'existing'.");
|
|
@@ -1746,6 +1887,113 @@ export async function localBranchExists(
|
|
|
1746
1887
|
return result.code === 0;
|
|
1747
1888
|
}
|
|
1748
1889
|
|
|
1890
|
+
function requireLosslessLocalRef(branchName: string): string {
|
|
1891
|
+
requireLosslessWorktreeIdentity(branchName, "branch");
|
|
1892
|
+
const fullRef = `refs/heads/${branchName}`;
|
|
1893
|
+
if (!isLosslessGitMetadata(fullRef, GIT_CONTEXT_VALUE_LIMIT_CHARS)) {
|
|
1894
|
+
throw new Error(
|
|
1895
|
+
"The full local branch ref cannot be returned safely and losslessly. " +
|
|
1896
|
+
`Use a branch name short enough to keep the full ref within ${GIT_CONTEXT_VALUE_LIMIT_CHARS} characters.`,
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
return fullRef;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
interface ExactLocalRefListing {
|
|
1903
|
+
exactObjectId: string | null;
|
|
1904
|
+
exactRefFound: boolean;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function parseExactLocalRefListing(
|
|
1908
|
+
branchName: string,
|
|
1909
|
+
fullRef: string,
|
|
1910
|
+
output: string,
|
|
1911
|
+
): ExactLocalRefListing {
|
|
1912
|
+
const row = stripSingleLineTerminator(output);
|
|
1913
|
+
if (!row) return { exactObjectId: null, exactRefFound: false };
|
|
1914
|
+
|
|
1915
|
+
const fields = row.split(NUL_SEPARATOR);
|
|
1916
|
+
if (fields.length !== 2 || !fields[0] || !fields[1]) {
|
|
1917
|
+
throw new Error(
|
|
1918
|
+
`Unable to inspect local branch ${safeWorktreeBranchLabel(branchName)}: ` +
|
|
1919
|
+
"Git returned a malformed exact-ref listing.",
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
const [observedRef, objectId] = fields;
|
|
1923
|
+
if (observedRef !== fullRef) {
|
|
1924
|
+
if (observedRef.startsWith(`${fullRef}/`)) {
|
|
1925
|
+
return { exactObjectId: null, exactRefFound: false };
|
|
1926
|
+
}
|
|
1927
|
+
throw new Error(
|
|
1928
|
+
`Unable to inspect local branch ${safeWorktreeBranchLabel(branchName)}: ` +
|
|
1929
|
+
"Git returned a different ref identity for the requested exact local branch.",
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(objectId)) {
|
|
1933
|
+
throw new Error(
|
|
1934
|
+
`Unable to inspect local branch ${safeWorktreeBranchLabel(branchName)}: ` +
|
|
1935
|
+
"Git returned a malformed full object identity.",
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
return { exactObjectId: objectId, exactRefFound: true };
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
async function rejectSymbolicLocalBranchRef(
|
|
1942
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1943
|
+
ctx: GitCommandContext,
|
|
1944
|
+
branchName: string,
|
|
1945
|
+
fullRef: string,
|
|
1946
|
+
signal?: AbortSignal,
|
|
1947
|
+
): Promise<void> {
|
|
1948
|
+
const symbolicRefArgs = ["symbolic-ref", "--quiet", fullRef];
|
|
1949
|
+
const symbolicRef = await runGit(pi, ctx, symbolicRefArgs, {
|
|
1950
|
+
signal,
|
|
1951
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1952
|
+
allowFailure: true,
|
|
1953
|
+
});
|
|
1954
|
+
if (symbolicRef.code === 0) {
|
|
1955
|
+
throw new Error(
|
|
1956
|
+
`Local branch ${safeWorktreeBranchLabel(branchName)} is symbolic; ` +
|
|
1957
|
+
"retirement requires a verified direct local branch ref.",
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
if (symbolicRef.code !== 1 || symbolicRef.stdout.length > 0 || symbolicRef.stderr.length > 0) {
|
|
1961
|
+
throw new Error(formatGitFailure(symbolicRefArgs, symbolicRef));
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
export async function inspectDirectLocalBranchRef(
|
|
1966
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1967
|
+
ctx: GitCommandContext,
|
|
1968
|
+
branchName: string,
|
|
1969
|
+
signal?: AbortSignal,
|
|
1970
|
+
): Promise<DirectLocalBranchRefInspection> {
|
|
1971
|
+
validateBranchNameInput(branchName);
|
|
1972
|
+
const fullRef = requireLosslessLocalRef(branchName);
|
|
1973
|
+
const listArgs = [
|
|
1974
|
+
"for-each-ref",
|
|
1975
|
+
"--count=1",
|
|
1976
|
+
"--sort=refname",
|
|
1977
|
+
"--format=%(refname)%00%(objectname)",
|
|
1978
|
+
fullRef,
|
|
1979
|
+
];
|
|
1980
|
+
const listing = await runGit(pi, ctx, listArgs, {
|
|
1981
|
+
signal,
|
|
1982
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
1983
|
+
});
|
|
1984
|
+
const parsed = parseExactLocalRefListing(branchName, fullRef, listing.stdout);
|
|
1985
|
+
await rejectSymbolicLocalBranchRef(pi, ctx, branchName, fullRef, signal);
|
|
1986
|
+
if (!parsed.exactRefFound || parsed.exactObjectId === null) {
|
|
1987
|
+
return { status: "absent", branchName, fullRef };
|
|
1988
|
+
}
|
|
1989
|
+
return {
|
|
1990
|
+
status: "present",
|
|
1991
|
+
branchName,
|
|
1992
|
+
fullRef,
|
|
1993
|
+
objectId: parsed.exactObjectId,
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1749
1997
|
export async function getLocalBranchCommit(
|
|
1750
1998
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1751
1999
|
ctx: GitCommandContext,
|
|
@@ -1764,6 +2012,64 @@ export async function getLocalBranchCommit(
|
|
|
1764
2012
|
return commit;
|
|
1765
2013
|
}
|
|
1766
2014
|
|
|
2015
|
+
export async function requireExistingLocalBranch(
|
|
2016
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2017
|
+
ctx: GitCommandContext,
|
|
2018
|
+
branchName: string,
|
|
2019
|
+
label: "Source" | "Target",
|
|
2020
|
+
signal?: AbortSignal,
|
|
2021
|
+
): Promise<void> {
|
|
2022
|
+
if (await localBranchExists(pi, ctx, branchName, signal)) return;
|
|
2023
|
+
throw new Error(`${label} local branch '${safeGitContextValue(branchName)}' does not exist.`);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
export async function isCommitAncestor(
|
|
2027
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2028
|
+
ctx: GitCommandContext,
|
|
2029
|
+
ancestorCommit: string,
|
|
2030
|
+
descendantCommit: string,
|
|
2031
|
+
signal?: AbortSignal,
|
|
2032
|
+
): Promise<boolean> {
|
|
2033
|
+
if (!/^[0-9a-f]{40,64}$/iu.test(ancestorCommit) || !/^[0-9a-f]{40,64}$/iu.test(descendantCommit)) {
|
|
2034
|
+
throw new Error("Ancestry verification requires two valid full commit identities.");
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
const args = ["merge-base", "--is-ancestor", ancestorCommit, descendantCommit];
|
|
2038
|
+
const result = await runGit(pi, ctx, args, {
|
|
2039
|
+
signal,
|
|
2040
|
+
timeout: GIT_STATUS_TIMEOUT_MS,
|
|
2041
|
+
allowFailure: true,
|
|
2042
|
+
});
|
|
2043
|
+
if (result.code !== 0 && result.code !== 1) throw new Error(formatGitFailure(args, result));
|
|
2044
|
+
return result.code === 0;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
export async function getLocalBranchAncestry(
|
|
2048
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2049
|
+
ctx: GitCommandContext,
|
|
2050
|
+
query: BranchStatusAncestryQuery,
|
|
2051
|
+
signal?: AbortSignal,
|
|
2052
|
+
): Promise<BranchAncestryDetails> {
|
|
2053
|
+
validateBranchNameInput(query.sourceBranch, "Source branch");
|
|
2054
|
+
validateBranchNameInput(query.targetBranch, "Target branch");
|
|
2055
|
+
await validateBranchName(pi, ctx, query.sourceBranch, signal);
|
|
2056
|
+
await validateBranchName(pi, ctx, query.targetBranch, signal);
|
|
2057
|
+
await requireExistingLocalBranch(pi, ctx, query.sourceBranch, "Source", signal);
|
|
2058
|
+
await requireExistingLocalBranch(pi, ctx, query.targetBranch, "Target", signal);
|
|
2059
|
+
|
|
2060
|
+
const sourceHead = await getLocalBranchCommit(pi, ctx, query.sourceBranch, signal);
|
|
2061
|
+
const targetHead = await getLocalBranchCommit(pi, ctx, query.targetBranch, signal);
|
|
2062
|
+
const isAncestor = await isCommitAncestor(pi, ctx, sourceHead, targetHead, signal);
|
|
2063
|
+
|
|
2064
|
+
return {
|
|
2065
|
+
sourceBranch: query.sourceBranch,
|
|
2066
|
+
targetBranch: query.targetBranch,
|
|
2067
|
+
sourceHead,
|
|
2068
|
+
targetHead,
|
|
2069
|
+
isAncestor,
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1767
2073
|
export async function createLocalBranch(
|
|
1768
2074
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1769
2075
|
ctx: GitCommandContext,
|
|
@@ -7,13 +7,17 @@ import {
|
|
|
7
7
|
CREATE_BRANCH_TOOL_NAME,
|
|
8
8
|
CREATE_WORKTREE_TOOL_NAME,
|
|
9
9
|
FETCH_BRANCH_TOOL_NAME,
|
|
10
|
+
GIT_INTEGRATION_SUMMARY_LIMIT_CHARS,
|
|
11
|
+
GIT_RETIREMENT_SUMMARY_LIMIT_CHARS,
|
|
10
12
|
GIT_WORKTREE_SUMMARY_LIMIT_CHARS,
|
|
13
|
+
INTEGRATE_BRANCH_TOOL_NAME,
|
|
11
14
|
LIST_WORKTREES_TOOL_NAME,
|
|
12
15
|
PULL_BRANCH_TOOL_NAME,
|
|
13
16
|
PULL_REQUEST_TOOL_NAME,
|
|
14
17
|
PUSH_BRANCH_TOOL_NAME,
|
|
15
18
|
REBASE_BRANCH_TOOL_NAME,
|
|
16
19
|
REMOVE_WORKTREE_TOOL_NAME,
|
|
20
|
+
RETIRE_BRANCH_TOOL_NAME,
|
|
17
21
|
} from "../constants.ts";
|
|
18
22
|
import {
|
|
19
23
|
changeExistingLocalBranch,
|
|
@@ -35,6 +39,8 @@ import {
|
|
|
35
39
|
withRepositoryMutationQueue,
|
|
36
40
|
} from "../git.ts";
|
|
37
41
|
import { collectGitContext, formatGitContext } from "../git-context.ts";
|
|
42
|
+
import { integrateBranch } from "../git-integration.ts";
|
|
43
|
+
import { retireBranch } from "../git-retirement.ts";
|
|
38
44
|
import {
|
|
39
45
|
createGitHubPullRequest,
|
|
40
46
|
ensureGitHubBranchExists,
|
|
@@ -49,6 +55,7 @@ import type {
|
|
|
49
55
|
ChangeBranchDetails,
|
|
50
56
|
CreateWorktreeDetails,
|
|
51
57
|
GitContextDetails,
|
|
58
|
+
IntegrateBranchDetails,
|
|
52
59
|
ListWorktreesDetails,
|
|
53
60
|
PullRequestDetails,
|
|
54
61
|
PullRequestInput,
|
|
@@ -56,11 +63,48 @@ import type {
|
|
|
56
63
|
PullRequestToolDetails,
|
|
57
64
|
PullRequestToolInput,
|
|
58
65
|
RemoveWorktreeDetails,
|
|
66
|
+
RetireBranchDetails,
|
|
59
67
|
WorktreeEntry,
|
|
60
68
|
} from "../types.ts";
|
|
61
69
|
|
|
62
70
|
const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
|
|
63
71
|
|
|
72
|
+
const BranchStatusParametersSchema = Type.Object(
|
|
73
|
+
{
|
|
74
|
+
ancestry: Type.Optional(
|
|
75
|
+
Type.Object(
|
|
76
|
+
{
|
|
77
|
+
sourceBranch: Type.String({ minLength: 1, description: "Exact existing local source branch to verify." }),
|
|
78
|
+
targetBranch: Type.String({ minLength: 1, description: "Exact existing local target branch to verify." }),
|
|
79
|
+
},
|
|
80
|
+
{ additionalProperties: false },
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
{ additionalProperties: false },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const IntegrateBranchParametersSchema = Type.Object(
|
|
88
|
+
{
|
|
89
|
+
sourceBranch: Type.String({ minLength: 1, description: "Exact existing local source branch to integrate." }),
|
|
90
|
+
targetBranch: Type.String({ minLength: 1, description: "Exact existing local target branch already checked out in the clean control worktree." }),
|
|
91
|
+
},
|
|
92
|
+
{ additionalProperties: false },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const RetireBranchParametersSchema = Type.Object(
|
|
96
|
+
{
|
|
97
|
+
branchName: Type.String({ minLength: 1, description: "Exact existing local branch to retire." }),
|
|
98
|
+
expectedHead: Type.String({
|
|
99
|
+
pattern: "^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$",
|
|
100
|
+
description: "Full 40- or 64-character hexadecimal commit identity expected for branchName.",
|
|
101
|
+
}),
|
|
102
|
+
targetBranch: Type.String({ minLength: 1, description: "Exact existing local branch used for ancestry verification." }),
|
|
103
|
+
force: Type.Boolean({ description: "Explicit authorization for unmerged retirement; false for merged retirement." }),
|
|
104
|
+
},
|
|
105
|
+
{ additionalProperties: false },
|
|
106
|
+
);
|
|
107
|
+
|
|
64
108
|
const CreateBranchParametersSchema = Type.Object(
|
|
65
109
|
{
|
|
66
110
|
branchName: Type.String({ minLength: 1, description: "Name of the new branch to create from current HEAD." }),
|
|
@@ -224,6 +268,68 @@ export function formatRemoveWorktree(details: RemoveWorktreeDetails): string {
|
|
|
224
268
|
return `Removed linked worktree directory ${path}. Verified it is no longer registered and retained local branch ${branch} at HEAD ${shortCommit(details.handoff.head)}; the removed cwd is not ready for handoff.`;
|
|
225
269
|
}
|
|
226
270
|
|
|
271
|
+
function integrationConflictOmissionLine(omitted: number): string {
|
|
272
|
+
return `${omitted} conflict path${omitted === 1 ? "" : "s"} omitted.`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function formatIntegrationConflict(details: Extract<IntegrateBranchDetails, { status: "conflict" }>): string {
|
|
276
|
+
const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
277
|
+
const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
278
|
+
const lines = [
|
|
279
|
+
`integrate_branch found conflicts while integrating ${source} into ${target}; the merge was automatically aborted and exact restoration was verified at target HEAD ${shortCommit(details.verified.heads.after.targetHead)}.`,
|
|
280
|
+
"Conflict paths:",
|
|
281
|
+
];
|
|
282
|
+
let omitted = details.conflict.omitted;
|
|
283
|
+
|
|
284
|
+
for (const [index, entry] of details.conflict.paths.entries()) {
|
|
285
|
+
const path = safeWorktreeFormatValue(entry.path, WORKTREE_FORMAT_PATH_LIMIT_CHARS);
|
|
286
|
+
const line = `- ${path}`;
|
|
287
|
+
const remaining = details.conflict.paths.length - index - 1;
|
|
288
|
+
const candidateOmitted = details.conflict.omitted + remaining;
|
|
289
|
+
const candidate = [
|
|
290
|
+
...lines,
|
|
291
|
+
line,
|
|
292
|
+
...(candidateOmitted > 0 ? [integrationConflictOmissionLine(candidateOmitted)] : []),
|
|
293
|
+
].join("\n");
|
|
294
|
+
if (candidate.length > GIT_INTEGRATION_SUMMARY_LIMIT_CHARS) {
|
|
295
|
+
omitted += details.conflict.paths.length - index;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
lines.push(line);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (omitted > 0) lines.push(integrationConflictOmissionLine(omitted));
|
|
302
|
+
return lines.join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function formatIntegrateBranch(details: IntegrateBranchDetails): string {
|
|
306
|
+
if (details.status === "conflict") return formatIntegrationConflict(details);
|
|
307
|
+
|
|
308
|
+
const source = safeWorktreeFormatValue(details.request.sourceBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
309
|
+
const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
310
|
+
const beforeTarget = shortCommit(details.verified.heads.before.targetHead);
|
|
311
|
+
const afterTarget = shortCommit(details.verified.heads.after.targetHead);
|
|
312
|
+
if (details.status === "already_integrated") {
|
|
313
|
+
return `integrate_branch verified ${source} was already integrated into ${target}; no merge ran and target HEAD remained ${afterTarget}.`;
|
|
314
|
+
}
|
|
315
|
+
if (details.status === "fast_forward") {
|
|
316
|
+
return `integrate_branch fast-forwarded ${target} from ${beforeTarget} to ${afterTarget}, integrating ${source}.`;
|
|
317
|
+
}
|
|
318
|
+
return `integrate_branch created and verified a merge commit ${afterTarget} on ${target} from previous target ${beforeTarget} and source ${source}.`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function formatRetireBranch(details: RetireBranchDetails): string {
|
|
322
|
+
const branch = safeWorktreeFormatValue(details.request.branchName, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
323
|
+
const target = safeWorktreeFormatValue(details.request.targetBranch, WORKTREE_FORMAT_BRANCH_LIMIT_CHARS);
|
|
324
|
+
const retiredHead = shortCommit(details.verified.refs.before.retiring.head);
|
|
325
|
+
const targetHead = shortCommit(details.verified.refs.after.target.head);
|
|
326
|
+
const localOnly = "No remote or remote-tracking branch was deleted.";
|
|
327
|
+
const text = details.mode === "merged"
|
|
328
|
+
? `retire_branch retired merged local branch ${branch} at HEAD ${retiredHead} after verifying ancestry to ${target} at ${targetHead}. ${localOnly}`
|
|
329
|
+
: `retire_branch force-retired unmerged local branch ${branch} at HEAD ${retiredHead} against ${target} at ${targetHead}. The retired commit may lose its remaining local branch reference and can eventually become unreachable. ${localOnly}`;
|
|
330
|
+
return text.slice(0, GIT_RETIREMENT_SUMMARY_LIMIT_CHARS);
|
|
331
|
+
}
|
|
332
|
+
|
|
227
333
|
const PULL_REQUEST_INPUT_FIELDS: PullRequestInputField[] = ["headBranch", "baseBranch", "title", "body", "draft"];
|
|
228
334
|
|
|
229
335
|
interface ResolvedPullRequestBranches {
|
|
@@ -381,16 +487,18 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
381
487
|
pi.registerTool({
|
|
382
488
|
name: BRANCH_STATUS_TOOL_NAME,
|
|
383
489
|
label: "Branch Status",
|
|
384
|
-
description: "branch_status explicitly refreshes the current Git repository snapshot
|
|
385
|
-
promptSnippet: "branch_status: explicitly refresh current-repository
|
|
490
|
+
description: "branch_status explicitly refreshes the current Git repository snapshot and can optionally verify whether one captured local branch commit is an ancestor of another. branch_status is read-only and never mutates files, Git state, or GitHub state.",
|
|
491
|
+
promptSnippet: "branch_status: explicitly refresh current-repository Git state and optionally verify targeted local-branch ancestry without mutation",
|
|
386
492
|
promptGuidelines: [
|
|
387
493
|
"Use the automatic Git context for start-of-run questions; call branch_status only for an explicit refresh or after Git state changes during the current run.",
|
|
388
494
|
"Use branch_status as a read-only refresh; branch_status never mutates files, Git state, or GitHub state.",
|
|
495
|
+
"Use targeted branch_status ancestry verification only after integrate_branch completes; do not issue branch_status in the same parallel tool batch as integrate_branch.",
|
|
389
496
|
],
|
|
390
|
-
parameters:
|
|
391
|
-
async execute(_toolCallId,
|
|
497
|
+
parameters: BranchStatusParametersSchema,
|
|
498
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
392
499
|
const details = await collectGitContext(pi, ctx, {
|
|
393
500
|
signal,
|
|
501
|
+
ancestry: params.ancestry,
|
|
394
502
|
env: options.env,
|
|
395
503
|
fetchImpl: options.fetchImpl,
|
|
396
504
|
});
|
|
@@ -514,6 +622,53 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
514
622
|
},
|
|
515
623
|
});
|
|
516
624
|
|
|
625
|
+
pi.registerTool({
|
|
626
|
+
name: INTEGRATE_BRANCH_TOOL_NAME,
|
|
627
|
+
label: "Integrate Branch",
|
|
628
|
+
description: "integrate_branch verifies and merges one exact existing local source branch into one exact existing local target branch from the current clean control worktree, returning an already-integrated, fast-forward, merge-commit, or automatically aborted conflict result. integrate_branch never fetches or pushes.",
|
|
629
|
+
promptSnippet: "integrate_branch: merge an exact local source branch into the checked-out clean local target with verified outcomes and automatic conflict abort",
|
|
630
|
+
promptGuidelines: [
|
|
631
|
+
"Use integrate_branch only when the user explicitly wants one exact existing local source branch integrated into one exact existing local target branch.",
|
|
632
|
+
"Before integrate_branch, require the current clean control worktree to already have targetBranch checked out; integrate_branch never switches branches, stashes, or discards changes.",
|
|
633
|
+
"Use integrate_branch only after local refs already contain the commits to integrate; integrate_branch never fetches or pushes.",
|
|
634
|
+
"Call integrate_branch by itself and wait for it to complete; never batch integrate_branch with other Git mutations.",
|
|
635
|
+
"After integrate_branch completes, run any targeted branch_status ancestry proof separately; never place integrate_branch and branch_status in the same parallel tool batch.",
|
|
636
|
+
"A conflict result from integrate_branch means the merge was automatically aborted and restoration was verified; semantic conflict analysis belongs to a separate delegated workflow, not integrate_branch.",
|
|
637
|
+
],
|
|
638
|
+
parameters: IntegrateBranchParametersSchema,
|
|
639
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
640
|
+
const details = await integrateBranch(pi, ctx, params, signal);
|
|
641
|
+
return {
|
|
642
|
+
content: [{ type: "text", text: formatIntegrateBranch(details) }],
|
|
643
|
+
details,
|
|
644
|
+
};
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
pi.registerTool({
|
|
649
|
+
name: RETIRE_BRANCH_TOOL_NAME,
|
|
650
|
+
label: "Retire Branch",
|
|
651
|
+
description: "retire_branch deletes one exact unoccupied local branch only when its current commit matches expectedHead and its ancestry to one exact local targetBranch is verified. retire_branch requires force: true for explicitly authorized unmerged retirement, never directly deletes remote or remote-tracking branches, and leaves local branch configuration untouched.",
|
|
652
|
+
promptSnippet: "retire_branch: delete one exact local branch with an expected-HEAD lease, target ancestry verification, worktree-occupancy rejection, and explicit unmerged force authorization",
|
|
653
|
+
promptGuidelines: [
|
|
654
|
+
"Use retire_branch only when the user explicitly intends to delete one exact local branch.",
|
|
655
|
+
"Before retire_branch, obtain a fresh expected commit and ancestry proof with branch_status unless the exact commit was already supplied.",
|
|
656
|
+
"Call branch_status and retire_branch sequentially; never place branch_status and retire_branch in the same parallel tool batch.",
|
|
657
|
+
"Call retire_branch by itself; never run retire_branch beside another Git mutation.",
|
|
658
|
+
"Use force: true with retire_branch only after explicit user authorization for unmerged data loss.",
|
|
659
|
+
"Treat remove_worktree and retire_branch as separate sequential operations: removing a linked worktree retains its branch, which may be retired only afterward.",
|
|
660
|
+
"retire_branch never directly deletes a remote or remote-tracking branch.",
|
|
661
|
+
],
|
|
662
|
+
parameters: RetireBranchParametersSchema,
|
|
663
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
664
|
+
const details = await retireBranch(pi, ctx, params, signal);
|
|
665
|
+
return {
|
|
666
|
+
content: [{ type: "text", text: formatRetireBranch(details) }],
|
|
667
|
+
details,
|
|
668
|
+
};
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
|
|
517
672
|
pi.registerTool({
|
|
518
673
|
name: PUSH_BRANCH_TOOL_NAME,
|
|
519
674
|
label: "Push Branch",
|