diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index edf6dd381e4dd18456fec89b9a729c8b4e405e63..45b1348a1a841b6062864b25045a49bbceabeacd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -779,6 +779,57 @@ it.effect("ignores worktree metadata for directories that no longer exist", () = ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); +it.effect("reads worktrees from git that rejects worktree list -z", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + // Git before 2.36 has no `worktree list -z` and exits with a usage error. + const oldGitSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("worktree") && command.args.includes("-z")) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(129)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make("error: unknown switch `z'")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, oldGitSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + const worktreePath = NodeFS.realpathSync.native(yield* makeTmpDir()); + yield* git(cwd, ["worktree", "add", "-b", "feature/old-git", worktreePath]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + const listedPath = refs.refs.find((ref) => ref.name === "feature/old-git")?.worktreePath; + assert.equal(listedPath && NodeFS.realpathSync.native(listedPath), worktreePath); + + const paths = yield* driver.listWorktreePaths(cwd); + assert.include( + paths.map((listed) => NodeFS.realpathSync.native(listed)), + worktreePath, + ); + }), + ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), +); + it.effect("refreshes the current branch after an external checkout", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 57132d887a727412db3c527d72f00633bb973ca1..ed68e44632dcfeef5fabc488a6bbc004ad5b5c23 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1181,6 +1181,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((result) => result.stdout), ); + /** + * Runs `git worktree list --porcelain -z`. Git before 2.36 rejects `-z`, so a failed run + * retries the line form and returns it NUL-delimited, the same shape as `-z` output. + * The line form cannot carry paths that contain newlines. + */ + const executeWorktreeList = Effect.fn("executeWorktreeList")(function* ( + operation: string, + cwd: string, + gitArgs: readonly string[], + options: ExecuteGitOptions = {}, + ) { + const args = [...gitArgs, "worktree", "list", "--porcelain"]; + const result = yield* executeGit(operation, cwd, [...args, "-z"], { + ...options, + allowNonZeroExit: true, + }); + if (result.exitCode === 0) return result; + const lineResult = yield* executeGit(operation, cwd, args, options); + return { ...lineResult, stdout: lineResult.stdout.replaceAll("\n", "\0") }; + }); + const runGitStdoutWithOptions = ( operation: string, cwd: string, @@ -3163,16 +3184,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ), - executeGit( - "GitVcsDriver.listRefs.worktreeList", - fetchCwd, - [...gitDirArgs, "worktree", "list", "--porcelain", "-z"], - { - timeoutMs: 30_000, - allowNonZeroExit: true, - maxOutputBytes: 16 * 1024 * 1024, - }, - ), + executeWorktreeList("GitVcsDriver.listRefs.worktreeList", fetchCwd, gitDirArgs, { + timeoutMs: 30_000, + allowNonZeroExit: true, + maxOutputBytes: 16 * 1024 * 1024, + }), executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -4043,15 +4059,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => - runGitStdout("GitVcsDriver.listWorktreePaths", cwd, [ - "worktree", - "list", - "--porcelain", - "-z", - ]).pipe( + executeWorktreeList("GitVcsDriver.listWorktreePaths", cwd, []).pipe( // One record per worktree, each ended by an empty field. A `prunable` // record's directory is gone, and another checkout may now sit there. - Effect.map((stdout) => + Effect.map(({ stdout }) => stdout.split("\0\0").flatMap((record) => { const fields = record.split("\0"); const worktree = fields.find((field) => field.startsWith("worktree "));