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