authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:56:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:56:46-07:00
loga26f0dc89fd46f88529823511cdd068c9cd2c151
treecf971f463c0cd8c122fdfccd23ffdf826e16356b
parent6ff6c13fc8bff69e2967a4f768a076bbb1b88aa5
signature Signed by PGP key B5690EEEBB952194

[codex] structure source-control repository failures (#3336)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 98 insertions(+), 45 deletions(-)

apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+83-4
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import * as NodePath from "@effect/platform-node/NodePath";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
6import { ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcessSpawner } from "effect/unstable/process";
79
8import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";10import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts";
911
10import * as ServerConfig from "../config.ts";12import * as ServerConfig from "../config.ts";
11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";13import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
...@@ -54,8 +56,9 @@ function processOutput(): GitVcsDriver.ExecuteGitResult {...@@ -54,8 +56,9 @@ function processOutput(): GitVcsDriver.ExecuteGitResult {
54function makeLayer(input: {56function makeLayer(input: {
55 readonly provider?: SourceControlProvider.SourceControlProvider["Service"];57 readonly provider?: SourceControlProvider.SourceControlProvider["Service"];
56 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;58 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
59 readonly fileSystem?: FileSystem.FileSystem;
57}) {60}) {
58 return SourceControlRepositoryService.layer.pipe(61 const serviceLayer = SourceControlRepositoryService.layer.pipe(
59 Layer.provide(62 Layer.provide(
60 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({63 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
61 get: () => Effect.succeed(input.provider ?? makeProvider()),64 get: () => Effect.succeed(input.provider ?? makeProvider()),
...@@ -75,9 +78,20 @@ function makeLayer(input: {...@@ -75,9 +78,20 @@ function makeLayer(input: {
75 ...input.git,78 ...input.git,
76 }),79 }),
77 ),80 ),
78 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })),81 Layer.provide(
79 Layer.provideMerge(NodeServices.layer),82 ServerConfig.layerTest(
83 process.cwd(),
84 input.fileSystem ? "/tmp/t3-source-control-repos" : { prefix: "t3-source-control-repos-" },
85 ),
86 ),
80 );87 );
88
89 return input.fileSystem
90 ? serviceLayer.pipe(
91 Layer.provide(Layer.succeed(FileSystem.FileSystem, input.fileSystem)),
92 Layer.provideMerge(NodePath.layer),
93 )
94 : serviceLayer.pipe(Layer.provideMerge(NodeServices.layer));
81}95}
8296
83it.effect("looks up repositories through the requested provider without search", () => {97it.effect("looks up repositories through the requested provider without search", () => {
...@@ -103,6 +117,39 @@ it.effect("looks up repositories through the requested provider without search",...@@ -103,6 +117,39 @@ it.effect("looks up repositories through the requested provider without search",
103 }).pipe(Effect.provide(makeLayer({ provider })));117 }).pipe(Effect.provide(makeLayer({ provider })));
104});118});
105119
120it.effect("preserves provider failures without deriving the repository message from them", () => {
121 const providerCause = new SourceControlProviderError({
122 provider: "github",
123 operation: "getRepositoryCloneUrls",
124 cwd: "/workspace",
125 repository: "octocat/t3code",
126 detail: "credential token abc123 was rejected",
127 });
128 const provider = makeProvider({
129 getRepositoryCloneUrls: () => Effect.fail(providerCause),
130 });
131
132 return Effect.gen(function* () {
133 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
134 const error = yield* Effect.flip(
135 service.lookupRepository({
136 provider: "github",
137 repository: "octocat/t3code",
138 cwd: "/workspace",
139 }),
140 );
141
142 assert.strictEqual(error.provider, "github");
143 assert.strictEqual(error.operation, "lookupRepository");
144 assert.strictEqual(error.detail, "The source control operation could not be completed.");
145 assert.strictEqual(
146 error.message,
147 "Source control repository operation lookupRepository failed for github: The source control operation could not be completed.",
148 );
149 assert.strictEqual(error.cause, providerCause);
150 }).pipe(Effect.provide(makeLayer({ provider })));
151});
152
106it.effect("clones a looked-up repository into the requested destination", () =>153it.effect("clones a looked-up repository into the requested destination", () =>
107 Effect.gen(function* () {154 Effect.gen(function* () {
108 const fs = yield* FileSystem.FileSystem;155 const fs = yield* FileSystem.FileSystem;
...@@ -148,6 +195,38 @@ it.effect("clones a looked-up repository into the requested destination", () =>...@@ -148,6 +195,38 @@ it.effect("clones a looked-up repository into the requested destination", () =>
148 }).pipe(Effect.provide(NodeServices.layer)),195 }).pipe(Effect.provide(NodeServices.layer)),
149);196);
150197
198it.effect("preserves destination probe failures instead of treating them as missing paths", () => {
199 const fileSystemCause = PlatformError.systemError({
200 _tag: "PermissionDenied",
201 module: "FileSystem",
202 method: "exists",
203 pathOrDescriptor: "/restricted/t3code",
204 });
205
206 return Effect.gen(function* () {
207 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
208 const error = yield* Effect.flip(
209 service.cloneRepository({
210 remoteUrl: CLONE_URLS.sshUrl,
211 destinationPath: "/restricted/t3code",
212 }),
213 );
214
215 assert.strictEqual(error.provider, "unknown");
216 assert.strictEqual(error.operation, "cloneRepository");
217 assert.strictEqual(error.cause, fileSystemCause);
218 }).pipe(
219 Effect.provide(
220 makeLayer({
221 fileSystem: FileSystem.makeNoop({
222 exists: () => Effect.fail(fileSystemCause),
223 makeDirectory: () => Effect.void,
224 }),
225 }),
226 ),
227 );
228});
229
151it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => {230it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => {
152 const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = [];231 const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = [];
153 const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = [];232 const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = [];
apps/server/src/sourceControl/SourceControlRepositoryService.ts+15-41
...@@ -39,41 +39,14 @@ export class SourceControlRepositoryService extends Context.Service<...@@ -39,41 +39,14 @@ export class SourceControlRepositoryService extends Context.Service<
39 }39 }
40>()("t3/sourceControl/SourceControlRepositoryService") {}40>()("t3/sourceControl/SourceControlRepositoryService") {}
4141
42function detailFromUnknown(cause: unknown): string {
43 if (typeof cause === "object" && cause !== null) {
44 if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) {
45 return cause.detail;
46 }
47 if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) {
48 return cause.message;
49 }
50 }
51
52 return "An unexpected source control error occurred.";
53}
54
55function repositoryError(input: {
56 readonly operation: string;
57 readonly provider: SourceControlProviderKind;
58 readonly detail: string;
59 readonly cause?: unknown;
60}): SourceControlRepositoryError {
61 return new SourceControlRepositoryError({
62 provider: input.provider,
63 operation: input.operation,
64 detail: input.detail,
65 ...(input.cause === undefined ? {} : { cause: input.cause }),
66 });
67}
68
69function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {42function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {
70 return Effect.mapError((cause: unknown) =>43 return Effect.mapError((cause: unknown) =>
71 isSourceControlRepositoryError(cause)44 isSourceControlRepositoryError(cause)
72 ? cause45 ? cause
73 : repositoryError({46 : new SourceControlRepositoryError({
74 operation,47 operation,
75 provider,48 provider,
76 detail: detailFromUnknown(cause),49 detail: "The source control operation could not be completed.",
77 cause,50 cause,
78 }),51 }),
79 );52 );
...@@ -130,7 +103,7 @@ export const make = Effect.gen(function* () {...@@ -130,7 +103,7 @@ export const make = Effect.gen(function* () {
130 }103 }
131104
132 return Effect.fail(105 return Effect.fail(
133 repositoryError({106 new SourceControlRepositoryError({
134 operation: input.operation,107 operation: input.operation,
135 provider: input.provider,108 provider: input.provider,
136 detail: "Choose a source control provider before continuing.",109 detail: "Choose a source control provider before continuing.",
...@@ -157,7 +130,7 @@ export const make = Effect.gen(function* () {...@@ -157,7 +130,7 @@ export const make = Effect.gen(function* () {
157 function* (destinationPath: string) {130 function* (destinationPath: string) {
158 const trimmed = destinationPath.trim();131 const trimmed = destinationPath.trim();
159 if (trimmed.length === 0) {132 if (trimmed.length === 0) {
160 return yield* repositoryError({133 return yield* new SourceControlRepositoryError({
161 operation: "cloneRepository",134 operation: "cloneRepository",
162 provider: "unknown",135 provider: "unknown",
163 detail: "Choose a destination path before cloning.",136 detail: "Choose a destination path before cloning.",
...@@ -171,21 +144,22 @@ export const make = Effect.gen(function* () {...@@ -171,21 +144,22 @@ export const make = Effect.gen(function* () {
171 const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")(144 const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")(
172 function* (destinationPath: string) {145 function* (destinationPath: string) {
173 const normalizedDestination = yield* normalizeDestinationPath(destinationPath);146 const normalizedDestination = yield* normalizeDestinationPath(destinationPath);
174 if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) {147 if (yield* fileSystem.exists(normalizedDestination)) {
175 const entries = yield* fileSystem148 const entries = yield* fileSystem
176 .readDirectory(normalizedDestination, { recursive: false })149 .readDirectory(normalizedDestination, { recursive: false })
177 .pipe(150 .pipe(
178 Effect.mapError((cause) =>151 Effect.mapError(
179 repositoryError({152 (cause) =>
180 operation: "cloneRepository",153 new SourceControlRepositoryError({
181 provider: "unknown",154 operation: "cloneRepository",
182 detail: "Destination path already exists and is not a directory.",155 provider: "unknown",
183 cause,156 detail: "Destination path already exists and is not a directory.",
184 }),157 cause,
158 }),
185 ),159 ),
186 );160 );
187 if (entries.length > 0) {161 if (entries.length > 0) {
188 return yield* repositoryError({162 return yield* new SourceControlRepositoryError({
189 operation: "cloneRepository",163 operation: "cloneRepository",
190 provider: "unknown",164 provider: "unknown",
191 detail: "Destination path already exists and is not empty.",165 detail: "Destination path already exists and is not empty.",
...@@ -222,7 +196,7 @@ export const make = Effect.gen(function* () {...@@ -222,7 +196,7 @@ export const make = Effect.gen(function* () {
222 }196 }
223197
224 if (!remoteUrl) {198 if (!remoteUrl) {
225 return yield* repositoryError({199 return yield* new SourceControlRepositoryError({
226 operation: "cloneRepository",200 operation: "cloneRepository",
227 provider,201 provider,
228 detail: "Enter a repository path or clone URL before cloning.",202 detail: "Enter a repository path or clone URL before cloning.",