| 1 | import type { |
| 2 | GitRunStackedActionResult, |
| 3 | GitStackedAction, |
| 4 | VcsStatusResult, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; |
| 7 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 8 | import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; |
| 9 | |
| 10 | export type GitActionIconName = "commit" | "push" | "pr"; |
| 11 | |
| 12 | export type GitDialogAction = "commit" | "push" | "create_pr"; |
| 13 | |
| 14 | export 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 | |
| 23 | export interface GitQuickAction { |
| 24 | label: string; |
| 25 | disabled: boolean; |
| 26 | kind: "run_action" | "run_pull" | "show_hint"; |
| 27 | action?: GitStackedAction; |
| 28 | hint?: string; |
| 29 | } |
| 30 | |
| 31 | export interface DefaultBranchActionDialogCopy { |
| 32 | title: string; |
| 33 | description: string; |
| 34 | continueLabel: string; |
| 35 | } |
| 36 | |
| 37 | export interface GitActionProgressPresentation { |
| 38 | readonly status: string; |
| 39 | readonly output: string | null; |
| 40 | readonly startedAtMs: number | null; |
| 41 | } |
| 42 | |
| 43 | export interface GitActionResultToastTiming { |
| 44 | readonly timeout: 0; |
| 45 | readonly dismissAfterVisibleMs: number | null; |
| 46 | } |
| 47 | |
| 48 | export type DefaultBranchConfirmableAction = |
| 49 | | "push" |
| 50 | | "create_pr" |
| 51 | | "commit_push" |
| 52 | | "commit_push_pr"; |
| 53 | |
| 54 | export const GIT_ACTION_SUCCESS_VISIBLE_MS = 10_000; |
| 55 | |
| 56 | export 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 | |
| 65 | function resolveChangeRequestTerminology( |
| 66 | gitStatus: VcsStatusResult | null, |
| 67 | ): ChangeRequestTerminology { |
| 68 | return sourceControlClients.get(gitStatus?.sourceControlProvider?.kind).changeRequest; |
| 69 | } |
| 70 | |
| 71 | export 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 | |
| 103 | export 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 | |
| 118 | export 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 | |
| 158 | export 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 | |
| 229 | export 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 | |
| 384 | export 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 | |
| 397 | export 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 | |
| 436 | export 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 | |
| 448 | export 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 | |
| 458 | export 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 |
| 489 | export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; |