1import type {
2 GitRunStackedActionResult,
3 GitStackedAction,
4 VcsStatusResult,
5} from "@t3tools/contracts";
6import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
7import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
8import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients";
9
10export type GitActionIconName = "commit" | "push" | "pr";
11
12export type GitDialogAction = "commit" | "push" | "create_pr";
13
14export interface GitActionMenuItem {
15 id: "commit" | "push" | "pr";
16 label: string;
17 disabled: boolean;
18 icon: GitActionIconName;
19 kind: "open_dialog";
20 dialogAction?: GitDialogAction;
21}
22
23export interface GitQuickAction {
24 label: string;
25 disabled: boolean;
26 kind: "run_action" | "run_pull" | "show_hint";
27 action?: GitStackedAction;
28 hint?: string;
29}
30
31export interface DefaultBranchActionDialogCopy {
32 title: string;
33 description: string;
34 continueLabel: string;
35}
36
37export interface GitActionProgressPresentation {
38 readonly status: string;
39 readonly output: string | null;
40 readonly startedAtMs: number | null;
41}
42
43export interface GitActionResultToastTiming {
44 readonly timeout: 0;
45 readonly dismissAfterVisibleMs: number | null;
46}
47
48export type DefaultBranchConfirmableAction =
49 | "push"
50 | "create_pr"
51 | "commit_push"
52 | "commit_push_pr";
53
54export const GIT_ACTION_SUCCESS_VISIBLE_MS = 10_000;
55
56export function resolveGitActionResultToastTiming(
57 type: "error" | "success",
58): GitActionResultToastTiming {
59 return {
60 timeout: 0,
61 dismissAfterVisibleMs: type === "success" ? GIT_ACTION_SUCCESS_VISIBLE_MS : null,
62 };
63}
64
65function resolveChangeRequestTerminology(
66 gitStatus: VcsStatusResult | null,
67): ChangeRequestTerminology {
68 return sourceControlClients.get(gitStatus?.sourceControlProvider?.kind).changeRequest;
69}
70
71export function resolveGitActionProgressPresentation(input: {
72 readonly isRunning: boolean;
73 readonly operation: string | null;
74 readonly currentLabel: string | null;
75 readonly lastOutputLine: string | null;
76 readonly phaseStartedAtMs: number | null;
77 readonly hookStartedAtMs: number | null;
78}): GitActionProgressPresentation | null {
79 if (
80 !input.isRunning ||
81 (input.operation !== "run_change_request" && input.operation !== "pull")
82 ) {
83 return null;
84 }
85
86 const currentLabel = input.currentLabel?.trim();
87 const output = input.lastOutputLine?.trim();
88 const isPull = input.operation === "pull";
89 return {
90 status:
91 currentLabel && currentLabel !== "Running source control action"
92 ? currentLabel
93 : isPull
94 ? "Pulling latest changes..."
95 : "Starting source control action...",
96 output: !isPull && output ? output : null,
97 startedAtMs: isPull
98 ? input.phaseStartedAtMs
99 : (input.hookStartedAtMs ?? input.phaseStartedAtMs),
100 };
101}
102
103export function formatGitActionElapsed(startedAtMs: number | null, nowMs: number): string | null {
104 if (startedAtMs === null) {
105 return null;
106 }
107
108 const elapsedSeconds = Math.max(0, Math.floor((nowMs - startedAtMs) / 1_000));
109 if (elapsedSeconds < 60) {
110 return `${elapsedSeconds}s`;
111 }
112
113 const minutes = Math.floor(elapsedSeconds / 60);
114 const seconds = elapsedSeconds % 60;
115 return `${minutes}m ${seconds}s`;
116}
117
118export function buildGitActionProgressStages(input: {
119 action: GitStackedAction;
120 hasCustomCommitMessage: boolean;
121 hasWorkingTreeChanges: boolean;
122 pushTarget?: string;
123 featureBranch?: boolean;
124 shouldPushBeforePr?: boolean;
125 terminology?: ChangeRequestTerminology;
126}): string[] {
127 const terminology = input.terminology ?? sourceControlClients.get(undefined).changeRequest;
128 const branchStages = input.featureBranch ? ["Preparing feature ref..."] : [];
129 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
130 const prStages = [
131 `Preparing ${terminology.shortLabel}...`,
132 `Generating ${terminology.shortLabel} content...`,
133 `Creating ${terminology.singular}...`,
134 ];
135
136 if (input.action === "push") {
137 return [pushStage];
138 }
139 if (input.action === "create_pr") {
140 return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages;
141 }
142
143 const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges;
144 const commitStages = !shouldIncludeCommitStages
145 ? []
146 : input.hasCustomCommitMessage
147 ? ["Committing..."]
148 : ["Generating commit message...", "Committing..."];
149 if (input.action === "commit") {
150 return [...branchStages, ...commitStages];
151 }
152 if (input.action === "commit_push") {
153 return [...branchStages, ...commitStages, pushStage];
154 }
155 return [...branchStages, ...commitStages, pushStage, ...prStages];
156}
157
158export function buildMenuItems(
159 gitStatus: VcsStatusResult | null,
160 isBusy: boolean,
161 hasPrimaryRemote = true,
162): GitActionMenuItem[] {
163 if (!gitStatus) return [];
164 const terminology = resolveChangeRequestTerminology(gitStatus);
165
166 const hasBranch = gitStatus.refName !== null;
167 const hasChanges = gitStatus.hasWorkingTreeChanges;
168 const hasOpenPr = gitStatus.pr?.state === "open";
169 const isBehind = gitStatus.behindCount > 0;
170 const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0;
171 const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream;
172 const canCommit = !isBusy && hasChanges;
173 const canPush =
174 !isBusy &&
175 hasBranch &&
176 !isBehind &&
177 gitStatus.aheadCount > 0 &&
178 (gitStatus.hasUpstream || canPushWithoutUpstream);
179 const canCreatePr =
180 !isBusy &&
181 hasBranch &&
182 !hasOpenPr &&
183 hasDefaultBranchDelta &&
184 !isBehind &&
185 (gitStatus.hasUpstream || canPushWithoutUpstream);
186
187 const commitItem: GitActionMenuItem = {
188 id: "commit",
189 label: "Commit",
190 disabled: !canCommit,
191 icon: "commit",
192 kind: "open_dialog",
193 dialogAction: "commit",
194 };
195
196 if (!hasPrimaryRemote) {
197 return [commitItem];
198 }
199
200 const pushItem: GitActionMenuItem = {
201 id: "push",
202 label: "Push",
203 disabled: !canPush,
204 icon: "push",
205 kind: "open_dialog",
206 dialogAction: "push",
207 };
208
209 // An open change request is surfaced by the standalone attribution row, so
210 // the menu offers no change-request entry at all while one is open.
211 if (hasOpenPr) {
212 return [commitItem, pushItem];
213 }
214
215 return [
216 commitItem,
217 pushItem,
218 {
219 id: "pr",
220 label: `Create ${terminology.shortLabel}`,
221 disabled: !canCreatePr,
222 icon: "pr",
223 kind: "open_dialog",
224 dialogAction: "create_pr",
225 },
226 ];
227}
228
229export function resolveQuickAction(
230 gitStatus: VcsStatusResult | null,
231 isBusy: boolean,
232 isDefaultRef = false,
233 hasPrimaryRemote = true,
234): GitQuickAction {
235 if (isBusy) {
236 return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
237 }
238
239 if (!gitStatus) {
240 return {
241 label: "Commit",
242 disabled: true,
243 kind: "show_hint",
244 hint: "Git status is unavailable.",
245 };
246 }
247
248 const hasBranch = gitStatus.refName !== null;
249 const hasChanges = gitStatus.hasWorkingTreeChanges;
250 const hasOpenPr = gitStatus.pr?.state === "open";
251 const isAhead = gitStatus.aheadCount > 0;
252 const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0;
253 const isBehind = gitStatus.behindCount > 0;
254 const isDiverged = isAhead && isBehind;
255 const terminology = resolveChangeRequestTerminology(gitStatus);
256
257 if (!hasBranch) {
258 return {
259 label: "Commit",
260 disabled: true,
261 kind: "show_hint",
262 hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`,
263 };
264 }
265
266 if (hasChanges) {
267 if (!gitStatus.hasUpstream && !hasPrimaryRemote) {
268 return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
269 }
270 if (hasOpenPr || isDefaultRef) {
271 return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
272 }
273 return {
274 label: `Commit, push & ${terminology.shortLabel}`,
275 disabled: false,
276 kind: "run_action",
277 action: "commit_push_pr",
278 };
279 }
280
281 if (!gitStatus.hasUpstream) {
282 if (!hasPrimaryRemote) {
283 return {
284 label: "Commit",
285 disabled: true,
286 kind: "show_hint",
287 hint: "No changes to commit.",
288 };
289 }
290 if (!isAhead) {
291 if (hasOpenPr) {
292 return {
293 label: "Commit",
294 disabled: true,
295 kind: "show_hint",
296 hint: "Branch is up to date. No action needed.",
297 };
298 }
299 return {
300 label: "Push",
301 disabled: true,
302 kind: "show_hint",
303 hint: "No local commits to push.",
304 };
305 }
306 if (hasOpenPr || isDefaultRef) {
307 return {
308 label: "Push",
309 disabled: false,
310 kind: "run_action",
311 action: isDefaultRef ? "commit_push" : "push",
312 };
313 }
314 return {
315 label: `Push & create ${terminology.shortLabel}`,
316 disabled: false,
317 kind: "run_action",
318 action: "create_pr",
319 };
320 }
321
322 if (isDiverged) {
323 return {
324 label: "Sync ref",
325 disabled: true,
326 kind: "show_hint",
327 hint: "Branch has diverged from upstream. Rebase/merge first.",
328 };
329 }
330
331 if (isBehind) {
332 return {
333 label: "Pull",
334 disabled: false,
335 kind: "run_pull",
336 };
337 }
338
339 if (isAhead) {
340 if (hasOpenPr || isDefaultRef) {
341 return {
342 label: "Push",
343 disabled: false,
344 kind: "run_action",
345 action: isDefaultRef ? "commit_push" : "push",
346 };
347 }
348 return {
349 label: `Push & create ${terminology.shortLabel}`,
350 disabled: false,
351 kind: "run_action",
352 action: "create_pr",
353 };
354 }
355
356 // An open change request is surfaced by the standalone attribution row in the
357 // details panel, so the action button rests in its disabled up-to-date state.
358 if (hasOpenPr && gitStatus.hasUpstream) {
359 return {
360 label: "Commit",
361 disabled: true,
362 kind: "show_hint",
363 hint: "Branch is up to date. No action needed.",
364 };
365 }
366
367 if (hasDefaultBranchDelta && !isDefaultRef) {
368 return {
369 label: `Create ${terminology.shortLabel}`,
370 disabled: false,
371 kind: "run_action",
372 action: "create_pr",
373 };
374 }
375
376 return {
377 label: "Commit",
378 disabled: true,
379 kind: "show_hint",
380 hint: "Branch is up to date. No action needed.",
381 };
382}
383
384export function requiresDefaultBranchConfirmation(
385 action: GitStackedAction,
386 isDefaultRef: boolean,
387): boolean {
388 if (!isDefaultRef) return false;
389 return (
390 action === "push" ||
391 action === "create_pr" ||
392 action === "commit_push" ||
393 action === "commit_push_pr"
394 );
395}
396
397export function resolveDefaultBranchActionDialogCopy(input: {
398 action: DefaultBranchConfirmableAction;
399 branchName: string;
400 includesCommit: boolean;
401 terminology?: ChangeRequestTerminology;
402}): DefaultBranchActionDialogCopy {
403 const branchLabel = input.branchName;
404 const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`;
405 const terminology = input.terminology ?? sourceControlClients.get(undefined).changeRequest;
406
407 if (input.action === "push" || input.action === "commit_push") {
408 if (input.includesCommit) {
409 return {
410 title: "Commit & push to default ref?",
411 description: `This action will commit and push changes${suffix}`,
412 continueLabel: `Commit & push to ${branchLabel}`,
413 };
414 }
415 return {
416 title: "Push to default ref?",
417 description: `This action will push local commits${suffix}`,
418 continueLabel: `Push to ${branchLabel}`,
419 };
420 }
421
422 if (input.includesCommit) {
423 return {
424 title: `Commit, push & create ${terminology.shortLabel} from default ref?`,
425 description: `This action will commit, push, and create a ${terminology.singular}${suffix}`,
426 continueLabel: `Commit, push & create ${terminology.shortLabel}`,
427 };
428 }
429 return {
430 title: `Push & create ${terminology.shortLabel} from default ref?`,
431 description: `This action will push local commits and create a ${terminology.singular}${suffix}`,
432 continueLabel: `Push & create ${terminology.shortLabel}`,
433 };
434}
435
436export function resolveThreadBranchUpdate(
437 result: GitRunStackedActionResult,
438): { branch: string } | null {
439 if (result.branch.status !== "created" || !result.branch.name) {
440 return null;
441 }
442
443 return {
444 branch: result.branch.name,
445 };
446}
447
448export function resolveThreadBranchMetadataPatch(
449 branch: string | null,
450 expectedBranch: string | null,
451): {
452 branch: string | null;
453 expectedBranch: string | null;
454} {
455 return { branch, expectedBranch };
456}
457
458export function resolveLiveThreadBranchUpdate(input: {
459 threadBranch: string | null;
460 gitStatus: VcsStatusResult | null;
461}): { branch: string | null } | null {
462 if (!input.gitStatus) {
463 return null;
464 }
465
466 if (input.gitStatus.refName === null && input.threadBranch !== null) {
467 return null;
468 }
469
470 if (input.threadBranch === input.gitStatus.refName) {
471 return null;
472 }
473
474 if (
475 input.threadBranch !== null &&
476 input.gitStatus.refName !== null &&
477 !isTemporaryWorktreeBranch(input.threadBranch) &&
478 isTemporaryWorktreeBranch(input.gitStatus.refName)
479 ) {
480 return null;
481 }
482
483 return {
484 branch: input.gitStatus.refName,
485 };
486}
487
488// Re-export from shared for backwards compatibility in this module's exports
489export { resolveAutoFeatureBranchName } from "@t3tools/shared/git";