@senad-d/branchme 0.1.3 → 0.1.5
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 +6 -4
- package/README.md +56 -25
- package/SECURITY.md +24 -7
- package/docs/SMOKE_TEST.md +8 -6
- package/docs/STRUCTURE.md +27 -17
- package/docs/TUI_CAPTURE.md +10 -7
- package/package.json +3 -3
- package/src/commands/branchme-command.ts +8 -5
- package/src/constants.ts +9 -0
- package/src/extension.ts +2 -0
- package/src/git-context.ts +363 -0
- package/src/git.ts +335 -24
- package/src/github.ts +305 -2
- package/src/tools/branchme-tools.ts +39 -21
- package/src/types.ts +57 -0
- package/src/ui/branchme-panel.ts +2 -1
package/src/github.ts
CHANGED
|
@@ -5,12 +5,26 @@ import { redactSecrets } from "./redaction.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
GITHUB_API_BASE_URL,
|
|
7
7
|
GITHUB_API_VERSION,
|
|
8
|
+
GITHUB_RELATED_PR_TIMEOUT_MS,
|
|
8
9
|
GITHUB_RESPONSE_BODY_LIMIT_BYTES,
|
|
9
10
|
GITHUB_USER_AGENT,
|
|
11
|
+
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
10
12
|
MAX_SUMMARY_OUTPUT_CHARS,
|
|
11
13
|
} from "./constants.ts";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
+
import {
|
|
15
|
+
getCurrentBranch,
|
|
16
|
+
getGitRoot,
|
|
17
|
+
getOriginUrl,
|
|
18
|
+
validateBranchNameInput,
|
|
19
|
+
type GitCommandContext,
|
|
20
|
+
} from "./git.ts";
|
|
21
|
+
import type {
|
|
22
|
+
GitHubRepository,
|
|
23
|
+
PullRequestDetails,
|
|
24
|
+
PullRequestInput,
|
|
25
|
+
RelatedPullRequest,
|
|
26
|
+
RelatedPullRequestDetails,
|
|
27
|
+
} from "./types.ts";
|
|
14
28
|
|
|
15
29
|
type TokenEnvironmentKey = "GITHUB_TOKEN" | "GH_TOKEN";
|
|
16
30
|
|
|
@@ -33,6 +47,11 @@ export interface PullRequestFetchOptions {
|
|
|
33
47
|
signal?: AbortSignal;
|
|
34
48
|
}
|
|
35
49
|
|
|
50
|
+
export interface RelatedPullRequestLookupOptions extends PullRequestFetchOptions {
|
|
51
|
+
env?: NodeJS.ProcessEnv;
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
export interface GitHubBranchDetails {
|
|
37
56
|
name: string;
|
|
38
57
|
commitSha: string;
|
|
@@ -484,6 +503,11 @@ function requireGitHubResponseObject(payload: unknown, responseContext: string):
|
|
|
484
503
|
return payload;
|
|
485
504
|
}
|
|
486
505
|
|
|
506
|
+
function requireGitHubResponseArray(payload: unknown, responseContext: string): unknown[] {
|
|
507
|
+
if (!Array.isArray(payload)) throw new Error(`${responseContext} was not an array.`);
|
|
508
|
+
return payload;
|
|
509
|
+
}
|
|
510
|
+
|
|
487
511
|
async function readGitHubJsonObject(
|
|
488
512
|
response: Response,
|
|
489
513
|
signal: AbortSignal | undefined,
|
|
@@ -496,6 +520,18 @@ async function readGitHubJsonObject(
|
|
|
496
520
|
return requireGitHubResponseObject(parseGitHubJson(body.text, responseContext, token), responseContext);
|
|
497
521
|
}
|
|
498
522
|
|
|
523
|
+
async function readGitHubJsonArray(
|
|
524
|
+
response: Response,
|
|
525
|
+
signal: AbortSignal | undefined,
|
|
526
|
+
token: string,
|
|
527
|
+
responseContext: string,
|
|
528
|
+
readFailurePrefix: string,
|
|
529
|
+
): Promise<unknown[]> {
|
|
530
|
+
const body = await readGitHubResponseBody(response, signal, readFailurePrefix, token);
|
|
531
|
+
if (body.truncated) throw new Error(`${responseContext} exceeded the ${GITHUB_RESPONSE_BODY_LIMIT_BYTES} byte limit.`);
|
|
532
|
+
return requireGitHubResponseArray(parseGitHubJson(body.text, responseContext, token), responseContext);
|
|
533
|
+
}
|
|
534
|
+
|
|
499
535
|
function parseGitHubBranchDetails(payload: Record<string, unknown>, branchName: string): GitHubBranchDetails {
|
|
500
536
|
const commit = isRecord(payload.commit) ? stringField(payload.commit.sha, "commit.sha") : "";
|
|
501
537
|
if (!/^[0-9a-f]{40,64}$/iu.test(commit)) throw new Error("GitHub branch preflight response is missing commit.sha.");
|
|
@@ -604,6 +640,273 @@ function pullRequestDetailsFromPayload(
|
|
|
604
640
|
};
|
|
605
641
|
}
|
|
606
642
|
|
|
643
|
+
function escapeRelatedPullRequestControlCharacter(character: string): string {
|
|
644
|
+
const codePoint = character.codePointAt(0);
|
|
645
|
+
return codePoint === undefined ? "" : String.raw`\u${codePoint.toString(16).padStart(4, "0")}`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function boundedRelatedPullRequestValue(value: string): string {
|
|
649
|
+
const safeValue = redactSecrets(value).replace(
|
|
650
|
+
/[\p{Cc}\p{Cf}\u2028\u2029]/gu,
|
|
651
|
+
escapeRelatedPullRequestControlCharacter,
|
|
652
|
+
);
|
|
653
|
+
if (safeValue.length <= GIT_CONTEXT_VALUE_LIMIT_CHARS) return safeValue;
|
|
654
|
+
|
|
655
|
+
let end = GIT_CONTEXT_VALUE_LIMIT_CHARS - 1;
|
|
656
|
+
const lastCodePoint = safeValue.codePointAt(end - 1);
|
|
657
|
+
if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
|
|
658
|
+
return `${safeValue.slice(0, end)}…`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function requireRelatedPullRequestString(value: unknown, field: string): string {
|
|
662
|
+
const text = stringField(value, field);
|
|
663
|
+
if (text.trim().length === 0) throw new Error(`GitHub response ${field} cannot be blank.`);
|
|
664
|
+
return text;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function requireRelatedPullRequestUrl(
|
|
668
|
+
value: unknown,
|
|
669
|
+
repository: GitHubRepository,
|
|
670
|
+
number: number,
|
|
671
|
+
): string {
|
|
672
|
+
const rawUrl = requireRelatedPullRequestString(value, "html_url");
|
|
673
|
+
if (rawUrl.length > GIT_CONTEXT_VALUE_LIMIT_CHARS || /[\p{Cc}\p{Cf}\s]/u.test(rawUrl)) {
|
|
674
|
+
throw new Error("GitHub response html_url was invalid.");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
let parsed: URL;
|
|
678
|
+
try {
|
|
679
|
+
parsed = new URL(rawUrl);
|
|
680
|
+
} catch {
|
|
681
|
+
throw new Error("GitHub response html_url was invalid.");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const expectedPath = `/${repository.owner}/${repository.repo}/pull/${number}`.toLowerCase();
|
|
685
|
+
if (
|
|
686
|
+
parsed.protocol !== "https:" ||
|
|
687
|
+
parsed.hostname.toLowerCase() !== "github.com" ||
|
|
688
|
+
parsed.port !== "" ||
|
|
689
|
+
parsed.username !== "" ||
|
|
690
|
+
parsed.password !== "" ||
|
|
691
|
+
parsed.search !== "" ||
|
|
692
|
+
parsed.hash !== "" ||
|
|
693
|
+
parsed.pathname.toLowerCase() !== expectedPath
|
|
694
|
+
) {
|
|
695
|
+
throw new Error("GitHub response html_url did not match the resolved repository and pull request number.");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return rawUrl;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function requireRelatedPullRequestRepository(
|
|
702
|
+
value: unknown,
|
|
703
|
+
repository: GitHubRepository,
|
|
704
|
+
): void {
|
|
705
|
+
const headRepository = requireGitHubResponseObject(value, "GitHub related pull request head.repo");
|
|
706
|
+
const fullName = requireRelatedPullRequestString(headRepository.full_name, "head.repo.full_name");
|
|
707
|
+
if (fullName.toLowerCase() !== repositoryLabel(repository).toLowerCase()) {
|
|
708
|
+
throw new Error("GitHub related pull request head repository did not match the resolved repository.");
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function relatedPullRequestDetailsFromPayload(
|
|
713
|
+
repository: GitHubRepository,
|
|
714
|
+
currentBranch: string,
|
|
715
|
+
payload: unknown,
|
|
716
|
+
): RelatedPullRequestDetails {
|
|
717
|
+
const response = requireGitHubResponseObject(payload, "GitHub related pull request response item");
|
|
718
|
+
const number = pullRequestNumberField(response.number);
|
|
719
|
+
const state = requireRelatedPullRequestString(response.state, "state");
|
|
720
|
+
if (state !== "open") throw new Error("GitHub related pull request response state was not open.");
|
|
721
|
+
if (typeof response.draft !== "boolean") throw new Error("GitHub response draft must be a boolean.");
|
|
722
|
+
|
|
723
|
+
const head = requireGitHubResponseObject(response.head, "GitHub related pull request head");
|
|
724
|
+
const headRef = requireRelatedPullRequestString(head.ref, "head.ref");
|
|
725
|
+
if (headRef !== currentBranch) {
|
|
726
|
+
throw new Error("GitHub related pull request head did not match the current branch.");
|
|
727
|
+
}
|
|
728
|
+
requireRelatedPullRequestRepository(head.repo, repository);
|
|
729
|
+
|
|
730
|
+
const base = requireGitHubResponseObject(response.base, "GitHub related pull request base");
|
|
731
|
+
const baseRef = requireRelatedPullRequestString(base.ref, "base.ref");
|
|
732
|
+
const title = requireRelatedPullRequestString(response.title, "title");
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
repository: {
|
|
736
|
+
owner: boundedRelatedPullRequestValue(repository.owner),
|
|
737
|
+
repo: boundedRelatedPullRequestValue(repository.repo),
|
|
738
|
+
},
|
|
739
|
+
number,
|
|
740
|
+
url: requireRelatedPullRequestUrl(response.html_url, repository, number),
|
|
741
|
+
title: boundedRelatedPullRequestValue(title),
|
|
742
|
+
state,
|
|
743
|
+
draft: response.draft,
|
|
744
|
+
head: boundedRelatedPullRequestValue(headRef),
|
|
745
|
+
base: boundedRelatedPullRequestValue(baseRef),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function unavailableRelatedPullRequest(reason: string): RelatedPullRequest {
|
|
750
|
+
return { status: "unavailable", reason };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function abortedRelatedPullRequest(
|
|
754
|
+
callerSignal: AbortSignal | undefined,
|
|
755
|
+
timeoutSignal: AbortSignal,
|
|
756
|
+
): RelatedPullRequest | null {
|
|
757
|
+
if (callerSignal?.aborted) return unavailableRelatedPullRequest("Related pull request lookup was cancelled.");
|
|
758
|
+
if (timeoutSignal.aborted) return unavailableRelatedPullRequest("Related pull request lookup timed out.");
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function validateRelatedPullRequestLookupBounds(repository: GitHubRepository, branch: string): void {
|
|
763
|
+
if (
|
|
764
|
+
repository.owner.length > GIT_CONTEXT_VALUE_LIMIT_CHARS ||
|
|
765
|
+
repository.repo.length > GIT_CONTEXT_VALUE_LIMIT_CHARS ||
|
|
766
|
+
branch.length > GIT_CONTEXT_VALUE_LIMIT_CHARS
|
|
767
|
+
) {
|
|
768
|
+
throw new Error("GitHub related pull request lookup metadata exceeded its value limit.");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function relatedPullRequestLookupUrl(repository: GitHubRepository, currentBranch: string): string {
|
|
773
|
+
const query = new URLSearchParams({
|
|
774
|
+
state: "open",
|
|
775
|
+
head: `${repository.owner}:${currentBranch}`,
|
|
776
|
+
per_page: "1",
|
|
777
|
+
});
|
|
778
|
+
return `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/pulls?${query.toString()}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function lookupRelatedPullRequestWithSignal(
|
|
782
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
783
|
+
ctx: GitCommandContext,
|
|
784
|
+
options: RelatedPullRequestLookupOptions,
|
|
785
|
+
signal: AbortSignal,
|
|
786
|
+
timeoutSignal: AbortSignal,
|
|
787
|
+
): Promise<RelatedPullRequest> {
|
|
788
|
+
const initialAbort = abortedRelatedPullRequest(options.signal, timeoutSignal);
|
|
789
|
+
if (initialAbort) return initialAbort;
|
|
790
|
+
|
|
791
|
+
let repoRoot: string;
|
|
792
|
+
let currentBranch: string;
|
|
793
|
+
let repository: GitHubRepository;
|
|
794
|
+
try {
|
|
795
|
+
repoRoot = await getGitRoot(pi, ctx, signal);
|
|
796
|
+
const rootCtx = { cwd: repoRoot };
|
|
797
|
+
const branch = await getCurrentBranch(pi, rootCtx, signal);
|
|
798
|
+
if (branch.detached || !branch.currentBranch) {
|
|
799
|
+
return unavailableRelatedPullRequest("Related pull request lookup is unavailable for detached HEAD.");
|
|
800
|
+
}
|
|
801
|
+
currentBranch = branch.currentBranch;
|
|
802
|
+
validatePullRequestBranchRef(currentBranch, "headBranch");
|
|
803
|
+
repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env ?? process.env);
|
|
804
|
+
validateRelatedPullRequestLookupBounds(repository, currentBranch);
|
|
805
|
+
} catch {
|
|
806
|
+
return (
|
|
807
|
+
abortedRelatedPullRequest(options.signal, timeoutSignal) ??
|
|
808
|
+
unavailableRelatedPullRequest("The current GitHub repository or branch could not be resolved.")
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let token: string;
|
|
813
|
+
try {
|
|
814
|
+
token = (await resolveGitHubToken(options.env ?? process.env, { cwd: repoRoot, signal })).token;
|
|
815
|
+
} catch {
|
|
816
|
+
return (
|
|
817
|
+
abortedRelatedPullRequest(options.signal, timeoutSignal) ??
|
|
818
|
+
unavailableRelatedPullRequest("GitHub authentication is unavailable.")
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
let response: Response;
|
|
823
|
+
try {
|
|
824
|
+
const fetchImpl = requireFetchImplementation(options.fetchImpl ?? globalThis.fetch);
|
|
825
|
+
response = await fetchGitHubResponse(
|
|
826
|
+
fetchImpl,
|
|
827
|
+
relatedPullRequestLookupUrl(repository, currentBranch),
|
|
828
|
+
{ method: "GET", headers: gitHubJsonHeaders(token), signal },
|
|
829
|
+
"GitHub related pull request request failed",
|
|
830
|
+
token,
|
|
831
|
+
);
|
|
832
|
+
} catch {
|
|
833
|
+
return (
|
|
834
|
+
abortedRelatedPullRequest(options.signal, timeoutSignal) ??
|
|
835
|
+
unavailableRelatedPullRequest("The GitHub related pull request request failed.")
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (!response.ok) {
|
|
840
|
+
try {
|
|
841
|
+
await readGitHubResponseBody(
|
|
842
|
+
response,
|
|
843
|
+
signal,
|
|
844
|
+
`GitHub related pull request request failed with HTTP ${response.status}: unable to read error response`,
|
|
845
|
+
token,
|
|
846
|
+
);
|
|
847
|
+
} catch {
|
|
848
|
+
return (
|
|
849
|
+
abortedRelatedPullRequest(options.signal, timeoutSignal) ??
|
|
850
|
+
unavailableRelatedPullRequest("The GitHub related pull request response could not be read.")
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
return unavailableRelatedPullRequest(`GitHub related pull request lookup returned HTTP ${response.status}.`);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
let payload: unknown[];
|
|
857
|
+
try {
|
|
858
|
+
payload = await readGitHubJsonArray(
|
|
859
|
+
response,
|
|
860
|
+
signal,
|
|
861
|
+
token,
|
|
862
|
+
"GitHub related pull request response",
|
|
863
|
+
"GitHub related pull request response could not be read",
|
|
864
|
+
);
|
|
865
|
+
} catch {
|
|
866
|
+
return (
|
|
867
|
+
abortedRelatedPullRequest(options.signal, timeoutSignal) ??
|
|
868
|
+
unavailableRelatedPullRequest("The GitHub related pull request response was invalid.")
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (payload.length === 0) return { status: "none" };
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
return {
|
|
876
|
+
status: "found",
|
|
877
|
+
pullRequest: relatedPullRequestDetailsFromPayload(repository, currentBranch, payload[0]),
|
|
878
|
+
};
|
|
879
|
+
} catch {
|
|
880
|
+
return unavailableRelatedPullRequest("The GitHub related pull request response was invalid.");
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export async function lookupRelatedPullRequest(
|
|
885
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
886
|
+
ctx: GitCommandContext,
|
|
887
|
+
options: RelatedPullRequestLookupOptions = {},
|
|
888
|
+
): Promise<RelatedPullRequest> {
|
|
889
|
+
const timeoutMs = options.timeoutMs ?? GITHUB_RELATED_PR_TIMEOUT_MS;
|
|
890
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
891
|
+
return unavailableRelatedPullRequest("Related pull request lookup timeout is invalid.");
|
|
892
|
+
}
|
|
893
|
+
if (options.signal?.aborted) {
|
|
894
|
+
return unavailableRelatedPullRequest("Related pull request lookup was cancelled.");
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// AbortSignal.timeout() uses an unref'ed timer, which can let Node exit while fetch is still pending.
|
|
898
|
+
const timeoutController = new AbortController();
|
|
899
|
+
const timeoutHandle = setTimeout(timeoutController.abort.bind(timeoutController), timeoutMs);
|
|
900
|
+
const timeoutSignal = timeoutController.signal;
|
|
901
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
902
|
+
|
|
903
|
+
try {
|
|
904
|
+
return await lookupRelatedPullRequestWithSignal(pi, ctx, options, signal, timeoutSignal);
|
|
905
|
+
} finally {
|
|
906
|
+
clearTimeout(timeoutHandle);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
607
910
|
export async function ensureGitHubBranchExists(
|
|
608
911
|
repository: GitHubRepository,
|
|
609
912
|
branchName: string,
|
|
@@ -4,20 +4,22 @@ import {
|
|
|
4
4
|
BRANCH_STATUS_TOOL_NAME,
|
|
5
5
|
CHANGE_BRANCH_TOOL_NAME,
|
|
6
6
|
CREATE_BRANCH_TOOL_NAME,
|
|
7
|
+
PULL_BRANCH_TOOL_NAME,
|
|
7
8
|
PULL_REQUEST_TOOL_NAME,
|
|
8
9
|
PUSH_BRANCH_TOOL_NAME,
|
|
9
10
|
} from "../constants.ts";
|
|
10
11
|
import {
|
|
11
12
|
changeExistingLocalBranch,
|
|
12
13
|
createLocalBranch,
|
|
13
|
-
getBranchStatus,
|
|
14
14
|
getGitRoot,
|
|
15
15
|
getLocalBranchCommit,
|
|
16
16
|
localBranchExists,
|
|
17
|
+
pullCurrentBranch,
|
|
17
18
|
pushCurrentBranch,
|
|
18
19
|
validateBranchName,
|
|
19
20
|
withRepositoryMutationQueue,
|
|
20
21
|
} from "../git.ts";
|
|
22
|
+
import { collectGitContext, formatGitContext } from "../git-context.ts";
|
|
21
23
|
import {
|
|
22
24
|
createGitHubPullRequest,
|
|
23
25
|
ensureGitHubBranchExists,
|
|
@@ -27,7 +29,7 @@ import {
|
|
|
27
29
|
resolveGitHubToken,
|
|
28
30
|
validatePullRequestBranchRef,
|
|
29
31
|
} from "../github.ts";
|
|
30
|
-
import type {
|
|
32
|
+
import type { ChangeBranchDetails, GitContextDetails, PullRequestDetails } from "../types.ts";
|
|
31
33
|
|
|
32
34
|
const EmptyParametersSchema = Type.Object({}, { additionalProperties: false });
|
|
33
35
|
|
|
@@ -62,17 +64,8 @@ export interface BranchMeToolOptions {
|
|
|
62
64
|
fetchImpl?: typeof fetch;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
function
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function formatBranchStatus(details: BranchStatusDetails): string {
|
|
70
|
-
const branch = details.detached ? "detached HEAD" : details.currentBranch ?? "unknown branch";
|
|
71
|
-
const tree = details.hasChanges ? "dirty" : "clean";
|
|
72
|
-
const upstream = details.upstream ? `upstream ${details.upstream}` : "no upstream";
|
|
73
|
-
const counts = details.ahead === null || details.behind === null ? "ahead/behind unavailable" : `ahead ${details.ahead}, behind ${details.behind}`;
|
|
74
|
-
const warning = details.warnings?.length ? `; warning: ${details.warnings.join("; ")}` : "";
|
|
75
|
-
return `BranchMe status: ${branch}; ${tree}; ${upstream}; ${counts}; GitHub ${repositoryText(details)}${warning}.`;
|
|
67
|
+
export function formatBranchStatus(details: GitContextDetails): string {
|
|
68
|
+
return formatGitContext(details, "refresh");
|
|
76
69
|
}
|
|
77
70
|
|
|
78
71
|
export function formatChangeBranch(details: ChangeBranchDetails): string {
|
|
@@ -134,20 +127,25 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
134
127
|
pi.registerTool({
|
|
135
128
|
name: BRANCH_STATUS_TOOL_NAME,
|
|
136
129
|
label: "Branch Status",
|
|
137
|
-
description: "branch_status
|
|
138
|
-
promptSnippet: "branch_status:
|
|
130
|
+
description: "branch_status explicitly refreshes the current Git repository snapshot, including branch, working tree, unstaged changes, related pull request, and recent commits. branch_status is read-only and never mutates files, Git state, or GitHub state.",
|
|
131
|
+
promptSnippet: "branch_status: explicitly refresh current-repository branch, working tree, changes, related PR, and recent commits without mutation",
|
|
139
132
|
promptGuidelines: [
|
|
140
|
-
"Use branch_status
|
|
141
|
-
"Use branch_status
|
|
133
|
+
"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.",
|
|
134
|
+
"Use branch_status as a read-only refresh; branch_status never mutates files, Git state, or GitHub state.",
|
|
142
135
|
],
|
|
143
136
|
parameters: EmptyParametersSchema,
|
|
144
137
|
async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
|
|
145
|
-
const details = await
|
|
138
|
+
const details = await collectGitContext(pi, ctx, {
|
|
139
|
+
signal,
|
|
140
|
+
env: options.env,
|
|
141
|
+
fetchImpl: options.fetchImpl,
|
|
142
|
+
});
|
|
146
143
|
|
|
147
144
|
try {
|
|
148
|
-
details.githubRepository
|
|
149
|
-
} catch {
|
|
150
|
-
|
|
145
|
+
details.githubRepository ??= await resolveGitHubRepository(pi, ctx, signal, options.env);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (signal?.aborted) throw error;
|
|
148
|
+
// Repository resolution is optional compatibility metadata; context collection is authoritative.
|
|
151
149
|
}
|
|
152
150
|
|
|
153
151
|
return {
|
|
@@ -201,6 +199,26 @@ export function registerBranchMeTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
201
199
|
},
|
|
202
200
|
});
|
|
203
201
|
|
|
202
|
+
pi.registerTool({
|
|
203
|
+
name: PULL_BRANCH_TOOL_NAME,
|
|
204
|
+
label: "Pull Branch",
|
|
205
|
+
description: "pull_branch updates the current branch from its configured upstream with git pull --ff-only --no-rebase --no-autostash. pull_branch requires a clean working tree and never rebases, creates merge commits, force-updates, stashes, stages, or commits.",
|
|
206
|
+
promptSnippet: "pull_branch: fast-forward the clean current branch from its configured upstream without rebasing",
|
|
207
|
+
promptGuidelines: [
|
|
208
|
+
"Use pull_branch only when the user explicitly wants to update the current branch from its configured upstream.",
|
|
209
|
+
"Use pull_branch only on a clean working tree with an upstream; pull_branch has no branchName, remote, force, or rebase parameters.",
|
|
210
|
+
"Do not call pull_branch in the same tool batch as change_branch or create_branch; call pull_branch after change_branch completes, then call create_branch after pull_branch completes.",
|
|
211
|
+
],
|
|
212
|
+
parameters: EmptyParametersSchema,
|
|
213
|
+
async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
|
|
214
|
+
const details = await pullCurrentBranch(pi, ctx, signal);
|
|
215
|
+
return {
|
|
216
|
+
content: [{ type: "text", text: `Pulled current branch ${details.currentBranch} with fast-forward-only semantics.` }],
|
|
217
|
+
details,
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
204
222
|
pi.registerTool({
|
|
205
223
|
name: PUSH_BRANCH_TOOL_NAME,
|
|
206
224
|
label: "Push Branch",
|
package/src/types.ts
CHANGED
|
@@ -13,6 +13,47 @@ export interface AheadBehindCount {
|
|
|
13
13
|
behind: number | null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface WorkingTreeDetails {
|
|
17
|
+
state: "clean" | "dirty";
|
|
18
|
+
staged: number;
|
|
19
|
+
unstaged: number;
|
|
20
|
+
untracked: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GitFileChange {
|
|
24
|
+
status: string;
|
|
25
|
+
path: string;
|
|
26
|
+
originalPath?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GitFileChangeSummary {
|
|
30
|
+
entries: GitFileChange[];
|
|
31
|
+
omitted: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RecentCommit {
|
|
35
|
+
hash: string;
|
|
36
|
+
shortHash: string;
|
|
37
|
+
date: string;
|
|
38
|
+
subject: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RelatedPullRequestDetails {
|
|
42
|
+
repository: GitHubRepository;
|
|
43
|
+
number: number;
|
|
44
|
+
url: string;
|
|
45
|
+
title: string;
|
|
46
|
+
state: string;
|
|
47
|
+
draft: boolean;
|
|
48
|
+
head: string;
|
|
49
|
+
base: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type RelatedPullRequest =
|
|
53
|
+
| { status: "found"; pullRequest: RelatedPullRequestDetails }
|
|
54
|
+
| { status: "none" }
|
|
55
|
+
| { status: "unavailable"; reason: string };
|
|
56
|
+
|
|
16
57
|
export interface BranchStatusDetails {
|
|
17
58
|
repoRoot: string;
|
|
18
59
|
currentBranch: string | null;
|
|
@@ -25,6 +66,13 @@ export interface BranchStatusDetails {
|
|
|
25
66
|
githubRepository?: GitHubRepository;
|
|
26
67
|
}
|
|
27
68
|
|
|
69
|
+
export interface GitContextDetails extends BranchStatusDetails {
|
|
70
|
+
workingTree: WorkingTreeDetails;
|
|
71
|
+
unstagedChanges: GitFileChangeSummary;
|
|
72
|
+
relatedPullRequest: RelatedPullRequest;
|
|
73
|
+
recentCommits: RecentCommit[];
|
|
74
|
+
}
|
|
75
|
+
|
|
28
76
|
export interface CreateBranchDetails {
|
|
29
77
|
repoRoot: string;
|
|
30
78
|
previousBranch: string;
|
|
@@ -39,6 +87,15 @@ export interface ChangeBranchDetails {
|
|
|
39
87
|
hasChangesBeforeSwitch: false;
|
|
40
88
|
}
|
|
41
89
|
|
|
90
|
+
export interface PullBranchDetails {
|
|
91
|
+
repoRoot: string;
|
|
92
|
+
currentBranch: string;
|
|
93
|
+
upstream: string;
|
|
94
|
+
remote: string;
|
|
95
|
+
remoteRef: string;
|
|
96
|
+
output: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
42
99
|
export interface PushBranchDetails {
|
|
43
100
|
repoRoot: string;
|
|
44
101
|
currentBranch: string;
|
package/src/ui/branchme-panel.ts
CHANGED
|
@@ -177,7 +177,7 @@ function sectionFooter(section: BranchMePanelSection, data: BranchMePanelData):
|
|
|
177
177
|
case "status":
|
|
178
178
|
return "status • current repository only • tools perform actions";
|
|
179
179
|
case "workflow":
|
|
180
|
-
return "workflow • inspect → change
|
|
180
|
+
return "workflow • inspect → change → pull → create → push → PR";
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
@@ -197,6 +197,7 @@ function sectionRows(section: BranchMePanelSection, data: BranchMePanelData): Pa
|
|
|
197
197
|
heading("WORKFLOW"),
|
|
198
198
|
workflowDetailRow("branch_status", "inspect"),
|
|
199
199
|
workflowDetailRow("change_branch", "existing local"),
|
|
200
|
+
workflowDetailRow("pull_branch", "fast-forward"),
|
|
200
201
|
workflowDetailRow("create_branch", "from HEAD"),
|
|
201
202
|
workflowDetailRow("push_branch", "current branch"),
|
|
202
203
|
workflowDetailRow("pull_request", "after push"),
|