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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user