Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class GitWorkflowService extends Context.Service<
readonly fetchRemote: (input: {
readonly cwd: string;
readonly remoteName: string;
readonly refName?: string;
}) => Effect.Effect<void, GitCommandError>;
readonly remoteExists: (input: {
readonly cwd: string;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11119,6 +11119,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
assert.deepEqual(fetchRemote.mock.calls[0]?.[0], {
cwd: "/tmp/project",
remoteName: "origin",
refName: "main",
});
assert.deepEqual(remoteBranchExists.mock.calls[0]?.[0], {
cwd: "/tmp/project",
Expand Down
168 changes: 164 additions & 4 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2137,6 +2137,57 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
});

describe("worktree operations", () => {
it.effect(
"uses parallel checkout while preserving filters, hooks, and configured workers",
() =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* git(cwd, ["config", "filter.test.smudge", "sed s/original/filtered/g"]);
yield* writeTextFile(cwd, ".gitattributes", "asset.txt filter=test\n");
yield* writeTextFile(cwd, "asset.txt", "original\n");
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "filtered asset"]);
yield* writeTextFile(
cwd,
".git/hooks/post-checkout",
"#!/bin/sh\ngit config checkout.workers > checkout-workers\nexit 0\n",
);
yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755);

for (const [configured, expected] of [
[null, "0"],
["1", "1"],
] as const) {
if (configured !== null) yield* git(cwd, ["config", "checkout.workers", configured]);
const worktreePath = path.join(
yield* makeTmpDir("git-worktrees-"),
`workers-${expected}`,
);
yield* driver.createWorktree({
cwd,
path: worktreePath,
refName: initialBranch,
newRefName: `feature/workers-${expected}`,
});
assert.equal(
yield* fs.readFileString(path.join(worktreePath, "checkout-workers")),
`${expected}\n`,
);
assert.equal(
yield* fs.readFileString(path.join(worktreePath, "asset.txt")),
"filtered\n",
);
assert.equal(
yield* git(worktreePath, ["rev-parse", "HEAD"]),
yield* git(cwd, ["rev-parse", "HEAD"]),
);
}
}),
);
it("parses checkout progress lines from git's stderr", () => {
assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), {
percent: 78,
Expand Down Expand Up @@ -2266,11 +2317,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("reports checkout progress while creating a worktree", () =>
it.effect("reports checkout progress during parallel worktree creation", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
for (let index = 0; index < 5; index += 1) {
for (let index = 0; index < 200; index += 1) {
yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`);
}
yield* git(cwd, ["add", "."]);
Expand Down Expand Up @@ -2304,7 +2355,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
const updates = yield* Ref.get(seen);
assert.isAbove(updates.length, 1);
assert.equal(updates.at(-1)?.percent, 100);
assert.equal(updates.at(-1)?.total, 6);
assert.equal(updates.at(-1)?.total, 201);
const completed = updates.map((update) => update.completed);
assert.deepEqual(
completed,
Expand Down Expand Up @@ -2540,6 +2591,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
});

describe("remote operations", () => {
for (const failure of ["offline", "auth", "timeout"] as const) {
it.effect(`does not retry a scoped fetch after ${failure}`, () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
yield* git(cwd, ["remote", "add", "origin", "https://example.invalid/repo.git"]);
const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
const started = yield* Deferred.make<void>();
const attempts: Array<ReadonlyArray<string>> = [];
const spawner = ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
if (!ChildProcess.isStandardCommand(command))
return yield* Effect.die("unexpected command");
if (command.args[0] !== "fetch") return yield* delegate.spawn(command);
attempts.push(command.args);
yield* Deferred.succeed(started, undefined);
return ChildProcessSpawner.makeHandle({
...makeNonRepositoryHandle(),
exitCode:
failure === "timeout"
? Effect.never
: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
stderr: Stream.encodeText(
Stream.make(
failure === "auth"
? "fatal: Authentication failed"
: "fatal: Could not resolve host",
),
),
});
}),
);
const driver = yield* makeGitVcsDriverCore().pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provide(ServerConfigLayer),
);
const fetching = yield* driver
.fetchRemote({ cwd, remoteName: "origin", refName: "main" })
.pipe(Effect.result, Effect.forkChild({ startImmediately: true }));
yield* Deferred.await(started);
if (failure === "timeout") {
yield* TestClock.adjust("31 seconds");
yield* TestClock.adjust("31 seconds");
}
const result = yield* Fiber.join(fetching);
assert.isTrue(Result.isFailure(result));
assert.equal(attempts.length, 1);
if (Result.isFailure(result)) {
assert.equal(
result.failure.detail,
failure === "timeout"
? "Git command timed out."
: failure === "auth"
? "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."
: "Git could not reach the remote. Check the server's network connection and remote host, then retry.",
);
}
}),
);
}

it.effect("explains a real fetch failure for a missing local remote", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down Expand Up @@ -2579,7 +2691,15 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
assert.notEqual(beforeFetch, remoteHead);

const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.fetchRemote({ cwd, remoteName: "origin" });
yield* git(peer, ["push", "origin", "HEAD:refs/heads/unrelated"]);
yield* driver.fetchRemote({
cwd,
remoteName: "origin",
refName: initialBranch,
});
assert.isFalse(
yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }),
);

assert.equal(
yield* driver.remoteBranchExists({
Expand Down Expand Up @@ -2641,6 +2761,46 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
const status = yield* driver.statusDetails(worktreePath);
assert.equal(status.aheadCount, 0);
assert.equal(status.aheadOfDefaultCount, 0);

// Local-only bases still use the old full-fetch fallback.
yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "local-only" });
assert.isTrue(
yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }),
);

// A branch literally named origin/topic must retain the old lookup at
// refs/remotes/origin/origin/topic, not get stripped to origin/topic.
yield* git(peer, ["push", "origin", "HEAD:refs/heads/origin/topic"]);
yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "origin/topic" });
assert.isTrue(
yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "origin/topic" }),
);

// A custom no-force fetch refspec keeps its full-fetch semantics.
yield* git(peer, ["push", "origin", "HEAD:refs/heads/custom-policy"]);
yield* git(cwd, ["config", "remote.origin.fetch", "refs/heads/*:refs/remotes/origin/*"]);
yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: initialBranch });
assert.isTrue(
yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "custom-policy" }),
);

// Pruning needs the full remote refspec, not only the selected branch.
yield* git(cwd, ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"]);
yield* git(cwd, ["config", "fetch.prune", "true"]);
yield* git(peer, ["push", "origin", ":refs/heads/custom-policy"]);
yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: initialBranch });
assert.isFalse(
yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "custom-policy" }),
);

// A remote configured to fetch all tags also keeps its full branch fetch.
yield* git(cwd, ["config", "--unset", "fetch.prune"]);
yield* git(cwd, ["config", "remote.origin.tagOpt", "--tags"]);
yield* git(peer, ["push", "origin", "HEAD:refs/heads/tag-policy"]);
yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: initialBranch });
assert.isTrue(
yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "tag-policy" }),
);
}),
);

Expand Down
146 changes: 108 additions & 38 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3070,23 +3070,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const progress = options?.progress;
const onCheckoutProgress = progress?.onCheckoutProgress;

yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, {
fallbackErrorDetail: "git worktree add failed",
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
...(onCheckoutProgress
? {
// Git only prints checkout progress when stderr is a tty or the
// delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe.
env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" },
progress: {
onStderrLine: (line) => {
const parsed = parseGitCheckoutProgressLine(line);
return parsed ? onCheckoutProgress(parsed) : Effect.void;
// Git defaults to a single checkout worker unless the caller opts in.
// Respect an explicit checkout.workers setting while enabling Git's
// automatic worker count for the common unset case.
const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0";
yield* executeGit(
"GitVcsDriver.createWorktree",
input.cwd,
["-c", `checkout.workers=${checkoutWorkers}`, ...args],
{
fallbackErrorDetail: "git worktree add failed",
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
...(onCheckoutProgress
? {
// Git only prints checkout progress when stderr is a tty or the
// delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe.
env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" },
progress: {
onStderrLine: (line) => {
const parsed = parseGitCheckoutProgressLine(line);
return parsed ? onCheckoutProgress(parsed) : Effect.void;
},
},
},
}
: {}),
});
}
: {}),
},
);

if (progress?.onWorktreeClaimed) {
yield* progress.onWorktreeClaimed(worktreePath);
Expand Down Expand Up @@ -3273,28 +3282,89 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
env: STATUS_UPSTREAM_REFRESH_ENV,
fallbackErrorDetail: `git fetch ${input.remoteName} failed`,
};
yield* executeGitWithStableDiagnostics("GitVcsDriver.fetchRemote", input.cwd, args, {
...options,
allowNonZeroExit: true,
}).pipe(
Effect.flatMap((result) =>
result.exitCode === 0
? Effect.void
: Effect.fail(
new GitCommandError({
...gitCommandContext({
operation: "GitVcsDriver.fetchRemote",
cwd: input.cwd,
args,
}),
detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail,
...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
stdoutLength: result.stdout.length,
stderrLength: result.stderr.length,
}),
),
),
);
const runFetch = (fetchArgs: ReadonlyArray<string>) =>
executeGitWithStableDiagnostics("GitVcsDriver.fetchRemote", input.cwd, [...fetchArgs], {
...options,
allowNonZeroExit: true,
});
let fetchArgs: ReadonlyArray<string> = args;
let result;
// An explicit refspec must not silently override a repository's custom
// remote.fetch mapping or its force-update policy. Scope the common
// default mapping; keep the existing full fetch for all other setups.
const defaultFetchRefspec = `+refs/heads/*:refs/remotes/${input.remoteName}/*`;
const configuredFetchRefspecs = input.refName
? (yield* runGitStdout(
"GitVcsDriver.fetchRemote.readRefspecs",
input.cwd,
["config", "--get-all", `remote.${input.remoteName}.fetch`],
true,
))
.split(/\r?\n/)
.filter((line) => line.length > 0)
: [];
const pruneSettings = input.refName
? yield* Effect.forEach(
[
"fetch.prune",
"fetch.pruneTags",
`remote.${input.remoteName}.prune`,
`remote.${input.remoteName}.pruneTags`,
],
(key) =>
runGitStdout(
"GitVcsDriver.fetchRemote.readPruneSetting",
input.cwd,
["config", "--type=bool", "--get", key],
true,
).pipe(Effect.map((value) => value.trim() === "true")),
)
: [];
const tagOption = input.refName
? yield* readConfigValue(input.cwd, `remote.${input.remoteName}.tagOpt`)
: null;
if (
input.refName &&
parseRemoteRefWithRemoteNames(input.refName, [input.remoteName]) === null &&
configuredFetchRefspecs.length === 1 &&
configuredFetchRefspecs[0] === defaultFetchRefspec &&
!pruneSettings.some(Boolean) &&
tagOption !== "--tags"
) {
const branch = input.refName;
const scopedArgs = [
...args,
`+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`,
];
fetchArgs = scopedArgs;
result = yield* runFetch(scopedArgs);
// A local-only base has no matching remote branch. Preserve the old
// full-fetch behavior so callers can still discover other remote refs.
if (
result.exitCode !== 0 &&
result.stderr
.split(/\r?\n/)
.includes(`fatal: couldn't find remote ref refs/heads/${branch}`)
) {
fetchArgs = args;
result = yield* runFetch(args);
}
} else {
result = yield* runFetch(args);
}
if (result.exitCode !== 0) {
return yield* new GitCommandError({
...gitCommandContext({
operation: "GitVcsDriver.fetchRemote",
cwd: input.cwd,
args: fetchArgs,
}),
detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail,
...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
stdoutLength: result.stdout.length,
stderrLength: result.stderr.length,
});
}
},
);

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1342,6 +1342,7 @@ const makeWsRpcLayer = (
yield* gitWorkflow.fetchRemote({
cwd: prepareWorktree.projectCwd,
remoteName: "origin",
refName: prepareWorktree.baseBranch,
});
const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({
cwd: prepareWorktree.projectCwd,
Expand Down
Loading