1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import {
4 OrchestrationDispatchCommandError,
5 ProjectId,
6 SourceControlRepositoryError,
7} from "@t3tools/contracts";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as Layer from "effect/Layer";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14
15import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts";
16import * as ProjectCloneTracker from "./ProjectCloneTracker.ts";
17import { parseGitCloneProgressLine } from "./gitCloneProgress.ts";
18
19const projectId = ProjectId.make("project-1");
20const startInput = {
21 projectId,
22 title: "t3code",
23 createdAt: "2026-01-01T00:00:00.000Z",
24 remoteUrl: "git@github.com:octocat/t3code.git",
25 destinationPath: "/workspace/t3code",
26};
27
28function makeHarness(options?: {
29 readonly clone?: SourceControlRepositoryService.SourceControlRepositoryService["Service"]["cloneRepository"];
30}) {
31 const created: Array<{ projectId: ProjectId; workspaceRoot: string }> = [];
32 const cloned: Array<ProjectId> = [];
33 const discarded: Array<string> = [];
34 const hooks: ProjectCloneTracker.ProjectCloneHooks = {
35 createProject: (input) =>
36 Effect.sync(() => {
37 created.push({ projectId: input.projectId, workspaceRoot: input.workspaceRoot });
38 }),
39 onCloned: (input) => Effect.sync(() => void cloned.push(input.projectId)),
40 };
41 const layer = ProjectCloneTracker.layer.pipe(
42 Layer.provide(
43 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
44 prepareClone: (input) =>
45 Effect.succeed({
46 destinationPath: input.destinationPath,
47 remoteUrl: input.remoteUrl ?? "",
48 cloneUrl: input.remoteUrl ?? "",
49 repository: null,
50 }),
51 cloneRepository:
52 options?.clone ??
53 ((input) =>
54 Effect.succeed({
55 cwd: input.destinationPath,
56 remoteUrl: input.remoteUrl ?? "",
57 repository: null,
58 })),
59 discardClone: (destination) => Effect.sync(() => void discarded.push(destination)),
60 }),
61 ),
62 );
63 return { layer, hooks, created, cloned, discarded };
64}
65
66describe("ProjectCloneTracker", () => {
67 it.effect("creates the project first and reports the clone through the stream", () => {
68 const release = Deferred.makeUnsafe<void>();
69 const harness = makeHarness({
70 clone: (input, options) =>
71 Effect.gen(function* () {
72 yield* (
73 options?.onProgress?.({ stage: "receiving", percent: 40, detail: "1 MiB" }) ??
74 Effect.void
75 );
76 yield* Deferred.await(release);
77 return { cwd: input.destinationPath, remoteUrl: input.remoteUrl ?? "", repository: null };
78 }),
79 });
80 return Effect.gen(function* () {
81 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
82 const collected = yield* tracker.stream.pipe(
83 Stream.takeUntil((clones) => clones[0]?.phase === "done"),
84 Stream.runCollect,
85 Effect.forkChild,
86 );
87 yield* Effect.yieldNow;
88
89 const result = yield* tracker.start(startInput, harness.hooks);
90 expect(result.cwd).toBe("/workspace/t3code");
91 // The project exists before git runs so the draft can open immediately.
92 expect(harness.created).toEqual([{ projectId, workspaceRoot: "/workspace/t3code" }]);
93
94 yield* Effect.yieldNow;
95 const running = yield* tracker.get(projectId);
96 expect(running).toMatchObject({ phase: "running", stage: "receiving", percent: 40 });
97
98 yield* Deferred.succeed(release, undefined);
99 const lists = yield* Fiber.join(collected);
100 const final = lists.at(-1)?.[0];
101 expect(final).toMatchObject({ phase: "done", percent: 100 });
102 expect(harness.cloned).toEqual([projectId]);
103
104 // Done clones drop out after the grace window so the toast can settle.
105 yield* TestClock.adjust("31 seconds");
106 expect(yield* tracker.get(projectId)).toBeNull();
107 }).pipe(Effect.provide(harness.layer));
108 });
109
110 it.effect("keeps a failed clone with git's own explanation and retries it", () => {
111 let attempts = 0;
112 const harness = makeHarness({
113 clone: (input) =>
114 Effect.suspend(() => {
115 attempts += 1;
116 return attempts === 1
117 ? Effect.fail(
118 new SourceControlRepositoryError({
119 operation: "cloneRepository",
120 provider: SourceControlProviderKind.make("unknown"),
121 detail: "fatal: repository not found",
122 }),
123 )
124 : Effect.succeed({
125 cwd: input.destinationPath,
126 remoteUrl: input.remoteUrl ?? "",
127 repository: null,
128 });
129 }),
130 });
131 return Effect.gen(function* () {
132 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
133 yield* tracker.start(startInput, harness.hooks);
134 yield* Effect.yieldNow;
135 const failed = yield* tracker.get(projectId);
136 expect(failed).toMatchObject({ phase: "failed", error: "fatal: repository not found" });
137
138 expect(yield* tracker.retry(projectId)).toBe(true);
139 // The partial checkout is cleared so git sees an empty destination.
140 expect(harness.discarded).toEqual(["/workspace/t3code"]);
141 yield* Effect.yieldNow;
142 expect((yield* tracker.get(projectId))?.phase).toBe("done");
143 expect(attempts).toBe(2);
144 }).pipe(Effect.provide(harness.layer));
145 });
146
147 it.effect("cancel interrupts the clone and removes the partial checkout", () => {
148 const harness = makeHarness({ clone: () => Effect.never });
149 return Effect.gen(function* () {
150 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
151 yield* tracker.start(startInput, harness.hooks);
152 yield* Effect.yieldNow;
153
154 expect(yield* tracker.cancel(projectId)).toBe(true);
155 expect((yield* tracker.get(projectId))?.phase).toBe("cancelled");
156 expect(harness.discarded).toEqual(["/workspace/t3code"]);
157 // Nothing left to cancel; retry is what brings it back.
158 expect(yield* tracker.cancel(projectId)).toBe(false);
159 expect(yield* tracker.retry(projectId)).toBe(true);
160 }).pipe(Effect.provide(harness.layer));
161 });
162
163 it.effect("a cancel that lands after git finished keeps the checkout", () => {
164 const gate = Deferred.makeUnsafe<void>();
165 const harness = makeHarness();
166 // The clone itself completes instantly; the post-clone hook is what hangs.
167 const hooks: ProjectCloneTracker.ProjectCloneHooks = {
168 ...harness.hooks,
169 onCloned: () => Deferred.await(gate),
170 };
171 return Effect.gen(function* () {
172 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
173 yield* tracker.start(startInput, hooks);
174 yield* Effect.yieldNow;
175 expect((yield* tracker.get(projectId))?.phase).toBe("done");
176 expect(yield* tracker.cancel(projectId)).toBe(false);
177 expect(harness.discarded).toEqual([]);
178 yield* Deferred.succeed(gate, undefined);
179 }).pipe(Effect.provide(harness.layer));
180 });
181
182 it.effect("hands git the credential-bearing URL while snapshots carry the redacted one", () => {
183 const cloneUrls: Array<string> = [];
184 const harness = makeHarness({
185 clone: (input) =>
186 Effect.sync(() => {
187 cloneUrls.push(input.remoteUrl ?? "");
188 return { cwd: input.destinationPath, remoteUrl: "", repository: null };
189 }),
190 });
191 const layer = ProjectCloneTracker.layer.pipe(
192 Layer.provide(
193 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
194 prepareClone: (input) =>
195 Effect.succeed({
196 destinationPath: input.destinationPath,
197 remoteUrl: "https://github.com/octocat/t3code.git",
198 cloneUrl: "https://user:s3cret@github.com/octocat/t3code.git",
199 repository: null,
200 }),
201 cloneRepository: (input) =>
202 Effect.sync(() => {
203 cloneUrls.push(input.remoteUrl ?? "");
204 return { cwd: input.destinationPath, remoteUrl: "", repository: null };
205 }),
206 discardClone: () => Effect.void,
207 }),
208 ),
209 );
210 return Effect.gen(function* () {
211 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
212 const result = yield* tracker.start(startInput, harness.hooks);
213 yield* Effect.yieldNow;
214 expect(result.remoteUrl).toBe("https://github.com/octocat/t3code.git");
215 expect(cloneUrls).toEqual(["https://user:s3cret@github.com/octocat/t3code.git"]);
216 }).pipe(Effect.provide(layer));
217 });
218
219 it.effect("discard forgets a project's clone when the project is deleted", () => {
220 const harness = makeHarness({ clone: () => Effect.never });
221 return Effect.gen(function* () {
222 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
223 yield* tracker.start(startInput, harness.hooks);
224 yield* Effect.yieldNow;
225 yield* tracker.discard(projectId);
226 expect(yield* tracker.get(projectId)).toBeNull();
227 expect(harness.discarded).toEqual(["/workspace/t3code"]);
228 }).pipe(Effect.provide(harness.layer));
229 });
230
231 it.effect("releases the claim when project creation fails", () => {
232 const harness = makeHarness();
233 const hooks: ProjectCloneTracker.ProjectCloneHooks = {
234 ...harness.hooks,
235 createProject: () =>
236 Effect.fail(new OrchestrationDispatchCommandError({ message: "workspace root exists" })),
237 };
238 return Effect.gen(function* () {
239 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
240 const error = yield* Effect.flip(tracker.start(startInput, hooks));
241 expect(error.message).toContain("workspace root exists");
242 expect(yield* tracker.get(projectId)).toBeNull();
243 // The destination is free again for a corrected attempt.
244 yield* tracker.start(startInput, harness.hooks);
245 yield* Effect.yieldNow;
246 expect((yield* tracker.get(projectId))?.phase).toBe("done");
247 }).pipe(Effect.provide(harness.layer));
248 });
249
250 it.effect("does not create a project when the clone cannot be prepared", () => {
251 const harness = makeHarness();
252 const layer = ProjectCloneTracker.layer.pipe(
253 Layer.provide(
254 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
255 prepareClone: () =>
256 Effect.fail(
257 new SourceControlRepositoryError({
258 operation: "cloneRepository",
259 provider: SourceControlProviderKind.make("unknown"),
260 detail: "Destination path already exists and is not empty.",
261 }),
262 ),
263 }),
264 ),
265 );
266 return Effect.gen(function* () {
267 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
268 const error = yield* Effect.flip(tracker.start(startInput, harness.hooks));
269 expect(error.message).toContain("not empty");
270 expect(harness.created).toEqual([]);
271 expect(yield* tracker.get(projectId)).toBeNull();
272 }).pipe(Effect.provide(layer));
273 });
274});
275
276describe("parseGitCloneProgressLine", () => {
277 it("parses git's transfer counters and ignores other output", () => {
278 expect(
279 parseGitCloneProgressLine("Receiving objects: 45% (4500/10000), 12.30 MiB | 5.00 MiB/s"),
280 ).toEqual({ stage: "receiving", percent: 45, detail: "12.30 MiB | 5.00 MiB/s" });
281 expect(parseGitCloneProgressLine("Resolving deltas: 100% (700/700), done.")).toEqual({
282 stage: "resolving",
283 percent: 100,
284 detail: null,
285 });
286 expect(parseGitCloneProgressLine("remote: Compressing objects: 12% (3/25)")).toEqual({
287 stage: "counting",
288 percent: 12,
289 detail: null,
290 });
291 expect(parseGitCloneProgressLine("Updating files: 78% (2104/2700)")).toEqual({
292 stage: "checkout",
293 percent: 78,
294 detail: null,
295 });
296 expect(parseGitCloneProgressLine("remote: Enumerating objects: 10, done.")).toEqual({
297 stage: "counting",
298 percent: null,
299 detail: null,
300 });
301 expect(parseGitCloneProgressLine("Cloning into 't3code'...")).toBeNull();
302 expect(parseGitCloneProgressLine("fatal: repository not found")).toBeNull();
303 });
304});