feat: Add merge conflict detection and agent resolution in initiative review

Pre-merge mergeability check via `git merge-tree --write-tree` (dry-run, no
side effects). When conflicts exist the "Merge & Push" button is disabled and
a ConflictResolutionPanel shows conflict files with options to resolve manually
or spawn a conflict-resolution agent. Agent questions appear inline via
QuestionForm; on completion the mergeability re-checks automatically.

New server-side: MergeabilityResult type, BranchManager.checkMergeability,
conflict-resolution prompt, checkInitiativeMergeability query,
spawnConflictResolutionAgent mutation, getActiveConflictAgent query.

New frontend: useConflictAgent hook, ConflictResolutionPanel component,
mergeability badge + panel integration in InitiativeReview.
This commit is contained in:
Lukas May
2026-03-06 11:17:25 +01:00
parent 3a01b9e9ca
commit 6cf6bd076f
17 changed files with 745 additions and 9 deletions

View File

@@ -6,7 +6,7 @@
* a worktree to be checked out.
*/
import type { MergeResult, BranchCommit } from './types.js';
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
export interface BranchManager {
/**
@@ -68,4 +68,11 @@ export interface BranchManager {
* Defaults to 'origin' if no remote specified.
*/
pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
/**
* Dry-run merge check — determines if sourceBranch can be cleanly merged
* into targetBranch without actually performing the merge.
* Uses `git merge-tree --write-tree` (git 2.38+).
*/
checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult>;
}

View File

@@ -13,7 +13,7 @@
export type { WorktreeManager } from './types.js';
// Domain types
export type { Worktree, WorktreeDiff, MergeResult } from './types.js';
export type { Worktree, WorktreeDiff, MergeResult, MergeabilityResult } from './types.js';
// Adapters
export { SimpleGitWorktreeManager } from './manager.js';

View File

@@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { simpleGit } from 'simple-git';
import type { BranchManager } from './branch-manager.js';
import type { MergeResult, BranchCommit } from './types.js';
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('branch-manager');
@@ -164,4 +164,41 @@ export class SimpleGitBranchManager implements BranchManager {
await git.push(remote, branch);
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
}
async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult> {
const git = simpleGit(repoPath);
try {
// git merge-tree --write-tree merges source INTO target virtually.
// Exit 0 = clean merge, non-zero = conflicts.
await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]);
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean');
return { mergeable: true };
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err);
// Parse conflict file names from "CONFLICT (content): Merge conflict in <path>"
const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g;
const conflicts: string[] = [];
let match: RegExpExecArray | null;
while ((match = conflictPattern.exec(stderr)) !== null) {
conflicts.push(match[1].trim());
}
if (conflicts.length > 0) {
log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts');
return { mergeable: false, conflicts };
}
// If we couldn't parse conflicts but the command failed, it's still a conflict
// (could be add/add, rename conflicts, etc.)
if (stderr.includes('CONFLICT')) {
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts');
return { mergeable: false, conflicts: ['(unable to parse conflict details)'] };
}
// Genuine error (not a conflict)
throw err;
}
}
}

View File

@@ -58,6 +58,19 @@ export interface MergeResult {
message: string;
}
// =============================================================================
// Mergeability Check
// =============================================================================
/**
* Result of a dry-run merge check.
* No side effects — only tells you whether the merge would succeed.
*/
export interface MergeabilityResult {
mergeable: boolean;
conflicts?: string[];
}
// =============================================================================
// Branch Commit Info
// =============================================================================