1import { useAtomMount } from "@effect/atom-react";
2
3import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
5import {
6 createVcsStatusRevalidationAtomFamily,
7 type GitActionRequestInput,
8 type VcsActionOperation,
9 type VcsRef,
10} from "@t3tools/client-runtime/state/vcs";
11import {
12 AuthOrchestrationOperateScope,
13 AuthSourceControlWriteScope,
14 EnvironmentAuthorizationError,
15 type GitRunStackedActionResult,
16} from "@t3tools/contracts";
17import {
18 dedupeRemoteBranchesWithLocalMatches,
19 sanitizeFeatureBranchName,
20} from "@t3tools/shared/git";
21import * as Cause from "effect/Cause";
22import { AsyncResult } from "effect/reactivity";
23
24import { useBranches } from "./queries";
25import { threadEnvironment } from "./threads";
26import { vcsActionManager, vcsEnvironment } from "./vcs";
27import { uuidv4 } from "../lib/uuid";
28import { appAtomRegistry } from "./atom-registry";
29import { readEnvironmentScope, useEnvironmentScope } from "./session";
30import {
31 environmentConnectedAtom,
32 setPendingConnectionError,
33} from "./use-remote-environment-registry";
34import { useAtomCommand } from "./use-atom-command";
35import { showGitActionResult } from "./use-vcs-action-state";
36import { useThreadSelection } from "./use-thread-selection";
37import { useSelectedThreadWorktree } from "./use-selected-thread-worktree";
38
39function refreshFailureMessage(cause: Cause.Cause<unknown>): string {
40 const error = Cause.squash(cause);
41 return error instanceof Error ? error.message : "Failed to refresh git status.";
42}
43
44// Selecting a thread or reconnecting its environment refreshes the checkout's
45// status once, shared by every sheet that shows it.
46const selectedThreadStatusRevalidation = createVcsStatusRevalidationAtomFamily({
47 refreshStatus: vcsEnvironment.refreshStatus,
48 connected: environmentConnectedAtom,
49 onRefreshed: (result) =>
50 setPendingConnectionError(
51 AsyncResult.isFailure(result) ? refreshFailureMessage(result.cause) : null,
52 ),
53});
54
55export function useSelectedThreadGitActions() {
56 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
57 reportFailure: false,
58 });
59 const readRefs = useAtomCommand(vcsEnvironment.readRefs, { reportFailure: false });
60 const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
61 const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
62 const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false });
63 const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false });
64 const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });
65 const { selectedThread, selectedThreadProject } = useThreadSelection();
66 const canWriteSourceControl = useEnvironmentScope(
67 selectedThread?.environmentId ?? null,
68 AuthSourceControlWriteScope,
69 );
70 const canOperateThread = useEnvironmentScope(
71 selectedThread?.environmentId ?? null,
72 AuthOrchestrationOperateScope,
73 );
74 const canChangeThreadBranch = canWriteSourceControl && canOperateThread;
75 const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
76 const runStackedAction = useAtomCommand(
77 vcsActionManager.runStackedAction({
78 environmentId: selectedThread?.environmentId ?? null,
79 cwd: selectedThreadCwd,
80 }),
81 { reportFailure: false },
82 );
83
84 const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null;
85 const branchTarget = {
86 environmentId: selectedThread?.environmentId ?? null,
87 cwd: selectedThreadGitRootCwd,
88 query: null,
89 };
90 const branchState = useBranches(branchTarget);
91 const updateThreadGitContext = async (
92 thread: NonNullable<typeof selectedThread>,
93 nextState: {
94 readonly branch?: string | null;
95 readonly worktreePath?: string | null;
96 },
97 ) => {
98 if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) {
99 return AsyncResult.failure<never, EnvironmentAuthorizationError>(
100 Cause.fail(
101 new EnvironmentAuthorizationError({
102 requiredScope: AuthOrchestrationOperateScope,
103 message: "This connection cannot update the thread's branch.",
104 }),
105 ),
106 );
107 }
108 return updateThreadMetadata({
109 environmentId: thread.environmentId,
110 input: {
111 threadId: thread.id,
112 ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}),
113 ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}),
114 },
115 });
116 };
117
118 const refreshSelectedThreadGitStatus = async (options?: {
119 readonly quiet?: boolean;
120 readonly cwd?: string | null;
121 }) => {
122 if (!selectedThread || !selectedThreadProject) {
123 return null;
124 }
125
126 const cwd = options?.cwd ?? selectedThreadCwd;
127 if (!cwd) {
128 return null;
129 }
130
131 const target = { environmentId: selectedThread.environmentId, cwd };
132 const execute = () =>
133 refreshStatus({
134 environmentId: selectedThread.environmentId,
135 input: { cwd },
136 });
137 const result = options?.quiet
138 ? await execute()
139 : await vcsActionManager.track(
140 appAtomRegistry,
141 target,
142 {
143 operation: "refresh_status",
144 label: "Refreshing source control status",
145 },
146 execute,
147 );
148 if (AsyncResult.isFailure(result)) {
149 setPendingConnectionError(refreshFailureMessage(result.cause));
150 return null;
151 }
152 setPendingConnectionError(null);
153 return result.value;
154 };
155
156 useAtomMount(
157 selectedThreadStatusRevalidation(
158 selectedThread && selectedThreadProject && selectedThreadCwd
159 ? { environmentId: selectedThread.environmentId, cwd: selectedThreadCwd }
160 : null,
161 ),
162 );
163
164 const runSelectedThreadGitMutation = async <T, E>(
165 operation: VcsActionOperation,
166 label: string,
167 execute: (input: {
168 readonly thread: EnvironmentThreadShell;
169 readonly project: EnvironmentProject;
170 readonly cwd: string;
171 }) => Promise<AtomCommandResult<T, E>>,
172 options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean },
173 ): Promise<T | null> => {
174 if (
175 !selectedThread ||
176 !selectedThreadProject ||
177 !selectedThreadCwd ||
178 !readEnvironmentScope(selectedThread.environmentId, AuthSourceControlWriteScope) ||
179 (options?.changesThreadBranch === true &&
180 !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope))
181 ) {
182 return null;
183 }
184
185 const target = {
186 environmentId: selectedThread.environmentId,
187 cwd: selectedThreadCwd,
188 };
189 setPendingConnectionError(null);
190 const run = () =>
191 execute({
192 thread: selectedThread,
193 project: selectedThreadProject,
194 cwd: selectedThreadCwd,
195 });
196 const result =
197 options?.managedExternally === true
198 ? await run()
199 : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run);
200 if (AsyncResult.isFailure(result)) {
201 const error = Cause.squash(result.cause);
202 const message = error instanceof Error ? error.message : "Git action failed.";
203 setPendingConnectionError(message);
204 showGitActionResult({ type: "error", title: "Git action failed", description: message });
205 return null;
206 }
207 return result.value;
208 };
209
210 const refreshSelectedThreadBranches = async (): Promise<ReadonlyArray<VcsRef> | null> => {
211 if (branchTarget.environmentId === null || branchTarget.cwd === null) return null;
212 const result = await readRefs({
213 environmentId: branchTarget.environmentId,
214 input: { cwd: branchTarget.cwd, limit: 100, refresh: true },
215 });
216 if (AsyncResult.isFailure(result)) {
217 const error = Cause.squash(result.cause);
218 const message = error instanceof Error ? error.message : "Failed to refresh branches.";
219 setPendingConnectionError(message);
220 showGitActionResult({ type: "error", title: "Git action failed", description: message });
221 return null;
222 }
223 setPendingConnectionError(null);
224 return dedupeRemoteBranchesWithLocalMatches(result.value.refs).filter(
225 (branch) => !branch.isRemote,
226 );
227 };
228
229 const syncSelectedThreadBranchState = async (input: {
230 readonly thread: EnvironmentThreadShell;
231 readonly cwd: string;
232 readonly nextThreadState?: {
233 readonly branch?: string | null;
234 readonly worktreePath?: string | null;
235 };
236 }): Promise<AtomCommandResult<void, unknown>> => {
237 // Git already changed; refresh even if metadata updates fail so the sheet shows the current branch.
238 const updateResult = input.nextThreadState
239 ? await updateThreadGitContext(input.thread, input.nextThreadState)
240 : AsyncResult.success(undefined);
241 branchState.refresh();
242 await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd });
243 return AsyncResult.isFailure(updateResult)
244 ? AsyncResult.failure(updateResult.cause)
245 : AsyncResult.success(undefined);
246 };
247
248 const onCheckoutSelectedThreadBranch = async (branch: string) => {
249 return runSelectedThreadGitMutation(
250 "switch_ref",
251 "Switching branch",
252 async ({ thread, cwd }) => {
253 const result = await switchRef({
254 environmentId: thread.environmentId,
255 input: { cwd, refName: branch },
256 });
257 if (AsyncResult.isFailure(result)) {
258 return result;
259 }
260 const syncResult = await syncSelectedThreadBranchState({
261 thread,
262 cwd,
263 nextThreadState: {
264 branch: result.value.refName ?? thread.branch,
265 worktreePath: selectedThreadWorktreePath,
266 },
267 });
268 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
269 },
270 { changesThreadBranch: true },
271 );
272 };
273
274 const onCreateSelectedThreadBranch = async (branch: string) => {
275 return runSelectedThreadGitMutation(
276 "create_ref",
277 "Creating branch",
278 async ({ thread, cwd }) => {
279 const result = await createRef({
280 environmentId: thread.environmentId,
281 input: { cwd, refName: branch, switchRef: true },
282 });
283 if (AsyncResult.isFailure(result)) {
284 return result;
285 }
286 const syncResult = await syncSelectedThreadBranchState({
287 thread,
288 cwd,
289 nextThreadState: {
290 branch: result.value.refName ?? thread.branch,
291 worktreePath: selectedThreadWorktreePath,
292 },
293 });
294 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
295 },
296 { changesThreadBranch: true },
297 );
298 };
299
300 const onCreateSelectedThreadWorktree = async (nextWorktree: {
301 readonly baseBranch: string;
302 readonly newBranch: string;
303 }) => {
304 return runSelectedThreadGitMutation(
305 "create_worktree",
306 "Creating worktree",
307 async ({ thread, project }) => {
308 const result = await createWorktree({
309 environmentId: thread.environmentId,
310 input: {
311 cwd: project.workspaceRoot,
312 refName: nextWorktree.baseBranch,
313 newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch),
314 path: null,
315 },
316 });
317 if (AsyncResult.isFailure(result)) {
318 return result;
319 }
320 const syncResult = await syncSelectedThreadBranchState({
321 thread,
322 cwd: result.value.worktree.path,
323 nextThreadState: {
324 branch: result.value.worktree.refName,
325 worktreePath: result.value.worktree.path,
326 },
327 });
328 return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result;
329 },
330 { changesThreadBranch: true },
331 );
332 };
333
334 const onPullSelectedThreadBranch = async () => {
335 await runSelectedThreadGitMutation(
336 "pull",
337 "Pulling latest changes",
338 async ({ thread, cwd }) => {
339 const result = await pull({
340 environmentId: thread.environmentId,
341 input: { cwd },
342 });
343 if (AsyncResult.isFailure(result)) {
344 return result;
345 }
346 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
347 showGitActionResult({
348 type: "success",
349 title:
350 result.value.status === "skipped_up_to_date"
351 ? "Already up to date"
352 : `Pulled latest on ${result.value.refName}`,
353 });
354 return result;
355 },
356 );
357 };
358
359 const onRunSelectedThreadGitAction = async (
360 input: GitActionRequestInput,
361 ): Promise<GitRunStackedActionResult | null> => {
362 const actionId = uuidv4();
363 return await runSelectedThreadGitMutation(
364 "run_change_request",
365 "Running source control action",
366 async ({ thread, cwd }) => {
367 const result = await runStackedAction({
368 actionId,
369 action: input.action,
370 ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
371 ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
372 ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
373 threadId: thread.id,
374 });
375 if (AsyncResult.isFailure(result)) {
376 return result;
377 }
378
379 if (result.value.branch.status === "created" && result.value.branch.name) {
380 const syncResult = await syncSelectedThreadBranchState({
381 thread,
382 cwd,
383 nextThreadState: {
384 branch: result.value.branch.name,
385 worktreePath: selectedThreadWorktreePath,
386 },
387 });
388 if (AsyncResult.isFailure(syncResult)) {
389 return AsyncResult.failure(syncResult.cause);
390 }
391 } else {
392 await refreshSelectedThreadGitStatus({ quiet: true, cwd });
393 }
394 showGitActionResult({
395 type: "success",
396 title: result.value.toast.title,
397 description: result.value.toast.description,
398 prUrl: result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined,
399 });
400 return result;
401 },
402 { managedExternally: true, changesThreadBranch: input.featureBranch === true },
403 );
404 };
405
406 return {
407 canWriteSourceControl,
408 canChangeThreadBranch,
409 refreshSelectedThreadGitStatus,
410 refreshSelectedThreadBranches,
411 onCheckoutSelectedThreadBranch,
412 onCreateSelectedThreadBranch,
413 onCreateSelectedThreadWorktree,
414 onPullSelectedThreadBranch,
415 onRunSelectedThreadGitAction,
416 };
417}