Showing the changes to apps/server/src/sourceControl/SourceControlRepositoryService.ts (1 of 29 files). Show all
Unified · Split
| @@ -17,9 +17,9 @@ import { · 16 unmodified lines | |||
| 17 | type SourceControlRepositoryInfo, | 17 | type SourceControlRepositoryInfo, |
| 18 | type SourceControlRepositoryLookupInput, | 18 | type SourceControlRepositoryLookupInput, |
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | |||
| 21 | import { ServerConfig } from "../config.ts"; | 20 | import { ServerConfig } from "../config.ts"; |
| 22 | import { expandHomePathWith } from "../pathExpansion.ts"; | 21 | import { expandHomePathWith } from "../pathExpansion.ts"; |
| 22 | import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; | ||
| 23 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 23 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 24 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 24 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 25 | const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); | 25 | const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); |
| @@ -83,6 +83,7 @@ export const make = Effect.gen(function* () { · 57 unmodified lines | |||
| 83 | const git = yield* GitVcsDriver.GitVcsDriver; | 83 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 84 | const path = yield* Path.Path; | 84 | const path = yield* Path.Path; |
| 85 | const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; | 85 | const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 86 | const cloneOperations = yield* makeKeyedSerialExecutor<string>(); | ||
| 86 | 87 | ||
| 87 | const ensureConcreteProvider = (input: { | 88 | const ensureConcreteProvider = (input: { |
| 88 | readonly operation: string; | 89 | readonly operation: string; |
| @@ -131,9 +132,26 @@ export const make = Effect.gen(function* () { · 42 unmodified lines | |||
| 131 | }, | 132 | }, |
| 132 | ); | 133 | ); |
| 133 | 134 | ||
| 135 | const canonicalDestinationKey = Effect.fn( | ||
| 136 | "SourceControlRepositoryService.canonicalDestinationKey", | ||
| 137 | )(function* (destinationPath: string) { | ||
| 138 | if (yield* fileSystem.exists(destinationPath)) { | ||
| 139 | return yield* fileSystem.realPath(destinationPath); | ||
| 140 | } | ||
| 141 | |||
| 142 | const suffix = [path.basename(destinationPath)]; | ||
| 143 | let ancestor = path.dirname(destinationPath); | ||
| 144 | while (!(yield* fileSystem.exists(ancestor))) { | ||
| 145 | const parent = path.dirname(ancestor); | ||
| 146 | if (parent === ancestor) break; | ||
| 147 | suffix.unshift(path.basename(ancestor)); | ||
| 148 | ancestor = parent; | ||
| 149 | } | ||
| 150 | return path.join(yield* fileSystem.realPath(ancestor), ...suffix); | ||
| 151 | }); | ||
| 152 | |||
| 134 | const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( | 153 | const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( |
| 135 | function* (destinationPath: string) { | 154 | function* (normalizedDestination: string) { |
| 136 | const normalizedDestination = yield* normalizeDestinationPath(destinationPath); | ||
| 137 | if (yield* fileSystem.exists(normalizedDestination)) { | 155 | if (yield* fileSystem.exists(normalizedDestination)) { |
| 138 | const entries = yield* fileSystem | 156 | const entries = yield* fileSystem |
| 139 | .readDirectory(normalizedDestination, { recursive: false }) | 157 | .readDirectory(normalizedDestination, { recursive: false }) |
| @@ -148,13 +166,12 @@ export const make = Effect.gen(function* () { · 8 unmodified lines | |||
| 148 | }), | 166 | }), |
| 149 | ), | 167 | ), |
| 150 | ); | 168 | ); |
| 151 | if (entries.length > 0) { | 169 | return { |
| 152 | return yield* new SourceControlRepositoryError({ | 170 | destinationPath: normalizedDestination, |
| 153 | operation: "cloneRepository", | 171 | parentPath: path.dirname(normalizedDestination), |
| 154 | provider: "unknown", | 172 | directoryName: path.basename(normalizedDestination), |
| 155 | detail: "Destination path already exists and is not empty.", | 173 | occupied: entries.length > 0, |
| 156 | }); | 174 | } as const; |
| 157 | } | ||
| 158 | } else { | 175 | } else { |
| 159 | yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); | 176 | yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); |
| 160 | } | 177 | } |
| @@ -163,49 +180,144 @@ export const make = Effect.gen(function* () { · 2 unmodified lines | |||
| 163 | destinationPath: normalizedDestination, | 180 | destinationPath: normalizedDestination, |
| 164 | parentPath: path.dirname(normalizedDestination), | 181 | parentPath: path.dirname(normalizedDestination), |
| 165 | directoryName: path.basename(normalizedDestination), | 182 | directoryName: path.basename(normalizedDestination), |
| 166 | }; | 183 | occupied: false, |
| 184 | } as const; | ||
| 167 | }, | 185 | }, |
| 168 | ); | 186 | ); |
| 169 | 187 | ||
| 188 | const isMatchingClone = Effect.fn("SourceControlRepositoryService.isMatchingClone")(function* ( | ||
| 189 | destinationPath: string, | ||
| 190 | remoteUrl: string, | ||
| 191 | ) { | ||
| 192 | const repositoryRoot = yield* git.execute({ | ||
| 193 | operation: "SourceControlRepositoryService.cloneRepository.verifyRoot", | ||
| 194 | cwd: destinationPath, | ||
| 195 | args: ["rev-parse", "--show-toplevel"], | ||
| 196 | timeoutMs: 5_000, | ||
| 197 | maxOutputBytes: 16 * 1024, | ||
| 198 | }); | ||
| 199 | const reportedRoot = repositoryRoot.stdout.trim(); | ||
| 200 | if (reportedRoot.length === 0) return false; | ||
| 201 | const [canonicalDestination, canonicalRoot] = yield* Effect.all([ | ||
| 202 | fileSystem.realPath(destinationPath), | ||
| 203 | fileSystem.realPath(reportedRoot), | ||
| 204 | ]); | ||
| 205 | if (canonicalDestination !== canonicalRoot) return false; | ||
| 206 | const existingRemote = yield* git.readConfigValue(destinationPath, "remote.origin.url"); | ||
| 207 | if (existingRemote === null || existingRemote.trim() !== remoteUrl.trim()) { | ||
| 208 | return false; | ||
| 209 | } | ||
| 210 | |||
| 211 | // A repository shell with only an origin, or with fetched refs but an | ||
| 212 | // unfinished checkout, can be left behind by an interrupted clone. | ||
| 213 | const headCommit = yield* git | ||
| 214 | .execute({ | ||
| 215 | operation: "SourceControlRepositoryService.cloneRepository.verifyHead", | ||
| 216 | cwd: destinationPath, | ||
| 217 | args: ["rev-parse", "--verify", "HEAD^{commit}"], | ||
| 218 | timeoutMs: 5_000, | ||
| 219 | maxOutputBytes: 16 * 1024, | ||
| 220 | }) | ||
| 221 | .pipe( | ||
| 222 | Effect.map((result) => result.stdout.trim() || null), | ||
| 223 | Effect.orElseSucceed(() => null), | ||
| 224 | ); | ||
| 225 | if (headCommit === null) return false; | ||
| 226 | |||
| 227 | const remoteCommits = yield* git | ||
| 228 | .execute({ | ||
| 229 | operation: "SourceControlRepositoryService.cloneRepository.verifyRemoteRefs", | ||
| 230 | cwd: destinationPath, | ||
| 231 | args: ["for-each-ref", "--format=%(objectname)", "refs/remotes/origin"], | ||
| 232 | timeoutMs: 5_000, | ||
| 233 | maxOutputBytes: 64 * 1024, | ||
| 234 | }) | ||
| 235 | .pipe( | ||
| 236 | Effect.map((result) => new Set(result.stdout.split("\n").map((line) => line.trim()))), | ||
| 237 | Effect.orElseSucceed(() => new Set<string>()), | ||
| 238 | ); | ||
| 239 | if (!remoteCommits.has(headCommit)) return false; | ||
| 240 | |||
| 241 | return yield* git | ||
| 242 | .execute({ | ||
| 243 | operation: "SourceControlRepositoryService.cloneRepository.verifyCheckout", | ||
| 244 | cwd: destinationPath, | ||
| 245 | args: ["status", "--porcelain=v1", "--untracked-files=no"], | ||
| 246 | timeoutMs: 5_000, | ||
| 247 | maxOutputBytes: 16 * 1024, | ||
| 248 | }) | ||
| 249 | .pipe( | ||
| 250 | Effect.map((result) => result.stdout.trim().length === 0), | ||
| 251 | Effect.orElseSucceed(() => false), | ||
| 252 | ); | ||
| 253 | }); | ||
| 254 | |||
| 170 | const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( | 255 | const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( |
| 171 | input: SourceControlCloneRepositoryInput, | 256 | input: SourceControlCloneRepositoryInput, |
| 172 | ) { | 257 | ) { |
| 173 | const preparedDestination = yield* prepareDestination(input.destinationPath); | 258 | const destinationPath = yield* normalizeDestinationPath(input.destinationPath); |
| 174 | let repository: SourceControlRepositoryInfo | null = null; | 259 | const destinationKey = yield* canonicalDestinationKey(destinationPath); |
| 175 | let remoteUrl = input.remoteUrl?.trim() ?? null; | 260 | return yield* cloneOperations.withLock( |
| 176 | let provider: SourceControlProviderKind = input.provider ?? "unknown"; | 261 | destinationKey, |
| 262 | Effect.gen(function* () { | ||
| 263 | let repository: SourceControlRepositoryInfo | null = null; | ||
| 264 | let remoteUrl = input.remoteUrl?.trim() ?? null; | ||
| 265 | let provider: SourceControlProviderKind = input.provider ?? "unknown"; | ||
| 177 | 266 | ||
| 178 | if (input.provider && input.repository) { | 267 | if (!remoteUrl && !(input.provider && input.repository)) { |
| 179 | repository = yield* lookupRepository({ | 268 | return yield* new SourceControlRepositoryError({ |
| 180 | provider: input.provider, | 269 | operation: "cloneRepository", |
| 181 | repository: input.repository, | 270 | provider, |
| 182 | cwd: preparedDestination.parentPath, | 271 | detail: "Enter a repository path or clone URL before cloning.", |
| 183 | }); | 272 | }); |
| 184 | remoteUrl = selectRemoteUrl(repository, input.protocol); | 273 | } |
| 185 | provider = input.provider; | ||
| 186 | } | ||
| 187 | 274 | ||
| 188 | if (!remoteUrl) { | 275 | const preparedDestination = yield* prepareDestination(destinationPath); |
| 189 | return yield* new SourceControlRepositoryError({ | ||
| 190 | operation: "cloneRepository", | ||
| 191 | provider, | ||
| 192 | detail: "Enter a repository path or clone URL before cloning.", | ||
| 193 | }); | ||
| 194 | } | ||
| 195 | 276 | ||
| 196 | yield* git.execute({ | 277 | if (input.provider && input.repository) { |
| 197 | operation: "SourceControlRepositoryService.cloneRepository", | 278 | repository = yield* lookupRepository({ |
| 198 | cwd: preparedDestination.parentPath, | 279 | provider: input.provider, |
| 199 | args: ["clone", remoteUrl, preparedDestination.directoryName], | 280 | repository: input.repository, |
| 200 | timeoutMs: 120_000, | 281 | cwd: preparedDestination.parentPath, |
| 201 | maxOutputBytes: 256 * 1024, | 282 | }); |
| 202 | }); | 283 | remoteUrl = selectRemoteUrl(repository, input.protocol); |
| 284 | provider = input.provider; | ||
| 285 | } | ||
| 286 | |||
| 287 | if (!remoteUrl) { | ||
| 288 | return yield* new SourceControlRepositoryError({ | ||
| 289 | operation: "cloneRepository", | ||
| 290 | provider, | ||
| 291 | detail: "Enter a repository path or clone URL before cloning.", | ||
| 292 | }); | ||
| 293 | } | ||
| 203 | 294 | ||
| 204 | return { | 295 | if (preparedDestination.occupied) { |
| 205 | cwd: preparedDestination.destinationPath, | 296 | const matches = yield* isMatchingClone(destinationPath, remoteUrl).pipe( |
| 206 | remoteUrl, | 297 | Effect.orElseSucceed(() => false), |
| 207 | repository, | 298 | ); |
| 208 | }; | 299 | if (!matches) { |
| 300 | return yield* new SourceControlRepositoryError({ | ||
| 301 | operation: "cloneRepository", | ||
| 302 | provider, | ||
| 303 | detail: | ||
| 304 | "Destination path already exists and is not a clone of the requested repository.", | ||
| 305 | }); | ||
| 306 | } | ||
| 307 | return { cwd: destinationPath, remoteUrl, repository }; | ||
| 308 | } | ||
| 309 | |||
| 310 | yield* git.execute({ | ||
| 311 | operation: "SourceControlRepositoryService.cloneRepository", | ||
| 312 | cwd: preparedDestination.parentPath, | ||
| 313 | args: ["clone", remoteUrl, preparedDestination.directoryName], | ||
| 314 | timeoutMs: 120_000, | ||
| 315 | maxOutputBytes: 256 * 1024, | ||
| 316 | }); | ||
| 317 | |||
| 318 | return { cwd: destinationPath, remoteUrl, repository }; | ||
| 319 | }), | ||
| 320 | ); | ||
| 209 | }); | 321 | }); |
| 210 | 322 | ||
| 211 | const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( | 323 | const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( |