History

feat(mcp): add project management tools

Julius Marminge committed 2 months ago

Commit 04bd6a81 parent 415ed0f29 files · +2143 −121Browse files

Showing the changes to apps/server/src/sourceControl/SourceControlRepositoryService.ts (1 of 29 files). Show all

Unified · Split

apps/server/src/sourceControl/SourceControlRepositoryService.ts+155−43
@@ -17,9 +17,9 @@ import { · 16 unmodified lines
1717 type SourceControlRepositoryInfo,
1818 type SourceControlRepositoryLookupInput,
1919 } from "@t3tools/contracts";
20−
2120 import { ServerConfig } from "../config.ts";
2221 import { expandHomePathWith } from "../pathExpansion.ts";
22+import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts";
2323 import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2424 import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2525 const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
@@ -83,6 +83,7 @@ export const make = Effect.gen(function* () { · 57 unmodified lines
8383 const git = yield* GitVcsDriver.GitVcsDriver;
8484 const path = yield* Path.Path;
8585 const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
86+ const cloneOperations = yield* makeKeyedSerialExecutor<string>();
8687
8788 const ensureConcreteProvider = (input: {
8889 readonly operation: string;
@@ -131,9 +132,26 @@ export const make = Effect.gen(function* () { · 42 unmodified lines
131132 },
132133 );
133134
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+
134153 const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")(
135− function* (destinationPath: string) {
136− const normalizedDestination = yield* normalizeDestinationPath(destinationPath);
154+ function* (normalizedDestination: string) {
137155 if (yield* fileSystem.exists(normalizedDestination)) {
138156 const entries = yield* fileSystem
139157 .readDirectory(normalizedDestination, { recursive: false })
@@ -148,13 +166,12 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
148166 }),
149167 ),
150168 );
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;
158175 } else {
159176 yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true });
160177 }
@@ -163,49 +180,144 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
163180 destinationPath: normalizedDestination,
164181 parentPath: path.dirname(normalizedDestination),
165182 directoryName: path.basename(normalizedDestination),
166− };
183+ occupied: false,
184+ } as const;
167185 },
168186 );
169187
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+
170255 const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* (
171256 input: SourceControlCloneRepositoryInput,
172257 ) {
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";
177266
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+ }
187274
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);
195276
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+ }
203294
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+ );
209321 });
210322
211323 const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")(