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", () =...@@ -779,6 +779,57 @@ it.effect("ignores worktree metadata for directories that no longer exist", () =
779 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),779 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
780);780);
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
782it.effect("refreshes the current branch after an external checkout", () =>833it.effect("refreshes the current branch after an external checkout", () =>
783 Effect.scoped(834 Effect.scoped(
784 Effect.gen(function* () {835 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriverCore.ts+28-17
...@@ -1181,6 +1181,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -1181,6 +1181,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
1181 Effect.map((result) => result.stdout),1181 Effect.map((result) => result.stdout),
1182 );1182 );
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
1184 const runGitStdoutWithOptions = (1205 const runGitStdoutWithOptions = (
1185 operation: string,1206 operation: string,
1186 cwd: string,1207 cwd: string,
...@@ -3163,16 +3184,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3163,16 +3184,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3163 allowNonZeroExit: true,3184 allowNonZeroExit: true,
3164 },3185 },
3165 ),3186 ),
3166 executeGit(3187 executeWorktreeList("GitVcsDriver.listRefs.worktreeList", fetchCwd, gitDirArgs, {
3167 "GitVcsDriver.listRefs.worktreeList",3188 timeoutMs: 30_000,
3168 fetchCwd,3189 allowNonZeroExit: true,
3169 [...gitDirArgs, "worktree", "list", "--porcelain", "-z"],3190 maxOutputBytes: 16 * 1024 * 1024,
3170 {3191 }),
3171 timeoutMs: 30_000,
3172 allowNonZeroExit: true,
3173 maxOutputBytes: 16 * 1024 * 1024,
3174 },
3175 ),
3176 executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {3192 executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], {
3177 timeoutMs: 5_000,3193 timeoutMs: 5_000,
3178 allowNonZeroExit: true,3194 allowNonZeroExit: true,
...@@ -4043,15 +4059,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -4043,15 +4059,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
4043 );4059 );
40444060
4045 const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) =>4061 const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) =>
4046 runGitStdout("GitVcsDriver.listWorktreePaths", cwd, [4062 executeWorktreeList("GitVcsDriver.listWorktreePaths", cwd, []).pipe(
4047 "worktree",
4048 "list",
4049 "--porcelain",
4050 "-z",
4051 ]).pipe(
4052 // One record per worktree, each ended by an empty field. A `prunable`4063 // One record per worktree, each ended by an empty field. A `prunable`
4053 // record's directory is gone, and another checkout may now sit there.4064 // record's directory is gone, and another checkout may now sit there.
4054 Effect.map((stdout) =>4065 Effect.map(({ stdout }) =>
4055 stdout.split("\0\0").flatMap((record) => {4066 stdout.split("\0\0").flatMap((record) => {
4056 const fields = record.split("\0");4067 const fields = record.split("\0");
4057 const worktree = fields.find((field) => field.startsWith("worktree "));4068 const worktree = fields.find((field) => field.startsWith("worktree "));