Skip to content
Source
Sign inSign up

restoreMissingWorktree.ts

179 lines6.8 KB

1import { ThreadId, type ProjectId, type TerminalCwdNotFoundError } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7​
8import * as GitWorkflowService from "../git/GitWorkflowService.ts";
9import * as ProjectService from "../project/ProjectService.ts";
10import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts";
11import { isWithinRoot } from "./KnownWorkspaces.ts";
12import { TerminalCwdRecovery } from "../terminal/Manager.ts";
13​
14class WorktreeRecreateError extends Schema.TaggedError<WorktreeRecreateError>()(
15 "WorktreeRecreateError",
16 {
17 worktreePath: Schema.String,
18 branch: Schema.String,
19 cause: Schema.Defect(),
20 },
21) {
22 override get message(): string {
23 return `Could not recreate the worktree for branch '${this.branch}' at ${this.worktreePath}.`;
24 }
26​
27class WorktreeRecreateNoBaseError extends Schema.TaggedError<WorktreeRecreateNoBaseError>()(
28 "WorktreeRecreateNoBaseError",
29 { worktreePath: Schema.String, branch: Schema.String },
30) {
31 override get message(): string {
32 return `Could not recreate branch '${this.branch}' at ${this.worktreePath}: the project workspace has no commit to start it from.`;
33 }
35​
36interface WorktreeThread {
37 readonly id: ThreadId;
38 readonly projectId: ProjectId;
39 readonly worktreePath: string | null;
40 readonly branch: string | null;
42​
43export const makeRestoreMissingWorktree = Effect.gen(function* () {
44 const fileSystem = yield* FileSystem.FileSystem;
45 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
46 const projects = yield* ProjectService.ProjectService;
47 const isManagedWorktreePath = yield* makeIsManagedWorktreePath;
48​
49 const resolveRecreateBase = Effect.fn("restoreMissingWorktree.resolveRecreateBase")(function* (
50 cwd: string,
51 remoteName: string | null,
52 ) {
53 // Recovery can follow an external checkout, before the status cache expires.
54 yield* gitWorkflow.invalidateLocalStatus(cwd);
55 const { refName: currentBranch } = yield* gitWorkflow.localStatus({ cwd });
56 if (currentBranch !== null) {
57 const refName = `refs/heads/${currentBranch}`;
58 return { refName, baseRefName: refName };
59 }
60 const remoteHead = `refs/remotes/${remoteName}/HEAD`;
61 if (remoteName !== null && (yield* gitWorkflow.hasCommit({ cwd, refName: remoteHead }))) {
62 const { commitSha } = yield* gitWorkflow.resolveRemoteTrackingCommit({
63 cwd,
64 refName: "HEAD",
65 fallbackRemoteName: remoteName,
66 });
67 // Start from the commit so the thread branch does not track the default branch.
68 return { refName: commitSha, baseRefName: remoteHead };
69 }
70 if (yield* gitWorkflow.hasCommit({ cwd, refName: "HEAD" })) {
71 return { refName: "HEAD", baseRefName: undefined };
72 }
73 return null;
74 });
75​
76 const recreateMissingWorktree = Effect.fn("restoreMissingWorktree.recreate")(function* (input: {
77 readonly cwd: string;
78 readonly worktreePath: string;
79 readonly branch: string;
80 }) {
81 const { cwd, worktreePath, branch } = input;
82 yield* gitWorkflow.pruneWorktrees({ cwd });
83 const localBranchNames = yield* gitWorkflow.listLocalBranchNames(cwd);
84 if (localBranchNames.includes(branch)) {
85 yield* gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath });
86 return;
87 }
88 const remoteName = yield* gitWorkflow
89 .resolvePrimaryRemoteName(cwd)
90 .pipe(Effect.orElseSucceed(() => null));
91 // Push keeps the tracking ref current, so recovery reads it without fetching.
92 const pushedRef = remoteName === null ? null : `refs/remotes/${remoteName}/${branch}`;
93 if (pushedRef !== null && (yield* gitWorkflow.hasCommit({ cwd, refName: pushedRef }))) {
94 yield* gitWorkflow.createWorktree({
95 cwd,
96 refName: pushedRef,
97 newRefName: branch,
98 path: worktreePath,
99 });
100 return;
101 }
102 const base = yield* resolveRecreateBase(cwd, remoteName);
103 if (base === null) {
104 return yield* new WorktreeRecreateNoBaseError({ worktreePath, branch });
105 }
106 yield* gitWorkflow.createWorktree({
107 cwd,
108 refName: base.refName,
109 newRefName: branch,
110 ...(base.baseRefName === undefined ? {} : { baseRefName: base.baseRefName }),
111 path: worktreePath,
112 });
113 });
114​
115 return Effect.fn("restoreMissingWorktree")(function* (thread: WorktreeThread) {
116 const { worktreePath, branch } = thread;
117 if (worktreePath === null || branch === null) return;
118 const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true));
119 if (exists || !(yield* isManagedWorktreePath(worktreePath))) return;
120 const project = yield* projects.getById(thread.projectId).pipe(
121 Effect.map(Option.getOrUndefined),
122 Effect.orElseSucceed(() => undefined),
123 );
124 if (project === undefined) return;
125 yield* Effect.logWarning("recreating missing worktree", {
126 threadId: thread.id,
127 worktreePath,
128 branch,
129 });
130 yield* recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch }).pipe(
131 Effect.mapError((cause) =>
132 cause._tag === "WorktreeRecreateNoBaseError"
133 ? cause
134 : new WorktreeRecreateError({ worktreePath, branch, cause }),
135 ),
136 );
137 });
138});
139​
140// Worktree setup uses terminals, so recovery must stay outside TerminalManager.
141export const makePrepareTerminalLaunch = Effect.fn(
142 "restoreMissingWorktree.makePrepareTerminalLaunch",
143)(function* <LookupError>(
144 getThreadShell: (threadId: ThreadId) => Effect.Effect<WorktreeThread | null, LookupError>,
145) {
146 const path = yield* Path.Path;
147 const restoreMissingWorktree = yield* makeRestoreMissingWorktree;
148 return Effect.fn("restoreMissingWorktree.prepareTerminalLaunch")(function* <
149 Input extends {
150 readonly threadId: string;
151 readonly cwd?: string | undefined;
152 readonly worktreePath?: string | null | undefined;
153 },
154 >(input: Input) {
155 const thread = yield* getThreadShell(ThreadId.make(input.threadId)).pipe(
156 Effect.orElseSucceed(() => null),
157 );
158 const worktreePath = thread?.worktreePath ?? null;
159 if (
160 thread === null ||
161 worktreePath === null ||
162 !isWithinRoot(path, path.resolve(input.cwd ?? worktreePath), worktreePath)
163 ) {
164 return { input, recover: TerminalCwdRecovery.defaultValue() };
165 }
166 const recover = (error: TerminalCwdNotFoundError) =>
167 restoreMissingWorktree(thread).pipe(
168 Effect.catch((cause) =>
169 Effect.logWarning("failed to recreate missing worktree", {
170 threadId: thread.id,
171 worktreePath,
172 cause,
173 }).pipe(Effect.andThen(Effect.fail(error))),
174 ),
175 );
176 // The lease must cover the worktree root, even when the terminal opens in a subdirectory.
177 return { input: { ...input, worktreePath }, recover };
178 });
179});