| author | |
| committer | |
| log | a0ea721c47f0b1ac923fbff1043d2994500ecf51 |
| tree | 268361ed3d8b5e482b4c51bf66516223e36d2fd8 |
| parent | c8a6768f079f9448228668c40ffe2262bda202a6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 779 | ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), |
| 780 | 780 | ); |
| 781 | 781 | |
| 782 | it.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 | ||
| 782 | 833 | it.effect("refreshes the current branch after an external checkout", () => |
| 783 | 834 | Effect.scoped( |
| 784 | 835 | Effect.gen(function* () { |
apps/server/src/vcs/GitVcsDriverCore.ts+28-17| ... | ... | @@ -1181,6 +1181,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1181 | 1181 | Effect.map((result) => result.stdout), |
| 1182 | 1182 | ); |
| 1183 | 1183 | |
| 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 | 1205 | const runGitStdoutWithOptions = ( |
| 1185 | 1206 | operation: string, |
| 1186 | 1207 | cwd: string, |
| ... | ... | @@ -3163,16 +3184,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3163 | 3184 | allowNonZeroExit: true, |
| 3164 | 3185 | }, |
| 3165 | 3186 | ), |
| 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 | }), | |
| 3176 | 3192 | executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], { |
| 3177 | 3193 | timeoutMs: 5_000, |
| 3178 | 3194 | allowNonZeroExit: true, |
| ... | ... | @@ -4043,15 +4059,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 4043 | 4059 | ); |
| 4044 | 4060 | |
| 4045 | 4061 | 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( | |
| 4052 | 4063 | // One record per worktree, each ended by an empty field. A `prunable` |
| 4053 | 4064 | // record's directory is gone, and another checkout may now sit there. |
| 4054 | Effect.map((stdout) => | |
| 4065 | Effect.map(({ stdout }) => | |
| 4055 | 4066 | stdout.split("\0\0").flatMap((record) => { |
| 4056 | 4067 | const fields = record.split("\0"); |
| 4057 | 4068 | const worktree = fields.find((field) => field.startsWith("worktree ")); |