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,