1import type {
2 GitRunStackedActionInput,
3 GitStackedAction,
4 VcsStatusResult,
5} from "@t3tools/contracts";
6import { type ChangeRequestTerminology, sourceControlClients } from "../sourceControlClients.ts";
7
8export type GitActionIconName = "commit" | "push" | "pr";
9
10export type GitDialogAction = "commit" | "push" | "create_pr";
11
12export interface GitActionMenuItem {
13 id: "commit" | "push" | "pr";
14 label: string;
15 disabled: boolean;
16 icon: GitActionIconName;
17 kind: "open_dialog" | "open_pr";
18 dialogAction?: GitDialogAction;
19}
20
21export interface GitQuickAction {
22 label: string;
23 disabled: boolean;
24 kind: "run_action" | "run_pull" | "open_pr" | "show_hint";
25 action?: GitStackedAction;
26 hint?: string;
27}
28
29export interface DefaultBranchActionDialogCopy {
30 title: string;
31 description: string;
32 continueLabel: string;
33}
34
35export type DefaultBranchConfirmableAction =
36 | "push"
37 | "create_pr"
38 | "commit_push"
39 | "commit_push_pr";
40
41export type GitActionRequestInput = Pick<
42 GitRunStackedActionInput,
43 "action" | "commitMessage" | "featureBranch" | "filePaths"
44>;
45
46export function changeRequestTerminologyFor(
47 gitStatus: VcsStatusResult | null,
48): ChangeRequestTerminology {
49 return sourceControlClients.get(gitStatus?.sourceControlProvider?.kind).changeRequest;
50}
51
52export function buildMenuItems(
53 gitStatus: VcsStatusResult | null,
54 isBusy: boolean,
55 hasOriginRemote = true,
56): GitActionMenuItem[] {
57 if (!gitStatus) return [];
58 const terminology = changeRequestTerminologyFor(gitStatus);
59
60 const hasBranch = gitStatus.refName !== null;
61 const hasChanges = gitStatus.hasWorkingTreeChanges;
62 const hasOpenPr = gitStatus.pr?.state === "open";
63 const isBehind = gitStatus.behindCount > 0;
64 const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream;
65 const canCommit = !isBusy && hasChanges;
66 const canPush =
67 !isBusy &&
68 hasBranch &&
69 !hasChanges &&
70 !isBehind &&
71 gitStatus.aheadCount > 0 &&
72 (gitStatus.hasUpstream || canPushWithoutUpstream);
73 const canCreatePr =
74 !isBusy &&
75 hasBranch &&
76 !hasOpenPr &&
77 (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0 &&
78 !isBehind &&
79 (gitStatus.hasUpstream || canPushWithoutUpstream);
80 const canOpenPr = !isBusy && hasOpenPr;
81
82 return [
83 {
84 id: "commit",
85 label: "Commit",
86 disabled: !canCommit,
87 icon: "commit",
88 kind: "open_dialog",
89 dialogAction: "commit",
90 },
91 {
92 id: "push",
93 label: "Push",
94 disabled: !canPush,
95 icon: "push",
96 kind: "open_dialog",
97 dialogAction: "push",
98 },
99 hasOpenPr
100 ? {
101 id: "pr",
102 label: `View ${terminology.shortLabel}`,
103 disabled: !canOpenPr,
104 icon: "pr",
105 kind: "open_pr",
106 }
107 : {
108 id: "pr",
109 label: `Create ${terminology.shortLabel}`,
110 disabled: !canCreatePr,
111 icon: "pr",
112 kind: "open_dialog",
113 dialogAction: "create_pr",
114 },
115 ];
116}
117
118export function resolveQuickAction(
119 gitStatus: VcsStatusResult | null,
120 isBusy: boolean,
121 isDefaultBranch = false,
122 hasOriginRemote = true,
123): GitQuickAction {
124 if (isBusy) {
125 return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
126 }
127
128 if (!gitStatus) {
129 return {
130 label: "Commit",
131 disabled: true,
132 kind: "show_hint",
133 hint: "Git status is unavailable.",
134 };
135 }
136
137 const hasBranch = gitStatus.refName !== null;
138 const hasChanges = gitStatus.hasWorkingTreeChanges;
139 const hasOpenPr = gitStatus.pr?.state === "open";
140 const isAhead = gitStatus.aheadCount > 0;
141 const isBehind = gitStatus.behindCount > 0;
142 const isDiverged = isAhead && isBehind;
143 const terminology = changeRequestTerminologyFor(gitStatus);
144
145 if (!hasBranch) {
146 return {
147 label: "Commit",
148 disabled: true,
149 kind: "show_hint",
150 hint: `Create and checkout a branch before pushing or opening ${terminology.shortLabelWithArticle}.`,
151 };
152 }
153
154 if (hasChanges) {
155 if (isBehind || (!gitStatus.hasUpstream && !hasOriginRemote)) {
156 return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
157 }
158 if (hasOpenPr || isDefaultBranch) {
159 return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
160 }
161 return {
162 label: `Commit, push & ${terminology.shortLabel}`,
163 disabled: false,
164 kind: "run_action",
165 action: "commit_push_pr",
166 };
167 }
168
169 if (!gitStatus.hasUpstream) {
170 if (!hasOriginRemote) {
171 if (hasOpenPr && !isAhead) {
172 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
173 }
174 return {
175 label: "Push",
176 disabled: true,
177 kind: "show_hint",
178 hint: `Add an "origin" remote before pushing or creating ${terminology.shortLabelWithArticle}.`,
179 };
180 }
181 if (!isAhead) {
182 if (hasOpenPr) {
183 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
184 }
185 return {
186 label: "Push",
187 disabled: true,
188 kind: "show_hint",
189 hint: "No local commits to push.",
190 };
191 }
192 if (hasOpenPr || isDefaultBranch) {
193 return {
194 label: "Push",
195 disabled: false,
196 kind: "run_action",
197 action: isDefaultBranch ? "commit_push" : "push",
198 };
199 }
200 return {
201 label: `Push & create ${terminology.shortLabel}`,
202 disabled: false,
203 kind: "run_action",
204 action: "create_pr",
205 };
206 }
207
208 if (isDiverged) {
209 return {
210 label: "Sync branch",
211 disabled: true,
212 kind: "show_hint",
213 hint: "Branch has diverged from upstream. Rebase/merge first.",
214 };
215 }
216
217 if (isBehind) {
218 return {
219 label: "Pull",
220 disabled: false,
221 kind: "run_pull",
222 };
223 }
224
225 if (isAhead) {
226 if (hasOpenPr || isDefaultBranch) {
227 return {
228 label: "Push",
229 disabled: false,
230 kind: "run_action",
231 action: isDefaultBranch ? "commit_push" : "push",
232 };
233 }
234 return {
235 label: `Push & create ${terminology.shortLabel}`,
236 disabled: false,
237 kind: "run_action",
238 action: "create_pr",
239 };
240 }
241
242 if (hasOpenPr && gitStatus.hasUpstream) {
243 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
244 }
245
246 return {
247 label: "Commit",
248 disabled: true,
249 kind: "show_hint",
250 hint: "Branch is up to date. No action needed.",
251 };
252}
253
254export function getGitActionDisabledReason(input: {
255 item: GitActionMenuItem;
256 gitStatus: VcsStatusResult | null;
257 isBusy: boolean;
258 hasOriginRemote: boolean;
259}): string | null {
260 const { item, gitStatus, isBusy, hasOriginRemote } = input;
261 if (!item.disabled) return null;
262 if (isBusy) return "Git action in progress.";
263 if (!gitStatus) return "Git status is unavailable.";
264
265 const hasBranch = gitStatus.refName !== null;
266 const hasChanges = gitStatus.hasWorkingTreeChanges;
267 const hasOpenPr = gitStatus.pr?.state === "open";
268 const isAhead = gitStatus.aheadCount > 0;
269 const isBehind = gitStatus.behindCount > 0;
270 const terminology = changeRequestTerminologyFor(gitStatus);
271
272 if (item.id === "commit") {
273 if (!hasChanges) {
274 return "Worktree is clean. Make changes before committing.";
275 }
276 return "Commit is currently unavailable.";
277 }
278
279 if (item.id === "push") {
280 if (!hasBranch) {
281 return "Detached HEAD: checkout a branch before pushing.";
282 }
283 if (hasChanges) {
284 return "Commit or stash local changes before pushing.";
285 }
286 if (isBehind) {
287 return "Branch is behind upstream. Pull/rebase before pushing.";
288 }
289 if (!gitStatus.hasUpstream && !hasOriginRemote) {
290 return 'Add an "origin" remote before pushing.';
291 }
292 if (!isAhead) {
293 return "No local commits to push.";
294 }
295 return "Push is currently unavailable.";
296 }
297
298 if (hasOpenPr) {
299 return `View ${terminology.shortLabel} is currently unavailable.`;
300 }
301 if (!hasBranch) {
302 return `Detached HEAD: checkout a branch before creating ${terminology.shortLabelWithArticle}.`;
303 }
304 if (!gitStatus.hasUpstream && !hasOriginRemote) {
305 return `Add an "origin" remote before creating ${terminology.shortLabelWithArticle}.`;
306 }
307 if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) {
308 return `No local commits to include in ${terminology.shortLabelWithArticle}.`;
309 }
310 if (isBehind) {
311 return `Branch is behind upstream. Pull/rebase before creating ${terminology.shortLabelWithArticle}.`;
312 }
313 return `Create ${terminology.shortLabel} is currently unavailable.`;
314}
315
316export function requiresDefaultBranchConfirmation(
317 action: GitStackedAction,
318 isDefaultBranch: boolean,
319): boolean {
320 if (!isDefaultBranch) return false;
321 return (
322 action === "push" ||
323 action === "create_pr" ||
324 action === "commit_push" ||
325 action === "commit_push_pr"
326 );
327}
328
329export function resolveDefaultBranchActionDialogCopy(input: {
330 action: DefaultBranchConfirmableAction;
331 branchName: string;
332 includesCommit: boolean;
333 terminology: ChangeRequestTerminology;
334}): DefaultBranchActionDialogCopy {
335 const branchLabel = input.branchName;
336 const { shortLabel, shortLabelWithArticle } = input.terminology;
337 const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`;
338
339 if (input.action === "push" || input.action === "commit_push") {
340 if (input.includesCommit) {
341 return {
342 title: "Commit & push to default branch?",
343 description: `This action will commit and push changes${suffix}`,
344 continueLabel: `Commit & push to ${branchLabel}`,
345 };
346 }
347 return {
348 title: "Push to default branch?",
349 description: `This action will push local commits${suffix}`,
350 continueLabel: `Push to ${branchLabel}`,
351 };
352 }
353
354 if (input.includesCommit) {
355 return {
356 title: `Commit, push & create ${shortLabel} from default branch?`,
357 description: `This action will commit, push, and create ${shortLabelWithArticle}${suffix}`,
358 continueLabel: `Commit, push & create ${shortLabel}`,
359 };
360 }
361 return {
362 title: `Push & create ${shortLabel} from default branch?`,
363 description: `This action will push local commits and create ${shortLabelWithArticle}${suffix}`,
364 continueLabel: `Push & create ${shortLabel}`,
365 };
366}