diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 9f3231beb490..f5f5a6d39336 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -74,6 +74,7 @@ export class GitWorkflowService extends Context.Service< readonly fetchRemote: (input: { readonly cwd: string; readonly remoteName: string; + readonly refName?: string; }) => Effect.Effect; readonly remoteExists: (input: { readonly cwd: string; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3eb40a1b9906..fcbc2fe97c32 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10660,6 +10660,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { cwd: "/tmp/project", remoteName: "origin", + refName: "main", }); assert.deepEqual(remoteBranchExists.mock.calls[0]?.[0], { cwd: "/tmp/project", diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 4d63a447d63f..aba6afd7fa88 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -231,6 +231,7 @@ export interface GitFetchRemoteTrackingBranchInput { export interface GitFetchRemoteInput { cwd: string; remoteName: string; + refName?: string; } export interface GitRemoteExistsInput { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index bc8a12700997..feb83f936a86 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1557,6 +1557,64 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("uses parallel checkout without skipping filters or hooks", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "filter.test.smudge", "sed s/original/filtered/g"]); + yield* writeTextFile(cwd, ".gitattributes", "asset.txt filter=test\n"); + yield* writeTextFile(cwd, "asset.txt", "original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "filtered asset"]); + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + "#!/bin/sh\ngit config checkout.workers > checkout-workers\nexit 0\n", + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "parallel"); + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/parallel", + baseRefName: initialBranch, + }); + + assert.notInclude(yield* git(cwd, ["worktree", "list", "--porcelain"]), "locked"); + assert.equal(yield* fs.readFileString(path.join(worktreePath, "checkout-workers")), "0\n"); + assert.equal(yield* fs.readFileString(path.join(worktreePath, "asset.txt")), "filtered\n"); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + assert.equal( + yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]), + initialBranch, + ); + for (const [configured, expected] of [ + ["1", "1"], + ["", "0"], + ] as const) { + yield* git(cwd, ["config", "checkout.workers", configured]); + const configuredPath = path.join(yield* makeTmpDir("git-worktrees-"), "configured"); + yield* driver.createWorktree({ + cwd, + path: configuredPath, + refName: initialBranch, + newRefName: `feature/configured-${expected}`, + }); + assert.equal( + yield* fs.readFileString(path.join(configuredPath, "checkout-workers")), + `${expected}\n`, + ); + } + }), + ); it("parses checkout progress lines from git's stderr", () => { assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { percent: 78, @@ -1686,11 +1744,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports checkout progress while creating a worktree", () => + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - for (let index = 0; index < 5; index += 1) { + for (let index = 0; index < 200; index += 1) { yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); } yield* git(cwd, ["add", "."]); @@ -1724,7 +1782,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const updates = yield* Ref.get(seen); assert.isAbove(updates.length, 1); assert.equal(updates.at(-1)?.percent, 100); - assert.equal(updates.at(-1)?.total, 6); + assert.equal(updates.at(-1)?.total, 201); const completed = updates.map((update) => update.completed); assert.deepEqual( completed, @@ -1957,6 +2015,61 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + for (const failure of ["offline", "auth", "timeout"] as const) { + it.effect(`does not retry a scoped fetch after ${failure}`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const started = yield* Deferred.make(); + const attempts: Array> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("unexpected command"); + if (command.args[0] !== "fetch") return yield* delegate.spawn(command); + attempts.push(command.args); + yield* Deferred.succeed(started, undefined); + return ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: + failure === "timeout" + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(128)), + stderr: Stream.encodeText( + Stream.make( + failure === "auth" + ? "fatal: Authentication failed" + : "fatal: Could not resolve host", + ), + ), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const fetching = yield* driver + .fetchRemote({ cwd, remoteName: "origin", refName: "main" }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + if (failure === "timeout") { + yield* TestClock.adjust("31 seconds"); + yield* TestClock.adjust("31 seconds"); + } + const result = yield* Fiber.join(fetching); + assert.isTrue(Result.isFailure(result)); + assert.equal(attempts.length, 1); + if (Result.isFailure(result)) { + assert.equal( + result.failure.detail, + failure === "timeout" ? "Git command timed out." : "git fetch origin failed", + ); + } + }), + ); + } + it.effect("creates a worktree from the latest fetched remote commit", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1979,8 +2092,16 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]); assert.notEqual(beforeFetch, remoteHead); + yield* git(peer, ["push", "origin", "HEAD:refs/heads/unrelated"]); const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.fetchRemote({ cwd, remoteName: "origin" }); + yield* driver.fetchRemote({ + cwd, + remoteName: "origin", + refName: `origin/${initialBranch}`, + }); + assert.isFalse( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); assert.equal( yield* driver.remoteBranchExists({ @@ -2042,6 +2163,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const status = yield* driver.statusDetails(worktreePath); assert.equal(status.aheadCount, 0); assert.equal(status.aheadOfDefaultCount, 0); + + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "local-only" }); + assert.isTrue( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 86a3e2ebd812..6974d5baac21 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3042,23 +3042,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorDetail: "git worktree add failed", - timeoutMs: WORKTREE_ADD_TIMEOUT_MS, - ...(onCheckoutProgress - ? { - // Git only prints checkout progress when stderr is a tty or the - // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. - env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, - progress: { - onStderrLine: (line) => { - const parsed = parseGitCheckoutProgressLine(line); - return parsed ? onCheckoutProgress(parsed) : Effect.void; + const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; + yield* executeGit( + "GitVcsDriver.createWorktree", + input.cwd, + ["-c", `checkout.workers=${checkoutWorkers}`, ...args], + { + fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + ...(onCheckoutProgress + ? { + // Git only prints checkout progress when stderr is a tty or the + // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. + env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, + progress: { + onStderrLine: (line) => { + const parsed = parseGitCheckoutProgressLine(line); + return parsed ? onCheckoutProgress(parsed) : Effect.void; + }, }, - }, - } - : {}), - }); + } + : {}), + }, + ); if (progress?.onWorktreeClaimed) { yield* progress.onWorktreeClaimed(worktreePath); @@ -3240,15 +3246,47 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { - yield* executeGit( + const args = ["fetch", "--quiet", input.remoteName]; + const options = { + env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: `git fetch ${input.remoteName} failed`, + }; + const fetchAll = executeGit("GitVcsDriver.fetchRemote", input.cwd, args, options); + if (input.refName === undefined) { + return yield* fetchAll.pipe(Effect.asVoid); + } + const branch = + parseRemoteRefWithRemoteNames(input.refName, [input.remoteName])?.branchName ?? + input.refName; + const scopedArgs = [ + ...args, + `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, + ]; + const result = yield* executeGitWithStableDiagnostics( "GitVcsDriver.fetchRemote", input.cwd, - ["fetch", "--quiet", input.remoteName], - { - env: STATUS_UPSTREAM_REFRESH_ENV, - fallbackErrorDetail: `git fetch ${input.remoteName} failed`, - }, + scopedArgs, + { ...options, allowNonZeroExit: true }, ); + if (result.exitCode === 0) return; + if ( + result.stderr + .split(/\r?\n/) + .includes(`fatal: couldn't find remote ref refs/heads/${branch}`) + ) { + return yield* fetchAll.pipe(Effect.asVoid); + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args: scopedArgs, + }), + detail: options.fallbackErrorDetail, + exitCode: result.exitCode, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); }, ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6a16640f7034..908839cc4b03 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1221,6 +1221,7 @@ const makeWsRpcLayer = ( yield* gitWorkflow.fetchRemote({ cwd: prepareWorktree.projectCwd, remoteName: "origin", + refName: prepareWorktree.baseBranch, }); const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ cwd: prepareWorktree.projectCwd,