494 lines · 13.2 KB · TypeScript
RawDownload
import type {  GitRunStackedActionResult,  GitStackedAction,  VcsStatusResult,} from "@t3tools/contracts";import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";import {  DEFAULT_CHANGE_REQUEST_TERMINOLOGY,  getChangeRequestTerminology,  type ChangeRequestTerminology,} from "../sourceControlPresentation"; export type GitActionIconName = "commit" | "push" | "pr"; export type GitDialogAction = "commit" | "push" | "create_pr"; export interface GitActionMenuItem {  id: "commit" | "push" | "pr";  label: string;  disabled: boolean;  icon: GitActionIconName;  kind: "open_dialog";  dialogAction?: GitDialogAction;} export interface GitQuickAction {  label: string;  disabled: boolean;  kind: "run_action" | "run_pull" | "open_publish" | "show_hint";  action?: GitStackedAction;  hint?: string;} export interface DefaultBranchActionDialogCopy {  title: string;  description: string;  continueLabel: string;} export interface GitActionProgressPresentation {  readonly status: string;  readonly output: string | null;  readonly startedAtMs: number | null;} export interface GitActionResultToastTiming {  readonly timeout: 0;  readonly dismissAfterVisibleMs: number | null;} export type DefaultBranchConfirmableAction =  | "push"  | "create_pr"  | "commit_push"  | "commit_push_pr"; export const GIT_ACTION_SUCCESS_VISIBLE_MS = 10_000; export function resolveGitActionResultToastTiming(  type: "error" | "success",): GitActionResultToastTiming {  return {    timeout: 0,    dismissAfterVisibleMs: type === "success" ? GIT_ACTION_SUCCESS_VISIBLE_MS : null,  };} function resolveChangeRequestTerminology(  gitStatus: VcsStatusResult | null,): ChangeRequestTerminology {  return gitStatus?.sourceControlProvider    ? getChangeRequestTerminology(gitStatus.sourceControlProvider)    : DEFAULT_CHANGE_REQUEST_TERMINOLOGY;} export function resolveGitActionProgressPresentation(input: {  readonly isRunning: boolean;  readonly operation: string | null;  readonly currentLabel: string | null;  readonly lastOutputLine: string | null;  readonly phaseStartedAtMs: number | null;  readonly hookStartedAtMs: number | null;}): GitActionProgressPresentation | null {  if (    !input.isRunning ||    (input.operation !== "run_change_request" && input.operation !== "pull")  ) {    return null;  }   const currentLabel = input.currentLabel?.trim();  const output = input.lastOutputLine?.trim();  const isPull = input.operation === "pull";  return {    status:      currentLabel && currentLabel !== "Running source control action"        ? currentLabel        : isPull          ? "Pulling latest changes..."          : "Starting source control action...",    output: !isPull && output ? output : null,    startedAtMs: isPull      ? input.phaseStartedAtMs      : (input.hookStartedAtMs ?? input.phaseStartedAtMs),  };} export function formatGitActionElapsed(startedAtMs: number | null, nowMs: number): string | null {  if (startedAtMs === null) {    return null;  }   const elapsedSeconds = Math.max(0, Math.floor((nowMs - startedAtMs) / 1_000));  if (elapsedSeconds < 60) {    return `${elapsedSeconds}s`;  }   const minutes = Math.floor(elapsedSeconds / 60);  const seconds = elapsedSeconds % 60;  return `${minutes}m ${seconds}s`;} export function buildGitActionProgressStages(input: {  action: GitStackedAction;  hasCustomCommitMessage: boolean;  hasWorkingTreeChanges: boolean;  pushTarget?: string;  featureBranch?: boolean;  shouldPushBeforePr?: boolean;  terminology?: ChangeRequestTerminology;}): string[] {  const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY;  const branchStages = input.featureBranch ? ["Preparing feature ref..."] : [];  const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";  const prStages = [    `Preparing ${terminology.shortLabel}...`,    `Generating ${terminology.shortLabel} content...`,    `Creating ${terminology.singular}...`,  ];   if (input.action === "push") {    return [pushStage];  }  if (input.action === "create_pr") {    return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages;  }   const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges;  const commitStages = !shouldIncludeCommitStages    ? []    : input.hasCustomCommitMessage      ? ["Committing..."]      : ["Generating commit message...", "Committing..."];  if (input.action === "commit") {    return [...branchStages, ...commitStages];  }  if (input.action === "commit_push") {    return [...branchStages, ...commitStages, pushStage];  }  return [...branchStages, ...commitStages, pushStage, ...prStages];} export function buildMenuItems(  gitStatus: VcsStatusResult | null,  isBusy: boolean,  hasPrimaryRemote = true,): GitActionMenuItem[] {  if (!gitStatus) return [];  const terminology = resolveChangeRequestTerminology(gitStatus);   const hasBranch = gitStatus.refName !== null;  const hasChanges = gitStatus.hasWorkingTreeChanges;  const hasOpenPr = gitStatus.pr?.state === "open";  const isBehind = gitStatus.behindCount > 0;  const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0;  const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream;  const canCommit = !isBusy && hasChanges;  const canPush =    !isBusy &&    hasBranch &&    !isBehind &&    gitStatus.aheadCount > 0 &&    (gitStatus.hasUpstream || canPushWithoutUpstream);  const canCreatePr =    !isBusy &&    hasBranch &&    !hasChanges &&    !hasOpenPr &&    hasDefaultBranchDelta &&    !isBehind &&    (gitStatus.hasUpstream || canPushWithoutUpstream);   const commitItem: GitActionMenuItem = {    id: "commit",    label: "Commit",    disabled: !canCommit,    icon: "commit",    kind: "open_dialog",    dialogAction: "commit",  };   if (!hasPrimaryRemote) {    return [commitItem];  }   const pushItem: GitActionMenuItem = {    id: "push",    label: "Push",    disabled: !canPush,    icon: "push",    kind: "open_dialog",    dialogAction: "push",  };   // An open change request is surfaced by the standalone attribution row, so  // the menu offers no change-request entry at all while one is open.  if (hasOpenPr) {    return [commitItem, pushItem];  }   return [    commitItem,    pushItem,    {      id: "pr",      label: `Create ${terminology.shortLabel}`,      disabled: !canCreatePr,      icon: "pr",      kind: "open_dialog",      dialogAction: "create_pr",    },  ];} export function resolveQuickAction(  gitStatus: VcsStatusResult | null,  isBusy: boolean,  isDefaultRef = false,  hasPrimaryRemote = true,): GitQuickAction {  if (isBusy) {    return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };  }   if (!gitStatus) {    return {      label: "Commit",      disabled: true,      kind: "show_hint",      hint: "Git status is unavailable.",    };  }   const hasBranch = gitStatus.refName !== null;  const hasChanges = gitStatus.hasWorkingTreeChanges;  const hasOpenPr = gitStatus.pr?.state === "open";  const isAhead = gitStatus.aheadCount > 0;  const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0;  const isBehind = gitStatus.behindCount > 0;  const isDiverged = isAhead && isBehind;  const terminology = resolveChangeRequestTerminology(gitStatus);   if (!hasBranch) {    return {      label: "Commit",      disabled: true,      kind: "show_hint",      hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`,    };  }   if (hasChanges) {    if (!gitStatus.hasUpstream && !hasPrimaryRemote) {      return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };    }    if (hasOpenPr || isDefaultRef) {      return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };    }    return {      label: `Commit, push & ${terminology.shortLabel}`,      disabled: false,      kind: "run_action",      action: "commit_push_pr",    };  }   if (!gitStatus.hasUpstream) {    if (!hasPrimaryRemote) {      return {        label: "Publish repository",        disabled: false,        kind: "open_publish",      };    }    if (!isAhead) {      if (hasOpenPr) {        return {          label: "Commit",          disabled: true,          kind: "show_hint",          hint: "Branch is up to date. No action needed.",        };      }      return {        label: "Push",        disabled: true,        kind: "show_hint",        hint: "No local commits to push.",      };    }    if (hasOpenPr || isDefaultRef) {      return {        label: "Push",        disabled: false,        kind: "run_action",        action: isDefaultRef ? "commit_push" : "push",      };    }    return {      label: `Push & create ${terminology.shortLabel}`,      disabled: false,      kind: "run_action",      action: "create_pr",    };  }   if (isDiverged) {    return {      label: "Sync ref",      disabled: true,      kind: "show_hint",      hint: "Branch has diverged from upstream. Rebase/merge first.",    };  }   if (isBehind) {    return {      label: "Pull",      disabled: false,      kind: "run_pull",    };  }   if (isAhead) {    if (hasOpenPr || isDefaultRef) {      return {        label: "Push",        disabled: false,        kind: "run_action",        action: isDefaultRef ? "commit_push" : "push",      };    }    return {      label: `Push & create ${terminology.shortLabel}`,      disabled: false,      kind: "run_action",      action: "create_pr",    };  }   // An open change request is surfaced by the standalone attribution row in the  // details panel, so the action button rests in its disabled up-to-date state.  if (hasOpenPr && gitStatus.hasUpstream) {    return {      label: "Commit",      disabled: true,      kind: "show_hint",      hint: "Branch is up to date. No action needed.",    };  }   if (hasDefaultBranchDelta && !isDefaultRef) {    return {      label: `Create ${terminology.shortLabel}`,      disabled: false,      kind: "run_action",      action: "create_pr",    };  }   return {    label: "Commit",    disabled: true,    kind: "show_hint",    hint: "Branch is up to date. No action needed.",  };} export function requiresDefaultBranchConfirmation(  action: GitStackedAction,  isDefaultRef: boolean,): boolean {  if (!isDefaultRef) return false;  return (    action === "push" ||    action === "create_pr" ||    action === "commit_push" ||    action === "commit_push_pr"  );} export function resolveDefaultBranchActionDialogCopy(input: {  action: DefaultBranchConfirmableAction;  branchName: string;  includesCommit: boolean;  terminology?: ChangeRequestTerminology;}): DefaultBranchActionDialogCopy {  const branchLabel = input.branchName;  const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`;  const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY;   if (input.action === "push" || input.action === "commit_push") {    if (input.includesCommit) {      return {        title: "Commit & push to default ref?",        description: `This action will commit and push changes${suffix}`,        continueLabel: `Commit & push to ${branchLabel}`,      };    }    return {      title: "Push to default ref?",      description: `This action will push local commits${suffix}`,      continueLabel: `Push to ${branchLabel}`,    };  }   if (input.includesCommit) {    return {      title: `Commit, push & create ${terminology.shortLabel} from default ref?`,      description: `This action will commit, push, and create a ${terminology.singular}${suffix}`,      continueLabel: `Commit, push & create ${terminology.shortLabel}`,    };  }  return {    title: `Push & create ${terminology.shortLabel} from default ref?`,    description: `This action will push local commits and create a ${terminology.singular}${suffix}`,    continueLabel: `Push & create ${terminology.shortLabel}`,  };} export function resolveThreadBranchUpdate(  result: GitRunStackedActionResult,): { branch: string } | null {  if (result.branch.status !== "created" || !result.branch.name) {    return null;  }   return {    branch: result.branch.name,  };} export function resolveThreadBranchMetadataPatch(  branch: string | null,  expectedBranch: string | null,): {  branch: string | null;  expectedBranch: string | null;} {  return { branch, expectedBranch };} export function resolveLiveThreadBranchUpdate(input: {  threadBranch: string | null;  gitStatus: VcsStatusResult | null;}): { branch: string | null } | null {  if (!input.gitStatus) {    return null;  }   if (input.gitStatus.refName === null && input.threadBranch !== null) {    return null;  }   if (input.threadBranch === input.gitStatus.refName) {    return null;  }   if (    input.threadBranch !== null &&    input.gitStatus.refName !== null &&    !isTemporaryWorktreeBranch(input.threadBranch) &&    isTemporaryWorktreeBranch(input.gitStatus.refName)  ) {    return null;  }   return {    branch: input.gitStatus.refName,  };} // Re-export from shared for backwards compatibility in this module's exportsexport { resolveAutoFeatureBranchName } from "@t3tools/shared/git";