authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 01:38:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 01:38:24-07:00
loga0ea721c47f0b1ac923fbff1043d2994500ecf51
tree268361ed3d8b5e482b4c51bf66516223e36d2fd8
parentc8a6768f079f9448228668c40ffe2262bda202a6
signature Signed by PGP key B5690EEEBB952194

fix(server): worktree paths no longer vanish on git before 2.36 (#19315)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 79 insertions(+), 17 deletions(-)

apps/server/src/vcs/GitVcsDriverCore.test.ts+51
......@@ -779,6 +779,57 @@ it.effect("ignores worktree metadata for directories that no longer exist", () =
779779 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
780780);
781781
782it.effect("reads worktrees from git that rejects worktree list -z", () =>
783 Effect.scoped(
784 Effect.gen(function* () {
785 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
786 // Git before 2.36 has no `worktree list -z` and exits with a usage error.
787 const oldGitSpawner = ChildProcessSpawner.make((command) =>
788 Effect.gen(function* () {
789 if (!ChildProcess.isStandardCommand(command)) {
790 return yield* Effect.die("expected a standard Git command");
791 }
792 if (command.args.includes("worktree") && command.args.includes("-z")) {
793 return ChildProcessSpawner.makeHandle({
794 pid: ChildProcessSpawner.ProcessId(1),
795 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(129)),
796 isRunning: Effect.succeed(false),
797 kill: () => Effect.void,
798 unref: Effect.succeed(Effect.void),
799 stdin: Sink.drain,
800 stdout: Stream.empty,
801 stderr: Stream.encodeText(Stream.make("error: unknown switch `z'")),
802 all: Stream.empty,
803 getInputFd: () => Sink.drain,
804 getOutputFd: () => Stream.empty,
805 });
806 }
807 return yield* delegate.spawn(command);
808 }),
809 );
810 const driver = yield* makeGitVcsDriverCore().pipe(
811 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, oldGitSpawner),
812 );
813 const cwd = yield* makeTmpDir();
814 yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
815 const worktreePath = NodeFS.realpathSync.native(yield* makeTmpDir());
816 yield* git(cwd, ["worktree", "add", "-b", "feature/old-git", worktreePath]).pipe(
817 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
818 );
819
820 const refs = yield* driver.listRefs({ cwd, refresh: true });
821 const listedPath = refs.refs.find((ref) => ref.name === "feature/old-git")?.worktreePath;
822 assert.equal(listedPath && NodeFS.realpathSync.native(listedPath), worktreePath);
823
824 const paths = yield* driver.listWorktreePaths(cwd);
825 assert.include(
826 paths.map((listed) => NodeFS.realpathSync.native(listed)),
827 worktreePath,
828 );
829 }),
830 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
831);
832
782833it.effect("refreshes the current branch after an external checkout", () =>
783834 Effect.scoped(
784835 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriverCore.ts+28-17
......@@ -1181,6 +1181,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
11811181 Effect.map((result) => result.stdout),
11821182 );
11831183
1184 /**
1185 * Runs `git worktree list --porcelain -z`. Git before 2.36 rejects `-z`, so a failed run
1186 * retries the line form and returns it NUL-delimited, the same shape as `-z` output.
1187 * The line form cannot carry paths that contain newlines.
1188 */
1189 const executeWorktreeList = Effect.fn("executeWorktreeList")(function* (
1190 operation: string,
1191 cwd: string,
1192 gitArgs: readonly string[],
1193 options: ExecuteGitOptions = {},
1194 ) {
1195 const args = [...gitArgs, "worktree", "list", "--porcelain"];
1196 const result = yield* executeGit(operation, cwd, [...args, "-z"], {
1197 ...options,
1198 allowNonZeroExit: true,
1199 });
1200 if (result.exitCode === 0) return result;
1201 const lineResult = yield* executeGit(operation, cwd, args, options);
1202 return { ...lineResult, stdout: lineResult.stdout.replaceAll("\n", "\0") };
1203 });
1204
11841205 const runGitStdoutWithOptions = (
11851206 operation: string,
11861207 cwd: string,
......@@ -3163,16 +3184,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
31633184 allowNonZeroExit: true,
31643185 },
31653186 ),
3166 executeGit(
3167 "GitVcsDriver.listRefs.worktreeList",
3168 fetchCwd,
3169 [...gitDirArgs, "worktree", "list", "--porcelain", "-z"],
3170 {
3171 timeoutMs: 30_000,
3172 allowNonZeroExit: true,
3173 maxOutputBytes: 16 * 1024 * 1024,
3174 },
3175 ),
3187 executeWorktreeList("GitVcsDriver.listRefs.worktreeList", fetchCwd, gitDirArgs, {
3188 timeoutMs: 30_000,
3189 allowNonZeroExit: true,
3190 maxOutputBytes: 16 * 1024 * 1024,
3191 }),
31763192 executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {
31773193 timeoutMs: 5_000,
31783194 allowNonZeroExit: true,
......@@ -4043,15 +4059,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
40434059 );
40444060
40454061 const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) =>
4046 runGitStdout("GitVcsDriver.listWorktreePaths", cwd, [
4047 "worktree",
4048 "list",
4049 "--porcelain",
4050 "-z",
4051 ]).pipe(
4062 executeWorktreeList("GitVcsDriver.listWorktreePaths", cwd, []).pipe(
40524063 // One record per worktree, each ended by an empty field. A `prunable`
40534064 // record's directory is gone, and another checkout may now sit there.
4054 Effect.map((stdout) =>
4065 Effect.map(({ stdout }) =>
40554066 stdout.split("\0\0").flatMap((record) => {
40564067 const fields = record.split("\0");
40574068 const worktree = fields.find((field) => field.startsWith("worktree "));