Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,12 @@ it.effect("coalesces concurrent ref pages into one repository snapshot", () =>
yield* writeTextFile(cwd, "README.md", "# test\n");
yield* runGit(["add", "."]);
yield* runGit(["commit", "-m", "initial commit"]);
// `git worktree list -z` needs git 2.36+; count the per-snapshot fallback scan on older git.
// Spawned via the delegate so the counting spawner's one-shot delay does not consume it.
const zProbeHandle = yield* delegate.spawn(
ChildProcess.make("git", ["worktree", "list", "--porcelain", "-z"], { cwd }),
);
const supportsWorktreeListZ = (yield* zProbeHandle.exitCode) === 0;
yield* Ref.set(spawnedArgs, []);

const initialRequest = yield* driver
Expand Down Expand Up @@ -474,7 +480,7 @@ it.effect("coalesces concurrent ref pages into one repository snapshot", () =>
(args) => args.includes("worktree") && args.includes("--porcelain"),
);
assert.equal(snapshotRefScans.length, 1);
assert.equal(worktreeScans.length, 1);
assert.equal(worktreeScans.length, supportsWorktreeListZ ? 1 : 2);

yield* driver.createRef({ cwd, refName: "feature/cache-invalidation" });
const refreshed = yield* driver.listRefs({ cwd, limit: 100 });
Expand Down Expand Up @@ -623,6 +629,56 @@ it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () =>
).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
);

it.effect("falls back to line-delimited worktree porcelain when -z is unsupported", () =>
Effect.scoped(
Effect.gen(function* () {
const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
const noZSpawner = 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")) {
// Git rejected `worktree list -z` before 2.36.
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, noZSpawner),
);
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));

const worktreePath = yield* makeTmpDir();
yield* git(cwd, ["worktree", "add", "-b", "feature/fallback", worktreePath]).pipe(
Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
);

const refs = yield* driver.listRefs({ cwd, refresh: true });
const listedPath = refs.refs.find((ref) => ref.name === "feature/fallback")?.worktreePath;

assert.equal(
listedPath === null ? null : NodeFS.realpathSync.native(listedPath),
NodeFS.realpathSync.native(worktreePath),
);
}),
).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
);

it.effect("marks the current branch when worktree metadata is unavailable", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -2328,24 +2384,36 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
() =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* initRepoWithCommit(cwd);
const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-");
const pathService = yield* Path.Path;
const worktreePath = pathService.join(worktreesRoot, "linked\nworktree");
const driver = yield* GitVcsDriver.GitVcsDriver;

yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]);

// `git worktree list -z` (git 2.36+) is the only form that can represent newline
// paths; older git emits them raw, which the line-delimited porcelain cannot carry.
const zListResult = yield* driver.execute({
operation: "GitVcsDriver.test.worktreeListZ",
cwd,
args: ["worktree", "list", "--porcelain", "-z"],
allowNonZeroExit: true,
timeoutMs: 10_000,
});
const supportsZ = zListResult.exitCode === 0;

const refs = yield* driver.listRefs({ cwd, refresh: true });
const listedPath = refs.refs.find(
(ref) => ref.name === "feature/newline-path",
)?.worktreePath;

if (typeof listedPath !== "string") {
return assert.fail("expected the linked branch to include its worktree path");
if (!supportsZ) {
assert.equal(listedPath, null);
return;
}
assert.equal(
NodeFS.realpathSync.native(listedPath),
NodeFS.realpathSync.native(listedPath as string),
NodeFS.realpathSync.native(worktreePath),
);
}),
Expand Down
40 changes: 34 additions & 6 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,14 @@ function paginateBranches(input: {
};
}

function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> {
/**
* Parses `git worktree list --porcelain` output, NUL-delimited with `-z` or line-delimited
* without it. Newline paths are only representable in the `-z` form, which git gained in 2.36.
*/
function parseWorktreeBranchPaths(
stdout: string,
fieldSeparator: "\0" | "\n",
): ReadonlyMap<string, string> {
const worktreePaths = new Map<string, string>();
let currentPath: string | null = null;
let currentBranch: string | null = null;
Expand All @@ -286,7 +293,7 @@ function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> {
currentPrunable = false;
};

for (const field of stdout.split("\0")) {
for (const field of stdout.split(fieldSeparator)) {
if (field === "") {
flush();
} else if (field.startsWith("worktree ")) {
Expand Down Expand Up @@ -2816,13 +2823,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
defaultRefResult.exitCode === 0
? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
: null;
let worktreeList: { stdout: string; fieldSeparator: "\0" | "\n" } | null = null;
if (worktreeListResult.exitCode === 0) {
worktreeList = { stdout: worktreeListResult.stdout, fieldSeparator: "\0" };
} else {
// `git worktree list` gained `-z` in git 2.36; older git rejects the flag, losing every
// worktree path. Retry with the line-delimited porcelain, which cannot represent newline
// paths but covers everything else.
const textListResult = yield* executeGit(
"GitVcsDriver.listRefs.worktreeListText",
fetchCwd,
[...gitDirArgs, "worktree", "list", "--porcelain"],
{
timeoutMs: 30_000,
allowNonZeroExit: true,
maxOutputBytes: 16 * 1024 * 1024,
},
);
if (textListResult.exitCode === 0) {
worktreeList = { stdout: textListResult.stdout, fieldSeparator: "\n" };
}
}
const parsedWorktreeEntries =
worktreeListResult.exitCode === 0
? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
worktreeList === null
? []
: [...parseWorktreeBranchPaths(worktreeList.stdout, worktreeList.fieldSeparator)].map(
([branchName, worktreePath]) =>
[branchName, path.normalize(path.resolve(worktreePath))] as const,
)
: [];
);
const existingWorktreeEntries = yield* Effect.filter(
parsedWorktreeEntries,
([, worktreePath]) =>
Expand Down
Loading