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
89 changes: 89 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}),
);
});
});
79 changes: 76 additions & 3 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}/`;
Expand Down Expand Up @@ -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<void, GitCommandError> =>
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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down