feat(FN-2160): add push-after-merge remote sync workflow

- Add project settings for pushAfterMerge and pushRemote with defaults and typed merge result fields for push status/errors
- Implement post-merge remote sync in the merger with pull --rebase, auto/AI conflict resolution, and one non-fast-forward retry before push
- Expose push-after-merge controls in Settings modal with conditional Push Remote input and coverage for desktop/mobile save flows
- Document the new settings in the settings reference and stabilize CLI cross-build help test timeout
This commit is contained in:
Fusion
2026-04-19 13:29:59 -07:00
committed by gsxdsm
parent 4c08b1c32a
commit a93f0f2ef9
9 changed files with 821 additions and 1 deletions

View File

@@ -91,6 +91,7 @@ vi.mock("./context-limit-detector.js", () => ({
import {
aiMergeTask,
pushToRemoteAfterMerge,
findWorktreeUser,
detectResolvableConflicts,
autoResolveFile,
@@ -439,6 +440,369 @@ describe("aiMergeTask — empty squash merge (branch already merged via dep)", (
});
});
describe("push-after-merge", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
function setupAiMergeExecSyncWithPush(pushBehavior?: (attempt: number) => void) {
let pushAttempts = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123" as any;
if (cmdStr.includes("git log HEAD..")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("git merge --squash")) return Buffer.from("");
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("git diff --cached --quiet")) {
// First call: squash-empty check, second call: post-agent commit verification.
return "1" as any;
}
if (cmdStr.startsWith("git commit ")) return Buffer.from("");
if (cmdStr.includes("git show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.startsWith("git pull --rebase")) return Buffer.from("");
if (cmdStr.startsWith("git push ")) {
pushAttempts += 1;
pushBehavior?.(pushAttempts);
return Buffer.from("");
}
return Buffer.from("");
});
}
it("pushes merged result when pushAfterMerge is enabled", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
mergeStrategy: "direct",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes('git pull --rebase "origin" "main"')),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes('git push "origin" "main"')),
).toBe(true);
});
it("does not push when pushAfterMerge is disabled (default)", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBeUndefined();
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git pull --rebase")),
).toBe(false);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git push ")),
).toBe(false);
});
it("does not push for pull-request merge strategy", async () => {
setupAiMergeExecSyncWithPush();
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeStrategy: "pull-request",
pushAfterMerge: true,
pushRemote: "origin",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.pushedToRemote).toBeUndefined();
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git pull --rebase")),
).toBe(false);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git push ")),
).toBe(false);
});
it("records push error but still completes merge when push fails", async () => {
setupAiMergeExecSyncWithPush((attempt) => {
if (attempt === 1) {
const err = new Error("failed to push some refs");
(err as Error & { stderr?: string }).stderr = "remote: permission denied";
throw err;
}
});
const store = createMockStore();
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
mergeStrategy: "direct",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
expect(result.pushedToRemote).toBe(false);
expect(result.pushError).toContain("permission denied");
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "done");
});
it("uses custom remote and branch when configured", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith("git pull --rebase \"upstream\" \"main\"")) return Buffer.from("");
if (cmdStr.startsWith("git push \"upstream\" \"main\"")) return Buffer.from("");
if (cmdStr.includes("rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision");
throw err;
}
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "upstream main",
});
expect(result.pushed).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git pull --rebase "upstream" "main"')),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git push "upstream" "main"')),
).toBe(true);
});
it("auto-resolves lock-file rebase conflicts and continues", async () => {
let rebaseInProgress = false;
let hasConflicts = false;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
hasConflicts = true;
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in pnpm-lock.yaml";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) {
return hasConflicts ? "pnpm-lock.yaml" as any : "" as any;
}
if (cmdStr.startsWith('git checkout --ours "pnpm-lock.yaml"')) return Buffer.from("");
if (cmdStr.startsWith('git add "pnpm-lock.yaml"')) {
hasConflicts = false;
return Buffer.from("");
}
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("GIT_EDITOR=true git rebase --continue")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) return Buffer.from("");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git checkout --ours "pnpm-lock.yaml"')),
).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("GIT_EDITOR=true git rebase --continue")),
).toBe(true);
});
it("uses AI to resolve complex rebase conflicts", async () => {
let rebaseInProgress = false;
let hasConflicts = false;
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn(async () => {
hasConflicts = false;
}),
dispose: vi.fn(),
},
} as any);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
hasConflicts = true;
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in src/app.ts";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) {
return hasConflicts ? "src/app.ts" as any : "" as any;
}
if (cmdStr.startsWith("git diff-tree -p -w")) return "@@\n-foo\n+bar" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("GIT_EDITOR=true git rebase --continue")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) return Buffer.from("");
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(mockedCreateHaiAgent).toHaveBeenCalled();
});
it("retries push once after non-fast-forward rejection", async () => {
let pushAttempts = 0;
let pullAttempts = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
pullAttempts += 1;
return Buffer.from("");
}
if (cmdStr.startsWith('git push "origin" "main"')) {
pushAttempts += 1;
if (pushAttempts === 1) {
const err = new Error("non-fast-forward") as Error & { stderr?: string };
err.stderr = "[rejected] main -> main (non-fast-forward)";
throw err;
}
return Buffer.from("");
}
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(true);
expect(pullAttempts).toBe(2);
expect(pushAttempts).toBe(2);
});
it("aborts rebase when conflicts remain unresolved", async () => {
let rebaseInProgress = false;
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.startsWith('git pull --rebase "origin" "main"')) {
rebaseInProgress = true;
const err = new Error("rebase conflict") as Error & { stderr?: string };
err.stderr = "CONFLICT (content): Merge conflict in src/app.ts";
throw err;
}
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "src/app.ts" as any;
if (cmdStr.startsWith("git diff-tree -p -w")) return "@@\n-foo\n+bar" as any;
if (cmdStr.includes("git rev-parse --verify REBASE_HEAD")) {
if (rebaseInProgress) return "rebasehead" as any;
const err = new Error("fatal: Needed a single revision") as Error & { status?: number };
err.status = 128;
throw err;
}
if (cmdStr.startsWith("git rebase --abort")) {
rebaseInProgress = false;
return Buffer.from("");
}
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
return Buffer.from("");
});
const store = createMockStore();
const result = await pushToRemoteAfterMerge(store, "/tmp/root", "FN-050", {
...DEFAULT_SETTINGS,
pushAfterMerge: true,
pushRemote: "origin",
});
expect(result.pushed).toBe(false);
expect(result.error).toContain("unable to resolve rebase conflicts");
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git rebase --abort")),
).toBe(true);
});
});
describe("aiMergeTask — includeTaskIdInCommit setting", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -71,6 +71,8 @@ const DEPENDENCY_SYNC_TRIGGER_PATTERNS = [
const VERIFICATION_COMMAND_MAX_BUFFER = 50 * 1024 * 1024;
const VERIFICATION_LOG_MAX_CHARS = 20_000;
const WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS = 4_000;
const PULL_REBASE_TIMEOUT_MS = 120_000;
const PUSH_TIMEOUT_MS = 60_000;
/** Maximum characters for commit log in merge prompt — prevents context overflow on large branches */
const MERGE_COMMIT_LOG_MAX_CHARS = 5000;
@@ -1307,6 +1309,327 @@ export interface MergerOptions {
agentStore?: import("@fusion/core").AgentStore;
}
function quoteArg(value: string): string {
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
}
function getCommandErrorMessage(error: unknown): string {
if (error instanceof Error) {
const stderr = (error as Error & { stderr?: string | Buffer }).stderr;
if (typeof stderr === "string" && stderr.trim()) return stderr.trim();
if (Buffer.isBuffer(stderr) && stderr.toString().trim()) return stderr.toString().trim();
return error.message;
}
return String(error);
}
function isNonFastForwardPushError(message: string): boolean {
const normalized = message.toLowerCase();
return normalized.includes("non-fast-forward")
|| normalized.includes("[rejected]")
|| normalized.includes("fetch first")
|| normalized.includes("failed to push some refs");
}
function isRebaseInProgress(rootDir: string): boolean {
try {
execSync("git rev-parse --verify REBASE_HEAD", {
cwd: rootDir,
stdio: "pipe",
});
return true;
} catch {
return false;
}
}
function parsePushRemoteTarget(rootDir: string, pushRemote?: string): { remote: string; branch: string } {
const rawTarget = pushRemote?.trim() || "origin";
const [remoteToken, ...branchTokens] = rawTarget.split(/\s+/).filter(Boolean);
const remote = remoteToken || "origin";
let branch = branchTokens.join(" ").trim();
if (!branch) {
branch = execSync("git symbolic-ref --short HEAD", {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
}).trim();
}
if (!branch) {
throw new Error(`Unable to determine branch for push target "${rawTarget}"`);
}
return { remote, branch };
}
async function resolveComplexRebaseConflictsWithAi(
store: TaskStore,
rootDir: string,
taskId: string,
settings: Settings,
conflictedFiles: string[],
options?: { onAgentText?: (delta: string) => void },
): Promise<void> {
mergerLog.log(`${taskId}: resolving ${conflictedFiles.length} complex rebase conflict(s) with AI`);
const includeTaskId = settings.includeTaskIdInCommit !== false;
const authorArg = getCommitAuthorArg(settings);
const basePrompt = buildMergeSystemPrompt(includeTaskId, settings.agentPrompts, authorArg);
const systemPrompt = `${basePrompt}
## Rebase conflict-only mode
You are assisting with a paused \`git pull --rebase\`.
- Resolve conflicted files and stage them with \`git add\`.
- Do NOT run \`git commit\`, \`git merge\`, or \`git rebase --continue\`.
- Do NOT perform unrelated edits outside conflicted files.
- Finish when all conflicts are resolved and staged.`;
const agentLogger = new AgentLogger({
store,
taskId,
agent: "merger",
onAgentText: options?.onAgentText
? (_id, delta) => options.onAgentText?.(delta)
: undefined,
});
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt,
tools: "coding",
onText: agentLogger.onText,
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
const prompt = [
`Resolve rebase conflicts for task ${taskId}.`,
"",
"Conflicted files:",
...conflictedFiles.map((file) => `- ${file}`),
"",
"After resolving each file, stage it with `git add <file>`. Do not create a commit.",
].join("\n");
try {
await withRateLimitRetry(async () => {
await promptWithFallback(session, prompt);
checkSessionError(session);
}, {
onRetry: (attempt, delayMs, error) => {
mergerLog.warn(
`${taskId}: rate limited while resolving rebase conflicts — retry ${attempt} in ${Math.round(delayMs / 1000)}s: ${error.message}`,
);
},
});
} finally {
session.dispose();
}
}
async function resolveRebaseConflictSet(
store: TaskStore,
rootDir: string,
taskId: string,
settings: Settings,
options?: { onAgentText?: (delta: string) => void },
): Promise<void> {
const conflictedFiles = await getConflictedFiles(rootDir);
if (conflictedFiles.length === 0) return;
mergerLog.log(`${taskId}: found ${conflictedFiles.length} rebase conflict(s)`);
const complexFiles: string[] = [];
for (const file of conflictedFiles) {
const conflictType = await classifyConflict(file, rootDir);
if (conflictType === "lockfile-ours") {
await resolveWithOurs(file, rootDir);
continue;
}
if (conflictType === "generated-theirs") {
await resolveWithTheirs(file, rootDir);
continue;
}
if (conflictType === "trivial-whitespace") {
await resolveTrivialWhitespace(file, rootDir);
continue;
}
complexFiles.push(file);
}
if (complexFiles.length > 0) {
await resolveComplexRebaseConflictsWithAi(store, rootDir, taskId, settings, complexFiles, options);
}
const remaining = await getConflictedFiles(rootDir);
if (remaining.length > 0) {
throw new Error(`Unresolved rebase conflicts remain: ${remaining.join(", ")}`);
}
}
async function pullWithRebaseAndResolveConflicts(
store: TaskStore,
rootDir: string,
taskId: string,
settings: Settings,
remote: string,
branch: string,
options?: { onAgentText?: (delta: string) => void },
): Promise<void> {
const pullCommand = `git pull --rebase ${quoteArg(remote)} ${quoteArg(branch)}`;
try {
await execAsync(pullCommand, {
cwd: rootDir,
timeout: PULL_REBASE_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
encoding: "utf-8",
});
mergerLog.log(`${taskId}: git pull --rebase succeeded for ${remote}/${branch}`);
return;
} catch (pullError: unknown) {
const conflictedFiles = await getConflictedFiles(rootDir);
if (conflictedFiles.length === 0) {
throw new Error(`git pull --rebase failed: ${getCommandErrorMessage(pullError)}`);
}
mergerLog.warn(
`${taskId}: git pull --rebase produced ${conflictedFiles.length} conflict(s); attempting resolution`,
);
try {
await resolveRebaseConflictSet(store, rootDir, taskId, settings, options);
for (let attempt = 1; attempt <= 10; attempt++) {
if (!isRebaseInProgress(rootDir)) {
mergerLog.log(`${taskId}: rebase conflicts resolved`);
return;
}
try {
await execAsync("GIT_EDITOR=true git rebase --continue", {
cwd: rootDir,
timeout: PULL_REBASE_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
encoding: "utf-8",
});
mergerLog.log(`${taskId}: git rebase --continue succeeded (attempt ${attempt})`);
} catch (continueError: unknown) {
const currentConflicts = await getConflictedFiles(rootDir);
if (currentConflicts.length === 0) {
throw new Error(`git rebase --continue failed: ${getCommandErrorMessage(continueError)}`);
}
mergerLog.warn(`${taskId}: rebase continue hit additional conflicts; retrying resolution`);
await resolveRebaseConflictSet(store, rootDir, taskId, settings, options);
continue;
}
const remainingConflicts = await getConflictedFiles(rootDir);
if (remainingConflicts.length > 0) {
mergerLog.warn(`${taskId}: rebase continue left conflicts; retrying resolution`);
await resolveRebaseConflictSet(store, rootDir, taskId, settings, options);
continue;
}
}
throw new Error("Exceeded maximum rebase conflict resolution attempts");
} catch (resolutionError: unknown) {
if (isRebaseInProgress(rootDir)) {
try {
await execAsync("git rebase --abort", {
cwd: rootDir,
timeout: PUSH_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
encoding: "utf-8",
});
mergerLog.warn(`${taskId}: aborted rebase after unresolved conflicts`);
} catch (abortError: unknown) {
mergerLog.warn(`${taskId}: failed to abort rebase: ${getCommandErrorMessage(abortError)}`);
}
}
throw new Error(`unable to resolve rebase conflicts: ${getCommandErrorMessage(resolutionError)}`);
}
}
}
/**
* Push the merged result to the configured remote after a successful direct merge.
* Failures are non-fatal because the merge commit already exists locally.
*/
export async function pushToRemoteAfterMerge(
store: TaskStore,
rootDir: string,
taskId: string,
settings: Settings,
options?: { onAgentText?: (delta: string) => void },
): Promise<{ pushed: boolean; error?: string }> {
let target: { remote: string; branch: string };
try {
target = parsePushRemoteTarget(rootDir, settings.pushRemote);
} catch (error: unknown) {
const message = getCommandErrorMessage(error);
mergerLog.error(`${taskId}: invalid push remote configuration: ${message}`);
return { pushed: false, error: message };
}
const { remote, branch } = target;
mergerLog.log(`${taskId}: push-after-merge enabled; syncing ${remote}/${branch}`);
try {
await pullWithRebaseAndResolveConflicts(store, rootDir, taskId, settings, remote, branch, options);
} catch (error: unknown) {
const message = getCommandErrorMessage(error);
mergerLog.error(`${taskId}: pull --rebase before push failed: ${message}`);
return { pushed: false, error: message };
}
const pushCommand = `git push ${quoteArg(remote)} ${quoteArg(branch)}`;
try {
await execAsync(pushCommand, {
cwd: rootDir,
timeout: PUSH_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
encoding: "utf-8",
});
mergerLog.log(`${taskId}: pushed merged result to ${remote}/${branch}`);
return { pushed: true };
} catch (firstPushError: unknown) {
const firstMessage = getCommandErrorMessage(firstPushError);
mergerLog.warn(`${taskId}: initial push failed: ${firstMessage}`);
if (!isNonFastForwardPushError(firstMessage)) {
return { pushed: false, error: firstMessage };
}
mergerLog.log(`${taskId}: push rejected as non-fast-forward; retrying pull --rebase and push once`);
try {
await pullWithRebaseAndResolveConflicts(store, rootDir, taskId, settings, remote, branch, options);
await execAsync(pushCommand, {
cwd: rootDir,
timeout: PUSH_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
encoding: "utf-8",
});
mergerLog.log(`${taskId}: push succeeded after non-fast-forward retry`);
return { pushed: true };
} catch (retryError: unknown) {
const retryMessage = getCommandErrorMessage(retryError);
mergerLog.error(`${taskId}: push retry failed: ${retryMessage}`);
return { pushed: false, error: retryMessage };
}
}
}
/**
* AI-powered merge with 3-attempt retry logic when autoResolveConflicts is enabled.
*
@@ -1805,6 +2128,26 @@ export async function aiMergeTask(
}
}
// 7b. Push to remote if configured
if (settings.pushAfterMerge && settings.mergeStrategy !== "pull-request") {
try {
const pushResult = await pushToRemoteAfterMerge(store, rootDir, taskId, settings, options);
if (pushResult.pushed) {
mergerLog.log(`${taskId}: pushed merged result to remote`);
} else {
mergerLog.warn(`${taskId}: push to remote failed: ${pushResult.error}`);
}
result.pushedToRemote = pushResult.pushed;
if (pushResult.error) {
result.pushError = pushResult.error;
}
} catch (err: any) {
mergerLog.error(`${taskId}: push to remote error: ${err.message}`);
result.pushedToRemote = false;
result.pushError = err.message;
}
}
// 8. Run post-merge workflow steps (failures logged but do not block completion)
try {
await runPostMergeWorkflowSteps(store, taskId, rootDir, settings, options);