import type { GitActionProgressEvent, GitCreateBranchInput, GitCreateBranchResult, GitCreateWorktreeInput, GitCreateWorktreeResult, GitCheckoutInput, GitCheckoutResult, GitPullInput, GitPullResult, GitRunStackedActionInput, GitRunStackedActionResult, GitStackedAction, GitStatusResult,} from "@t3tools/contracts";import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; import { buildGitActionProgressStages } from "./gitActions";import type { WsRpcClient } from "./wsRpcClient"; export type GitActionOperation = | "refresh_status" | "run_stacked_action" | "pull" | "checkout" | "create_branch" | "create_worktree" | "init"; export interface GitActionState { readonly isRunning: boolean; readonly operation: GitActionOperation | null; readonly actionId: string | null; readonly action: GitStackedAction | null; readonly currentLabel: string | null; readonly currentPhaseLabel: string | null; readonly hookName: string | null; readonly lastOutputLine: string | null; readonly phaseStartedAtMs: number | null; readonly hookStartedAtMs: number | null; readonly error: string | null;} export interface GitActionTarget { readonly environmentId: string | null; readonly cwd: string | null;} export type GitActionClient = Pick< WsRpcClient["git"], | "refreshStatus" | "runStackedAction" | "pull" | "checkout" | "createBranch" | "createWorktree" | "init">; export const EMPTY_GIT_ACTION_STATE = Object.freeze<GitActionState>({ isRunning: false, operation: null, actionId: null, action: null, currentLabel: null, currentPhaseLabel: null, hookName: null, lastOutputLine: null, phaseStartedAtMs: null, hookStartedAtMs: null, error: null,}); const knownGitActionKeys = new Set<string>(); export const gitActionStateAtom = Atom.family((key: string) => { knownGitActionKeys.add(key); return Atom.make(EMPTY_GIT_ACTION_STATE).pipe( Atom.keepAlive, Atom.withLabel(`git-action:${key}`), );}); export const EMPTY_GIT_ACTION_ATOM = Atom.make(EMPTY_GIT_ACTION_STATE).pipe( Atom.keepAlive, Atom.withLabel("git-action:null"),); export function getGitActionTargetKey(target: GitActionTarget): string | null { if (target.environmentId === null || target.cwd === null) { return null; } return `${target.environmentId}:${target.cwd}`;} export function applyGitActionProgressEvent( current: GitActionState, event: GitActionProgressEvent,): GitActionState { const now = Date.now(); switch (event.kind) { case "action_started": return { ...current, isRunning: true, actionId: event.actionId, action: event.action, operation: "run_stacked_action", phaseStartedAtMs: now, hookStartedAtMs: null, hookName: null, lastOutputLine: null, error: null, }; case "phase_started": return { ...current, isRunning: true, actionId: event.actionId, action: event.action, operation: "run_stacked_action", currentLabel: event.label, currentPhaseLabel: event.label, phaseStartedAtMs: now, hookStartedAtMs: null, hookName: null, lastOutputLine: null, error: null, }; case "hook_started": return { ...current, isRunning: true, actionId: event.actionId, action: event.action, operation: "run_stacked_action", currentLabel: `Running ${event.hookName}...`, hookName: event.hookName, hookStartedAtMs: now, lastOutputLine: null, error: null, }; case "hook_output": return { ...current, isRunning: true, actionId: event.actionId, action: event.action, operation: "run_stacked_action", lastOutputLine: event.text, error: null, }; case "hook_finished": return { ...current, isRunning: true, actionId: event.actionId, action: event.action, operation: "run_stacked_action", currentLabel: current.currentPhaseLabel, hookName: null, hookStartedAtMs: null, lastOutputLine: null, error: null, }; case "action_finished": return { ...current, isRunning: false, actionId: event.actionId, action: event.action, operation: "run_stacked_action", error: null, }; case "action_failed": return { ...EMPTY_GIT_ACTION_STATE, actionId: event.actionId, action: event.action, operation: "run_stacked_action", error: event.message, }; }} export interface GitActionManagerConfig { readonly getRegistry: () => AtomRegistry.AtomRegistry; readonly getClient: (environmentId: string) => GitActionClient | null; readonly getActionId?: () => string;} export function createGitActionManager(config: GitActionManagerConfig) { function setState(targetKey: string, nextState: GitActionState): void { config.getRegistry().set(gitActionStateAtom(targetKey), nextState); } function startOperation( targetKey: string, input: { readonly operation: GitActionOperation; readonly actionId?: string; readonly action?: GitStackedAction; readonly label: string; }, ): void { setState(targetKey, { isRunning: true, operation: input.operation, actionId: input.actionId ?? null, action: input.action ?? null, currentLabel: input.label, currentPhaseLabel: input.label, hookName: null, lastOutputLine: null, phaseStartedAtMs: Date.now(), hookStartedAtMs: null, error: null, }); } function finishOperation(targetKey: string): void { setState(targetKey, EMPTY_GIT_ACTION_STATE); } function failOperation( targetKey: string, error: unknown, input: { readonly operation: GitActionOperation; readonly actionId?: string; readonly action?: GitStackedAction; }, ): void { setState(targetKey, { ...EMPTY_GIT_ACTION_STATE, operation: input.operation, actionId: input.actionId ?? null, action: input.action ?? null, error: error instanceof Error ? error.message : "Git action failed.", }); } async function runOperation<TResult>( target: GitActionTarget, input: { readonly operation: GitActionOperation; readonly label: string; readonly actionId?: string; readonly action?: GitStackedAction; readonly client?: GitActionClient | undefined; readonly execute: (client: GitActionClient) => Promise<TResult>; }, ): Promise<TResult | null> { const targetKey = getGitActionTargetKey(target); if (targetKey === null || target.environmentId === null || target.cwd === null) { return null; } const resolved = input.client ?? config.getClient(target.environmentId); if (!resolved) { return null; } startOperation(targetKey, input); try { const result = await input.execute(resolved); finishOperation(targetKey); return result; } catch (error) { failOperation(targetKey, error, input); throw error; } } function getSnapshot(target: GitActionTarget): GitActionState { const targetKey = getGitActionTargetKey(target); if (targetKey === null) { return EMPTY_GIT_ACTION_STATE; } return config.getRegistry().get(gitActionStateAtom(targetKey)); } async function refreshStatus( target: GitActionTarget, client?: GitActionClient, options?: { readonly quiet?: boolean }, ): Promise<Awaited<ReturnType<GitActionClient["refreshStatus"]>> | null> { if (options?.quiet) { if (target.environmentId === null || target.cwd === null) { return null; } const resolved = client ?? config.getClient(target.environmentId); return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null; } return runOperation(target, { operation: "refresh_status", label: "Refreshing git status", client, execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }), }); } async function pull( target: GitActionTarget, client?: GitActionClient, options?: { readonly label?: string }, ): Promise<GitPullResult | null> { return runOperation(target, { operation: "pull", label: options?.label ?? "Pulling latest changes", client, execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies GitPullInput), }); } async function checkout( target: GitActionTarget, input: Omit<GitCheckoutInput, "cwd">, client?: GitActionClient, options?: { readonly label?: string }, ): Promise<GitCheckoutResult | null> { return runOperation(target, { operation: "checkout", label: options?.label ?? "Checking out branch", client, execute: (resolved) => resolved.checkout({ cwd: target.cwd!, ...input }), }); } async function createBranch( target: GitActionTarget, input: Omit<GitCreateBranchInput, "cwd">, client?: GitActionClient, options?: { readonly label?: string }, ): Promise<GitCreateBranchResult | null> { return runOperation(target, { operation: "create_branch", label: options?.label ?? "Creating branch", client, execute: (resolved) => resolved.createBranch({ cwd: target.cwd!, ...input }), }); } async function createWorktree( target: GitActionTarget, input: Omit<GitCreateWorktreeInput, "cwd">, client?: GitActionClient, options?: { readonly label?: string }, ): Promise<GitCreateWorktreeResult | null> { return runOperation(target, { operation: "create_worktree", label: options?.label ?? "Creating worktree", client, execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }), }); } async function init( target: GitActionTarget, client?: GitActionClient, options?: { readonly label?: string }, ): Promise<Awaited<ReturnType<GitActionClient["init"]>> | null> { return runOperation(target, { operation: "init", label: options?.label ?? "Initializing git repository", client, execute: (resolved) => resolved.init({ cwd: target.cwd! }), }); } async function runStackedAction( target: GitActionTarget, input: Omit<GitRunStackedActionInput, "cwd" | "actionId"> & { readonly actionId?: string }, options?: { readonly client?: GitActionClient; readonly gitStatus?: GitStatusResult | null; readonly onProgress?: (event: GitActionProgressEvent) => void; }, ): Promise<GitRunStackedActionResult | null> { const actionId = input.actionId ?? config.getActionId?.() ?? `git-action-${Date.now()}-${Math.random().toString(36).slice(2)}`; const targetKey = getGitActionTargetKey(target); return runOperation(target, { operation: "run_stacked_action", label: buildGitActionProgressStages({ action: input.action, hasCustomCommitMessage: Boolean(input.commitMessage?.trim()), hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false, featureBranch: input.featureBranch ?? false, shouldPushBeforePr: input.action === "create_pr" && (!(options?.gitStatus?.hasUpstream ?? false) || (options?.gitStatus?.aheadCount ?? 0) > 0), })[0] ?? "Running git action", actionId, action: input.action, client: options?.client, execute: async (resolved) => { const result = await resolved.runStackedAction( { cwd: target.cwd!, actionId, ...input, }, { onProgress: (event) => { if (targetKey !== null) { const current = getSnapshot(target); setState(targetKey, applyGitActionProgressEvent(current, event)); } options?.onProgress?.(event); }, }, ); return result; }, }); } function reset(): void { for (const key of knownGitActionKeys) { setState(key, EMPTY_GIT_ACTION_STATE); } } return { getSnapshot, refreshStatus, pull, checkout, createBranch, createWorktree, init, runStackedAction, reset, };}
import type {
GitActionProgressEvent,
GitCreateBranchInput,
GitCreateBranchResult,
GitCreateWorktreeInput,
GitCreateWorktreeResult,
GitCheckoutInput,
GitCheckoutResult,
GitPullInput,
GitPullResult,
GitRunStackedActionInput,
GitRunStackedActionResult,
GitStackedAction,
GitStatusResult,
} from "@t3tools/contracts";
import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
import { buildGitActionProgressStages } from "./gitActions";
import type { WsRpcClient } from "./wsRpcClient";
export type GitActionOperation =
| "refresh_status"
| "run_stacked_action"
| "pull"
| "checkout"
| "create_branch"
| "create_worktree"
| "init";
export interface GitActionState {
readonly isRunning: boolean;
readonly operation: GitActionOperation | null;
readonly actionId: string | null;
readonly action: GitStackedAction | null;
readonly currentLabel: string | null;
readonly currentPhaseLabel: string | null;
readonly hookName: string | null;
readonly lastOutputLine: string | null;
readonly phaseStartedAtMs: number | null;
readonly hookStartedAtMs: number | null;
readonly error: string | null;
}
export interface GitActionTarget {
readonly environmentId: string | null;
readonly cwd: string | null;
}
export type GitActionClient = Pick<
WsRpcClient["git"],
| "refreshStatus"
| "runStackedAction"
| "pull"
| "checkout"
| "createBranch"
| "createWorktree"
| "init"
>;
export const EMPTY_GIT_ACTION_STATE = Object.freeze<GitActionState>({
isRunning: false,
operation: null,
actionId: null,
action: null,
currentLabel: null,
currentPhaseLabel: null,
hookName: null,
lastOutputLine: null,
phaseStartedAtMs: null,
hookStartedAtMs: null,
error: null,
});
const knownGitActionKeys = new Set<string>();
export const gitActionStateAtom = Atom.family((key: string) => {
knownGitActionKeys.add(key);
return Atom.make(EMPTY_GIT_ACTION_STATE).pipe(
Atom.keepAlive,
Atom.withLabel(`git-action:${key}`),
);
});
export const EMPTY_GIT_ACTION_ATOM = Atom.make(EMPTY_GIT_ACTION_STATE).pipe(
Atom.keepAlive,
Atom.withLabel("git-action:null"),
);
export function getGitActionTargetKey(target: GitActionTarget): string | null {
if (target.environmentId === null || target.cwd === null) {
return null;
}
return `${target.environmentId}:${target.cwd}`;
}
export function applyGitActionProgressEvent(
current: GitActionState,
event: GitActionProgressEvent,
): GitActionState {
const now = Date.now();
switch (event.kind) {
case "action_started":
return {
...current,
isRunning: true,
actionId: event.actionId,
action: event.action,
operation: "run_stacked_action",
phaseStartedAtMs: now,
hookStartedAtMs: null,
hookName: null,
lastOutputLine: null,
error: null,
};
case "phase_started":
return {
...current,
isRunning: true,
actionId: event.actionId,
action: event.action,
operation: "run_stacked_action",
currentLabel: event.label,
currentPhaseLabel: event.label,
phaseStartedAtMs: now,
hookStartedAtMs: null,
hookName: null,
lastOutputLine: null,
error: null,
};
case "hook_started":
return {
...current,
isRunning: true,
actionId: event.actionId,
action: event.action,
operation: "run_stacked_action",
currentLabel: `Running ${event.hookName}...`,
hookName: event.hookName,
hookStartedAtMs: now,
lastOutputLine: null,
error: null,
};
case "hook_output":
return {
...current,
isRunning: true,
actionId: event.actionId,
action: event.action,
operation: "run_stacked_action",
lastOutputLine: event.text,
error: null,
};
case "hook_finished":
return {
...current,
isRunning: true,
actionId: event.actionId,
action: event.action,
operation: "run_stacked_action",
currentLabel: current.currentPhaseLabel,
hookName: null,
hookStartedAtMs: null,
lastOutputLine: null,
error: null,
};
case "action_finished":
return {
...current,
isRunning: false,
actionId: event.actionId,
action: event.action,
operation: "run_stacked_action",
error: null,
};
case "action_failed":
return {
...EMPTY_GIT_ACTION_STATE,
actionId: event.actionId,
action: event.action,
operation: "run_stacked_action",
error: event.message,
};
}
}
export interface GitActionManagerConfig {
readonly getRegistry: () => AtomRegistry.AtomRegistry;
readonly getClient: (environmentId: string) => GitActionClient | null;
readonly getActionId?: () => string;
}
export function createGitActionManager(config: GitActionManagerConfig) {
function setState(targetKey: string, nextState: GitActionState): void {
config.getRegistry().set(gitActionStateAtom(targetKey), nextState);
}
function startOperation(
targetKey: string,
input: {
readonly operation: GitActionOperation;
readonly actionId?: string;
readonly action?: GitStackedAction;
readonly label: string;
},
): void {
setState(targetKey, {
isRunning: true,
operation: input.operation,
actionId: input.actionId ?? null,
action: input.action ?? null,
currentLabel: input.label,
currentPhaseLabel: input.label,
hookName: null,
lastOutputLine: null,
phaseStartedAtMs: Date.now(),
hookStartedAtMs: null,
error: null,
});
}
function finishOperation(targetKey: string): void {
setState(targetKey, EMPTY_GIT_ACTION_STATE);
}
function failOperation(
targetKey: string,
error: unknown,
input: {
readonly operation: GitActionOperation;
readonly actionId?: string;
readonly action?: GitStackedAction;
},
): void {
setState(targetKey, {
...EMPTY_GIT_ACTION_STATE,
operation: input.operation,
actionId: input.actionId ?? null,
action: input.action ?? null,
error: error instanceof Error ? error.message : "Git action failed.",
});
}
async function runOperation<TResult>(
target: GitActionTarget,
input: {
readonly operation: GitActionOperation;
readonly label: string;
readonly actionId?: string;
readonly action?: GitStackedAction;
readonly client?: GitActionClient | undefined;
readonly execute: (client: GitActionClient) => Promise<TResult>;
},
): Promise<TResult | null> {
const targetKey = getGitActionTargetKey(target);
if (targetKey === null || target.environmentId === null || target.cwd === null) {
return null;
}
const resolved = input.client ?? config.getClient(target.environmentId);
if (!resolved) {
return null;
}
startOperation(targetKey, input);
try {
const result = await input.execute(resolved);
finishOperation(targetKey);
return result;
} catch (error) {
failOperation(targetKey, error, input);
throw error;
}
}
function getSnapshot(target: GitActionTarget): GitActionState {
const targetKey = getGitActionTargetKey(target);
if (targetKey === null) {
return EMPTY_GIT_ACTION_STATE;
}
return config.getRegistry().get(gitActionStateAtom(targetKey));
}
async function refreshStatus(
target: GitActionTarget,
client?: GitActionClient,
options?: { readonly quiet?: boolean },
): Promise<Awaited<ReturnType<GitActionClient["refreshStatus"]>> | null> {
if (options?.quiet) {
if (target.environmentId === null || target.cwd === null) {
return null;
}
const resolved = client ?? config.getClient(target.environmentId);
return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null;
}
return runOperation(target, {
operation: "refresh_status",
label: "Refreshing git status",
client,
execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }),
});
}
async function pull(
target: GitActionTarget,
client?: GitActionClient,
options?: { readonly label?: string },
): Promise<GitPullResult | null> {
return runOperation(target, {
operation: "pull",
label: options?.label ?? "Pulling latest changes",
client,
execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies GitPullInput),
});
}
async function checkout(
target: GitActionTarget,
input: Omit<GitCheckoutInput, "cwd">,
client?: GitActionClient,
options?: { readonly label?: string },
): Promise<GitCheckoutResult | null> {
return runOperation(target, {
operation: "checkout",
label: options?.label ?? "Checking out branch",
client,
execute: (resolved) => resolved.checkout({ cwd: target.cwd!, ...input }),
});
}
async function createBranch(
target: GitActionTarget,
input: Omit<GitCreateBranchInput, "cwd">,
client?: GitActionClient,
options?: { readonly label?: string },
): Promise<GitCreateBranchResult | null> {
return runOperation(target, {
operation: "create_branch",
label: options?.label ?? "Creating branch",
client,
execute: (resolved) => resolved.createBranch({ cwd: target.cwd!, ...input }),
});
}
async function createWorktree(
target: GitActionTarget,
input: Omit<GitCreateWorktreeInput, "cwd">,
client?: GitActionClient,
options?: { readonly label?: string },
): Promise<GitCreateWorktreeResult | null> {
return runOperation(target, {
operation: "create_worktree",
label: options?.label ?? "Creating worktree",
client,
execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }),
});
}
async function init(
target: GitActionTarget,
client?: GitActionClient,
options?: { readonly label?: string },
): Promise<Awaited<ReturnType<GitActionClient["init"]>> | null> {
return runOperation(target, {
operation: "init",
label: options?.label ?? "Initializing git repository",
client,
execute: (resolved) => resolved.init({ cwd: target.cwd! }),
});
}
async function runStackedAction(
target: GitActionTarget,
input: Omit<GitRunStackedActionInput, "cwd" | "actionId"> & { readonly actionId?: string },
options?: {
readonly client?: GitActionClient;
readonly gitStatus?: GitStatusResult | null;
readonly onProgress?: (event: GitActionProgressEvent) => void;
},
): Promise<GitRunStackedActionResult | null> {
const actionId =
input.actionId ??
config.getActionId?.() ??
`git-action-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const targetKey = getGitActionTargetKey(target);
return runOperation(target, {
operation: "run_stacked_action",
label:
buildGitActionProgressStages({
action: input.action,
hasCustomCommitMessage: Boolean(input.commitMessage?.trim()),
hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false,
featureBranch: input.featureBranch ?? false,
shouldPushBeforePr:
input.action === "create_pr" &&
(!(options?.gitStatus?.hasUpstream ?? false) ||
(options?.gitStatus?.aheadCount ?? 0) > 0),
})[0] ?? "Running git action",
actionId,
action: input.action,
client: options?.client,
execute: async (resolved) => {
const result = await resolved.runStackedAction(
{
cwd: target.cwd!,
actionId,
...input,
},
{
onProgress: (event) => {
if (targetKey !== null) {
const current = getSnapshot(target);
setState(targetKey, applyGitActionProgressEvent(current, event));
}
options?.onProgress?.(event);
},
},
);
return result;
},
});
}
function reset(): void {
for (const key of knownGitActionKeys) {
setState(key, EMPTY_GIT_ACTION_STATE);
}
}
return {
getSnapshot,
refreshStatus,
pull,
checkout,
createBranch,
createWorktree,
init,
runStackedAction,
reset,
};
}