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 | 17 | type SourceControlRepositoryInfo, |
| 18 | 18 | type SourceControlRepositoryLookupInput, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | − | |
| 21 | 20 | import { ServerConfig } from "../config.ts"; |
| 22 | 21 | import { expandHomePathWith } from "../pathExpansion.ts"; |
| 22 | +import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; | |
| 23 | 23 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 24 | 24 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 25 | 25 | const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); |
| @@ -83,6 +83,7 @@ export const make = Effect.gen(function* () { · 57 unmodified lines | ||
| 83 | 83 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 84 | 84 | const path = yield* Path.Path; |
| 85 | 85 | const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 86 | + const cloneOperations = yield* makeKeyedSerialExecutor<string>(); | |
| 86 | 87 | |
| 87 | 88 | const ensureConcreteProvider = (input: { |
| 88 | 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 | 153 | const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( |
| 135 | − function* (destinationPath: string) { | |
| 136 | − const normalizedDestination = yield* normalizeDestinationPath(destinationPath); | |
| 154 | + function* (normalizedDestination: string) { | |
| 137 | 155 | if (yield* fileSystem.exists(normalizedDestination)) { |
| 138 | 156 | const entries = yield* fileSystem |
| 139 | 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) { | |
| 152 | − return yield* new SourceControlRepositoryError({ | |
| 153 | − operation: "cloneRepository", | |
| 154 | − provider: "unknown", | |
| 155 | − detail: "Destination path already exists and is not empty.", | |
| 156 | − }); | |
| 157 | − } | |
| 169 | + return { | |
| 170 | + destinationPath: normalizedDestination, | |
| 171 | + parentPath: path.dirname(normalizedDestination), | |
| 172 | + directoryName: path.basename(normalizedDestination), | |
| 173 | + occupied: entries.length > 0, | |
| 174 | + } as const; | |
| 158 | 175 | } else { |
| 159 | 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 | 180 | destinationPath: normalizedDestination, |
| 164 | 181 | parentPath: path.dirname(normalizedDestination), |
| 165 | 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 | 255 | const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( |
| 171 | 256 | input: SourceControlCloneRepositoryInput, |
| 172 | 257 | ) { |
| 173 | − const preparedDestination = yield* prepareDestination(input.destinationPath); | |
| 174 | − let repository: SourceControlRepositoryInfo | null = null; | |
| 175 | − let remoteUrl = input.remoteUrl?.trim() ?? null; | |
| 176 | − let provider: SourceControlProviderKind = input.provider ?? "unknown"; | |
| 258 | + const destinationPath = yield* normalizeDestinationPath(input.destinationPath); | |
| 259 | + const destinationKey = yield* canonicalDestinationKey(destinationPath); | |
| 260 | + return yield* cloneOperations.withLock( | |
| 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) { | |
| 179 | − repository = yield* lookupRepository({ | |
| 180 | − provider: input.provider, | |
| 181 | − repository: input.repository, | |
| 182 | − cwd: preparedDestination.parentPath, | |
| 183 | − }); | |
| 184 | − remoteUrl = selectRemoteUrl(repository, input.protocol); | |
| 185 | − provider = input.provider; | |
| 186 | − } | |
| 267 | + if (!remoteUrl && !(input.provider && input.repository)) { | |
| 268 | + return yield* new SourceControlRepositoryError({ | |
| 269 | + operation: "cloneRepository", | |
| 270 | + provider, | |
| 271 | + detail: "Enter a repository path or clone URL before cloning.", | |
| 272 | + }); | |
| 273 | + } | |
| 187 | 274 | |
| 188 | − if (!remoteUrl) { | |
| 189 | − return yield* new SourceControlRepositoryError({ | |
| 190 | − operation: "cloneRepository", | |
| 191 | − provider, | |
| 192 | − detail: "Enter a repository path or clone URL before cloning.", | |
| 193 | − }); | |
| 194 | − } | |
| 275 | + const preparedDestination = yield* prepareDestination(destinationPath); | |
| 195 | 276 | |
| 196 | − yield* git.execute({ | |
| 197 | − operation: "SourceControlRepositoryService.cloneRepository", | |
| 198 | − cwd: preparedDestination.parentPath, | |
| 199 | − args: ["clone", remoteUrl, preparedDestination.directoryName], | |
| 200 | − timeoutMs: 120_000, | |
| 201 | − maxOutputBytes: 256 * 1024, | |
| 202 | − }); | |
| 277 | + if (input.provider && input.repository) { | |
| 278 | + repository = yield* lookupRepository({ | |
| 279 | + provider: input.provider, | |
| 280 | + repository: input.repository, | |
| 281 | + cwd: preparedDestination.parentPath, | |
| 282 | + }); | |
| 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 { | |
| 205 | − cwd: preparedDestination.destinationPath, | |
| 206 | − remoteUrl, | |
| 207 | − repository, | |
| 208 | − }; | |
| 295 | + if (preparedDestination.occupied) { | |
| 296 | + const matches = yield* isMatchingClone(destinationPath, remoteUrl).pipe( | |
| 297 | + Effect.orElseSucceed(() => false), | |
| 298 | + ); | |
| 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 | 323 | const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( |