authorgravatar for snorteman@gmail.comScott Norteman <snorteman@gmail.com> 2026-10-10 07:31:01-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 06:31:01-05:00
logadbecdac7b6f1a167cc389b3d5fe5be8ec70afdf
treea021e07ced431a616f814e3643bff9c9874e5b3b
parent7b09ac7e0022769425471fad1c0d152c03d5954f
signature Signed by PGP key B5690EEEBB952194

fix(server): a pull that fast-forwards no longer fails on large Git output (#17376)

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

2 files changed, 89 insertions(+), 0 deletions(-)

apps/server/src/vcs/GitVcsDriverCore.test.ts+85
...@@ -496,6 +496,91 @@ it.effect("coalesces concurrent ref pages into one repository snapshot", () =>...@@ -496,6 +496,91 @@ it.effect("coalesces concurrent ref pages into one repository snapshot", () =>
496 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),496 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
497);497);
498498
499describe("pull output beyond the output cap", () => {
500 // A fast-forward that prints over 1 MB takes thousands of files, so the real pull's output is
501 // padded past the cap instead.
502 const withPaddedPull = (stream: "stdout" | "stderr") =>
503 Effect.gen(function* () {
504 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
505 const padding = new Uint8Array(1_000_001).fill(0x20);
506 const paddingSpawner = ChildProcessSpawner.make((command) =>
507 Effect.gen(function* () {
508 const handle = yield* delegate.spawn(command);
509 const isPull =
510 ChildProcess.isStandardCommand(command) &&
511 command.args.includes("pull") &&
512 command.args.includes("--ff-only");
513 return isPull
514 ? ChildProcessSpawner.makeHandle({
515 ...handle,
516 [stream]: Stream.concat(handle[stream], Stream.make(padding)),
517 })
518 : handle;
519 }),
520 );
521 return yield* makeGitVcsDriverCore().pipe(
522 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, paddingSpawner),
523 );
524 });
525
526 const cloneBehindRemote = Effect.gen(function* () {
527 const cwd = yield* makeTmpDir();
528 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
529 const updater = yield* makeTmpDir("git-vcs-driver-updater-");
530 const { initialBranch } = yield* initRepoWithCommit(cwd);
531 yield* git(remote, ["init", "--bare"]);
532 yield* git(cwd, ["remote", "add", "origin", remote]);
533 yield* git(cwd, ["push", "-u", "origin", initialBranch]);
534 yield* git(updater, ["clone", remote, "."]);
535 yield* git(updater, ["config", "user.email", "test@test.com"]);
536 yield* git(updater, ["config", "user.name", "Test"]);
537 yield* writeTextFile(updater, "remote.txt", "remote\n");
538 yield* git(updater, ["add", "remote.txt"]);
539 yield* git(updater, ["commit", "-m", "remote commit"]);
540 yield* git(updater, ["push", "origin", initialBranch]);
541 return { cwd, remoteHead: yield* git(updater, ["rev-parse", "HEAD"]) };
542 });
543
544 it.effect.each(["stdout", "stderr"] as const)(
545 "reports a fast-forward whose %s passes the cap as pulled",
546 (stream) =>
547 Effect.scoped(
548 Effect.gen(function* () {
549 const driver = yield* withPaddedPull(stream);
550 yield* Effect.gen(function* () {
551 const { cwd, remoteHead } = yield* cloneBehindRemote;
552
553 const result = yield* driver.pullCurrentBranch(cwd);
554
555 assert.equal(result.status, "pulled");
556 assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteHead);
557 }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
558 }),
559 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
560 );
561
562 it.effect("still fails a pull that Git rejects, whatever its output size", () =>
563 Effect.scoped(
564 Effect.gen(function* () {
565 const driver = yield* withPaddedPull("stderr");
566 yield* Effect.gen(function* () {
567 const { cwd } = yield* cloneBehindRemote;
568 // A local commit the remote lacks: `pull --ff-only` cannot fast-forward and exits 128.
569 yield* writeTextFile(cwd, "local.txt", "local\n");
570 yield* git(cwd, ["add", "local.txt"]);
571 yield* git(cwd, ["commit", "-m", "local commit"]);
572
573 const error = yield* Effect.flip(driver.pullCurrentBranch(cwd));
574
575 assert.equal(error.operation, "GitVcsDriver.pullCurrentBranch.pull");
576 assert.equal(error.exitCode, 128);
577 assert.notInclude(error.detail, "exceeded");
578 }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
579 }),
580 ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
581 );
582});
583
499it.effect("retries an in-flight ref snapshot invalidated by a mutation", () =>584it.effect("retries an in-flight ref snapshot invalidated by a mutation", () =>
500 Effect.scoped(585 Effect.scoped(
501 Effect.gen(function* () {586 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriverCore.ts+4
...@@ -2518,9 +2518,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2518,9 +2518,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2518 ["rev-parse", "HEAD"],2518 ["rev-parse", "HEAD"],
2519 true,2519 true,
2520 ).pipe(Effect.map((stdout) => stdout.trim()));2520 ).pipe(Effect.map((stdout) => stdout.trim()));
2521 // After a successful pull, HEAD before and after decides the result, so its output is not
2522 // needed. A large fast-forward's diffstat can exceed the output cap, which must not fail a
2523 // pull Git applied.
2521 yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {2524 yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
2522 timeoutMs: 30_000,2525 timeoutMs: 30_000,
2523 fallbackErrorDetail: "git pull failed",2526 fallbackErrorDetail: "git pull failed",
2527 appendTruncationMarker: true,
2524 });2528 });
2525 const afterSha = yield* runGitStdout(2529 const afterSha = yield* runGitStdout(
2526 "GitVcsDriver.pullCurrentBranch.afterSha",2530 "GitVcsDriver.pullCurrentBranch.afterSha",