@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/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 { getGitRoot, getOriginUrl, validateBranchNameInput, type GitCommandContext } from "./git.ts";
13
- import type { GitHubRepository, PullRequestDetails, PullRequestInput } from "./types.ts";
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 { BranchStatusDetails, ChangeBranchDetails, PullRequestDetails } from "../types.ts";
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 repositoryText(details: BranchStatusDetails): string {
66
- return details.githubRepository ? repositoryLabel(details.githubRepository) : "not resolved";
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 inspects the current git repository, branch, upstream, dirty state, ahead/behind counts, and GitHub repository if available. branch_status is read-only.",
138
- promptSnippet: "branch_status: inspect current-repository git branch status without mutating files or git state",
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 before change_branch, create_branch, push_branch, or pull_request when the user asks about the current branch state.",
141
- "Use branch_status only for read-only inspection; branch_status never creates branches, pushes, commits, stages, or edits files.",
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 getBranchStatus(pi, ctx, signal);
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 = await resolveGitHubRepository(pi, ctx, signal, options.env);
149
- } catch {
150
- // Repository resolution is optional for branch_status; mutation tools fail closed.
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;
@@ -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/create → push → PR";
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"),