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:
@@ -84,6 +84,8 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
||||
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
|
||||
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. |
|
||||
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |
|
||||
| `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. |
|
||||
| `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. |
|
||||
| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation. |
|
||||
| `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. |
|
||||
| `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). |
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("build-exe-cross: --all builds all platforms", () => {
|
||||
}
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("fn");
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe("build-exe-cross: default (no args) backward compatibility", () => {
|
||||
|
||||
@@ -63,6 +63,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
worktreeInitCommand: undefined,
|
||||
testCommand: undefined,
|
||||
buildCommand: undefined,
|
||||
|
||||
@@ -1045,6 +1045,16 @@ export interface ProjectSettings {
|
||||
* before merging through GitHub
|
||||
* Default: "direct" for backward compatibility. */
|
||||
mergeStrategy?: MergeStrategy;
|
||||
/** When true, automatically push to the configured remote after a successful direct merge.
|
||||
* The push process includes pulling the latest from the remote (rebase) first.
|
||||
* If conflicts arise during the pull, they are resolved using the AI conflict resolution pipeline.
|
||||
* Only applies when mergeStrategy is "direct". Default: false. */
|
||||
pushAfterMerge?: boolean;
|
||||
/** The git remote and branch to push to after merging (e.g. "origin", "origin main").
|
||||
* When set to just a remote name (e.g. "origin"), the current branch is pushed.
|
||||
* When set to "remote branch" format, both the remote and branch are specified.
|
||||
* Only used when pushAfterMerge is true. Default: "origin". */
|
||||
pushRemote?: string;
|
||||
/** Shell command to run inside each new worktree immediately after creation.
|
||||
* Useful for project-specific setup (e.g. `pnpm install`, `cp .env.local .env`). */
|
||||
worktreeInitCommand?: string;
|
||||
@@ -1426,6 +1436,10 @@ export interface MergeResult extends MergeDetails {
|
||||
worktreeRemoved: boolean;
|
||||
branchDeleted: boolean;
|
||||
error?: string;
|
||||
/** Whether the merged result was pushed to the remote. Only set when pushAfterMerge is enabled. */
|
||||
pushedToRemote?: boolean;
|
||||
/** Error message if push to remote failed. Non-fatal — merge is already committed locally. */
|
||||
pushError?: string;
|
||||
/** Internal flag to track if a build retry has been attempted. Not persisted. */
|
||||
_buildRetried?: boolean;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ const defaultSettings = {
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
recycleWorktrees: false,
|
||||
worktreeNaming: "random",
|
||||
includeTaskIdInCommit: true,
|
||||
@@ -633,6 +635,67 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Merge section", () => {
|
||||
it("shows push-after-merge toggle and keeps Push Remote hidden by default", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
||||
|
||||
const pushAfterMergeToggle = screen.getByRole("checkbox", {
|
||||
name: /push to remote after merge/i,
|
||||
});
|
||||
expect(pushAfterMergeToggle).not.toBeChecked();
|
||||
expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Push Remote input when push-after-merge is enabled", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Push Remote")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes pushAfterMerge and pushRemote in the save payload", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
||||
await userEvent.click(
|
||||
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
|
||||
);
|
||||
|
||||
const pushRemoteInput = screen.getByLabelText("Push Remote");
|
||||
await userEvent.clear(pushRemoteInput);
|
||||
await userEvent.type(pushRemoteInput, "upstream main");
|
||||
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
||||
expect(payload.pushAfterMerge).toBe(true);
|
||||
expect(payload.pushRemote).toBe("upstream main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Experimental Features section", () => {
|
||||
it("renders the Experimental Features section in the sidebar", async () => {
|
||||
renderModal();
|
||||
|
||||
@@ -2215,6 +2215,36 @@ export function SettingsModal({
|
||||
</label>
|
||||
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="pushAfterMerge" className="checkbox-label">
|
||||
<input
|
||||
id="pushAfterMerge"
|
||||
type="checkbox"
|
||||
checked={form.pushAfterMerge === true}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, pushAfterMerge: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Push to remote after merge
|
||||
</label>
|
||||
<small>When enabled, the merged result is automatically pushed to the configured git remote. This includes pulling the latest from the remote first (rebase) and resolving any conflicts with AI if needed.</small>
|
||||
</div>
|
||||
|
||||
{form.pushAfterMerge && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="pushRemote">Push Remote</label>
|
||||
<input
|
||||
id="pushRemote"
|
||||
type="text"
|
||||
placeholder="origin"
|
||||
value={form.pushRemote || ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, pushRemote: e.target.value || undefined }))
|
||||
}
|
||||
/>
|
||||
<small>Git remote to push to (e.g. "origin"). Can include branch name (e.g. "origin main"). Default: "origin".</small>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "memory": {
|
||||
|
||||
@@ -15,6 +15,8 @@ const defaultSettings = {
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
pushAfterMerge: false,
|
||||
pushRemote: "origin",
|
||||
recycleWorktrees: false,
|
||||
worktreeInitCommand: "",
|
||||
testCommand: "",
|
||||
|
||||
@@ -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