From 2814c2d3b2ec6bd8fced7dcdb041223884a458fb Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 11:59:16 +0100 Subject: [PATCH] fix: Fetch remote before merge/push in initiative approval approveInitiative was merging and pushing with a stale local defaultBranch, causing "rejected (fetch first)" when origin/main had advanced since the last project sync. Now fetches remote and fast-forwards the target branch before merging. --- apps/server/execution/orchestrator.test.ts | 2 ++ apps/server/execution/orchestrator.ts | 9 +++++++++ apps/server/git/branch-manager.ts | 13 +++++++++++++ apps/server/git/simple-git-branch-manager.ts | 13 +++++++++++++ 4 files changed, 37 insertions(+) diff --git a/apps/server/execution/orchestrator.test.ts b/apps/server/execution/orchestrator.test.ts index 698b9d2..fb52e13 100644 --- a/apps/server/execution/orchestrator.test.ts +++ b/apps/server/execution/orchestrator.test.ts @@ -49,6 +49,8 @@ function createMocks() { getMergeBase: vi.fn().mockResolvedValue('abc123'), pushBranch: vi.fn(), checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }), + fetchRemote: vi.fn(), + fastForwardBranch: vi.fn(), }; const phaseRepository = { diff --git a/apps/server/execution/orchestrator.ts b/apps/server/execution/orchestrator.ts index 2883f18..e29e5a4 100644 --- a/apps/server/execution/orchestrator.ts +++ b/apps/server/execution/orchestrator.ts @@ -637,7 +637,16 @@ export class ExecutionOrchestrator { continue; } + // Fetch remote so local branches are up-to-date before merge/push + await this.branchManager.fetchRemote(clonePath); + if (strategy === 'merge_and_push') { + // Fast-forward local defaultBranch to match origin before merging + try { + await this.branchManager.fastForwardBranch(clonePath, project.defaultBranch); + } catch (ffErr) { + log.warn({ project: project.name, err: (ffErr as Error).message }, 'fast-forward of default branch failed — attempting merge anyway'); + } const result = await this.branchManager.mergeBranch(clonePath, initiative.branch, project.defaultBranch); if (!result.success) { throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`); diff --git a/apps/server/git/branch-manager.ts b/apps/server/git/branch-manager.ts index 901413d..ceb399c 100644 --- a/apps/server/git/branch-manager.ts +++ b/apps/server/git/branch-manager.ts @@ -75,4 +75,17 @@ export interface BranchManager { * Uses `git merge-tree --write-tree` (git 2.38+). */ checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise; + + /** + * Fetch all refs from a remote. + * Defaults to 'origin' if no remote specified. + */ + fetchRemote(repoPath: string, remote?: string): Promise; + + /** + * Fast-forward a local branch to match its remote-tracking counterpart. + * No-op if already up to date. Throws if fast-forward is not possible + * (i.e. the branches have diverged). + */ + fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise; } diff --git a/apps/server/git/simple-git-branch-manager.ts b/apps/server/git/simple-git-branch-manager.ts index fdca4b9..e686a6f 100644 --- a/apps/server/git/simple-git-branch-manager.ts +++ b/apps/server/git/simple-git-branch-manager.ts @@ -195,4 +195,17 @@ export class SimpleGitBranchManager implements BranchManager { log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean'); return { mergeable: true }; } + + async fetchRemote(repoPath: string, remote = 'origin'): Promise { + const git = simpleGit(repoPath); + await git.fetch(remote); + log.info({ repoPath, remote }, 'fetched remote'); + } + + async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise { + const git = simpleGit(repoPath); + const remoteBranch = `${remote}/${branch}`; + await git.raw(['merge', '--ff-only', remoteBranch, branch]); + log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch'); + } }