From 47865be5e33d4c9266535ba16bf53c1f0f92974f Mon Sep 17 00:00:00 2001 From: Enis Date: Wed, 19 Aug 2026 09:38:27 +0200 Subject: [PATCH] feat(vcs): auto-rebase and retry a push when the remote moved ahead pushCurrentBranch failed hard on a non-fast-forward rejection, leaving the user to fetch/rebase/push by hand. Now, when a push is rejected only because the remote branch has commits the local branch doesn't, the driver fetches that remote branch, rebases the local commits onto it, and retries the push once. A rebase that hits conflicts is aborted so the working tree is never left mid-rebase, and the conflict is surfaced with actionable detail. Auth/branch-protection failures still fail fast. Applies to all remote-targeted push paths (existing upstream, new upstream, and explicit remote). Co-Authored-By: Claude Opus 4.8 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 89 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 79 ++++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index b10ee1ab4c66..0346516c9af3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1851,5 +1851,94 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notEqual(originMain.exitCode, 0); }), ); + + it.effect("auto-rebases onto the remote and retries when the remote moved ahead", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + const base = yield* git(cwd, ["rev-parse", "HEAD"]); + + // Advance the remote by one commit, then rewind our checkout to the base + // and commit something else — the branches now genuinely diverge, which + // is the classic non-fast-forward push rejection. + yield* writeTextFile(cwd, "remote.txt", "remote\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Add remote update"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", base]); + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add local update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "main", + upstreamBranch: "origin/main", + setUpstream: false, + }); + // The remote now carries both commits, with ours rebased on top. + const remoteLog = yield* git(remote, ["log", "--pretty=%s", "main"]); + assert.match(remoteLog, /Add local update/); + assert.match(remoteLog, /Add remote update/); + assert.equal( + remoteLog.indexOf("Add local update") < remoteLog.indexOf("Add remote update"), + true, + ); + // History stayed linear — our commit sits directly on the remote one. + assert.equal( + yield* git(cwd, ["log", "--pretty=%s", "-2"]), + "Add local update\nAdd remote update", + ); + }), + ); + + it.effect("aborts the auto-rebase and leaves a clean tree when it conflicts", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + const base = yield* git(cwd, ["rev-parse", "HEAD"]); + + // Both the remote commit and our commit edit the SAME file, so rebasing + // our commit onto the remote can't apply cleanly. + yield* writeTextFile(cwd, "README.md", "# remote\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Remote edits readme"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", base]); + yield* writeTextFile(cwd, "README.md", "# local\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Local edits readme", ""); + + const error = yield* driver.pushCurrentBranch(cwd, null).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitCommandError"); + assert.match(error.detail, /rebasing onto it hit conflicts/i); + // The abort restored a clean checkout: not mid-rebase, our commit intact. + const rebaseInProgress = yield* driver.execute({ + operation: "GitVcsDriver.test.rebaseState", + cwd, + args: ["rev-parse", "--verify", "--quiet", "REBASE_HEAD"], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + assert.notEqual(rebaseInProgress.exitCode, 0); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "main"); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Local edits readme"); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 8b1281fae6f9..13c6f2d81049 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -459,6 +459,16 @@ export function describeGitRemoteRejection(output: string): string | null { return null; } +/** + * A push was rejected only because the remote moved ahead of us (the classic + * "non-fast-forward" / "fetch first" rejection). This is the recoverable case + * the push path auto-rebases and retries; auth/protection failures are not. + */ +export function isNonFastForwardRejection(error: GitCommandError): boolean { + const normalized = error.detail.toLowerCase(); + return normalized.includes("non-fast-forward") || normalized.includes("fetch first"); +} + function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; @@ -2065,6 +2075,63 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); + /** + * Run a push and, if it is rejected solely because the remote moved ahead, + * fetch that remote branch, rebase our commits onto it, and retry the push + * once. This makes an ordinary push self-heal the common "remote branch has + * commits this branch doesn't" case instead of failing and asking the user to + * reconcile by hand. A rebase that hits conflicts is aborted so the working + * tree is never left mid-rebase, and the original non-fast-forward is + * surfaced with actionable detail. Only non-fast-forward rejections are + * retried; auth/protection failures still fail fast. + */ + const runPushWithAutoRebase = ( + operation: string, + cwd: string, + pushArgs: readonly string[], + authEnv: NodeJS.ProcessEnv, + remoteName: string, + remoteBranch: string, + ): Effect.Effect => + runGitWithEnv(operation, cwd, pushArgs, authEnv).pipe( + Effect.catchIf(isNonFastForwardRejection, () => + Effect.gen(function* () { + // Refresh the exact remote branch we are pushing to; FETCH_HEAD then + // points at its current tip regardless of local tracking config. + yield* runGitWithEnv( + `${operation}.autoRebaseFetch`, + cwd, + ["fetch", remoteName, remoteBranch], + authEnv, + ); + const rebaseResult = yield* executeGit( + `${operation}.autoRebase`, + cwd, + ["rebase", "FETCH_HEAD"], + { allowNonZeroExit: true, env: authEnv, timeoutMs: 120_000 }, + ); + if (rebaseResult.exitCode !== 0) { + // Abort so the repo is left clean rather than mid-rebase; ignore the + // abort's own exit code (nothing more we can do if it fails). + yield* executeGit(`${operation}.autoRebaseAbort`, cwd, ["rebase", "--abort"], { + allowNonZeroExit: true, + }); + return yield* new GitCommandError({ + ...gitCommandContext({ operation, cwd, args: pushArgs }), + detail: appendGitOutputToDetail( + "The remote branch has commits this branch doesn't, and automatically rebasing onto it hit conflicts. Resolve the conflicts locally, then push again.", + rebaseResult.stdout, + rebaseResult.stderr, + ), + }); + } + // History is now linear on top of the remote; retry once (no further + // auto-rebase, so a second rejection fails fast). + yield* runGitWithEnv(operation, cwd, pushArgs, authEnv); + }), + ), + ); + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { @@ -2088,11 +2155,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const requestedRemoteName = options?.remoteName?.trim() || null; if (requestedRemoteName) { const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGitWithEnv( + yield* runPushWithAutoRebase( "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, ["push", "-u", requestedRemoteName, `HEAD:refs/heads/${publishBranch}`], authEnv, + requestedRemoteName, + publishBranch, ); return { status: "pushed" as const, @@ -2151,11 +2220,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGitWithEnv( + yield* runPushWithAutoRebase( "GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, ["push", "-u", publishRemoteName, `HEAD:refs/heads/${publishBranch}`], authEnv, + publishRemoteName, + publishBranch, ); return { status: "pushed" as const, @@ -2169,11 +2240,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { - yield* runGitWithEnv( + yield* runPushWithAutoRebase( "GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, ["push", currentUpstream.remoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], authEnv, + currentUpstream.remoteName, + currentUpstream.branchName, ); return { status: "pushed" as const,