Files
automaker/apps/server/src/services/merge-service.ts
gsxdsm 7df2182818 Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch

* feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count

* feat: Add validation for remote names and improve error handling

* Address PR comments and mobile layout fixes

* ```
refactor: Extract PR target resolution logic into dedicated service
```

* feat: Add app shell UI and improve service imports. Address PR comments

* fix: Improve security validation and cache handling in git operations

* feat: Add GET /list endpoint and improve parameter handling

* chore: Improve validation, accessibility, and error handling across apps

* chore: Format vite server port configuration

* fix: Add error handling for gh pr list command and improve offline fallbacks

* fix: Preserve existing PR creation time and improve remote handling
2026-02-19 21:55:12 -08:00

300 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* MergeService - Direct merge operations without HTTP
*
* Extracted from worktree merge route to allow internal service calls.
*/
import { createLogger, isValidBranchName, isValidRemoteName } from '@automaker/utils';
import { type EventEmitter } from '../lib/events.js';
import { execGitCommand } from '@automaker/git-utils';
const logger = createLogger('MergeService');
export interface MergeOptions {
squash?: boolean;
message?: string;
deleteWorktreeAndBranch?: boolean;
/** Remote name to fetch from before merging (defaults to 'origin') */
remote?: string;
}
export interface MergeServiceResult {
success: boolean;
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
mergedBranch?: string;
targetBranch?: string;
deleted?: {
worktreeDeleted: boolean;
branchDeleted: boolean;
};
}
/**
* Perform a git merge operation directly without HTTP.
*
* @param projectPath - Path to the git repository
* @param branchName - Source branch to merge
* @param worktreePath - Path to the worktree (used for deletion if requested)
* @param targetBranch - Branch to merge into (defaults to 'main')
* @param options - Merge options
* @param options.squash - If true, perform a squash merge
* @param options.message - Custom merge commit message
* @param options.deleteWorktreeAndBranch - If true, delete worktree and branch after merge
* @param options.remote - Remote name to fetch from before merging (defaults to 'origin')
*/
export async function performMerge(
projectPath: string,
branchName: string,
worktreePath: string,
targetBranch: string = 'main',
options?: MergeOptions,
emitter?: EventEmitter
): Promise<MergeServiceResult> {
if (!projectPath || !branchName || !worktreePath) {
return {
success: false,
error: 'projectPath, branchName, and worktreePath are required',
};
}
const mergeTo = targetBranch || 'main';
// Validate branch names early to reject invalid input before any git operations
if (!isValidBranchName(branchName)) {
return {
success: false,
error: `Invalid source branch name: "${branchName}"`,
};
}
if (!isValidBranchName(mergeTo)) {
return {
success: false,
error: `Invalid target branch name: "${mergeTo}"`,
};
}
// Validate source branch exists (using safe array-based command)
try {
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
} catch {
return {
success: false,
error: `Branch "${branchName}" does not exist`,
};
}
// Validate target branch exists (using safe array-based command)
try {
await execGitCommand(['rev-parse', '--verify', mergeTo], projectPath);
} catch {
return {
success: false,
error: `Target branch "${mergeTo}" does not exist`,
};
}
// Validate the remote name to prevent git option injection.
// Reject invalid remote names so the caller knows their input was wrong,
// consistent with how invalid branch names are handled above.
const remote = options?.remote || 'origin';
if (!isValidRemoteName(remote)) {
logger.warn('Invalid remote name supplied to merge-service', {
remote,
projectPath,
});
return {
success: false,
error: `Invalid remote name: "${remote}"`,
};
}
// Fetch latest from remote before merging to ensure we have up-to-date refs
try {
await execGitCommand(['fetch', remote], projectPath);
} catch (fetchError) {
logger.warn('Failed to fetch from remote before merge; proceeding with local refs', {
remote,
projectPath,
error: (fetchError as Error).message,
});
// Non-fatal: proceed with local refs if fetch fails (e.g. offline)
}
// Emit merge:start after validating inputs
emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });
// Merge the feature branch into the target branch (using safe array-based commands)
const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`;
const mergeArgs = options?.squash
? ['merge', '--squash', branchName]
: ['merge', branchName, '-m', mergeMessage];
try {
// Set LC_ALL=C so git always emits English output regardless of the system
// locale, making text-based conflict detection reliable.
await execGitCommand(mergeArgs, projectPath, { LC_ALL: 'C' });
} catch (mergeError: unknown) {
// Check if this is a merge conflict. We use a multi-layer strategy so
// that detection is reliable even when locale settings vary or git's text
// output changes across versions:
//
// 1. Primary (text-based): scan the error output for well-known English
// conflict markers. Because we pass LC_ALL=C above these strings are
// always in English, but we keep the check as one layer among several.
//
// 2. Unmerged-path check: run `git diff --name-only --diff-filter=U`
// (locale-stable) and treat any non-empty output as a conflict
// indicator, capturing the file list at the same time.
//
// 3. Fallback status check: run `git status --porcelain` and look for
// lines whose first two characters indicate an unmerged state
// (UU, AA, DD, AU, UA, DU, UD).
//
// hasConflicts is true when ANY of the three layers returns positive.
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
// Layer 1 text matching (locale-safe because we set LC_ALL=C above).
const textIndicatesConflict =
output.includes('CONFLICT') || output.includes('Automatic merge failed');
// Layers 2 & 3 repository state inspection (locale-independent).
// Layer 2: get conflicted files via diff (also locale-stable output).
let conflictFiles: string[] | undefined;
let diffIndicatesConflict = false;
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
projectPath,
{ LC_ALL: 'C' }
);
const files = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
if (files.length > 0) {
diffIndicatesConflict = true;
conflictFiles = files;
}
} catch {
// If we can't get the file list, leave conflictFiles undefined so callers
// can distinguish "no conflicts" (empty array) from "unknown due to diff failure" (undefined)
}
// Layer 3: check for unmerged paths via machine-readable git status.
let hasUnmergedPaths = false;
try {
const statusOutput = await execGitCommand(['status', '--porcelain'], projectPath, {
LC_ALL: 'C',
});
// Unmerged status codes occupy the first two characters of each line.
// Standard unmerged codes: UU, AA, DD, AU, UA, DU, UD.
const unmergedLines = statusOutput
.split('\n')
.filter((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
hasUnmergedPaths = unmergedLines.length > 0;
// If Layer 2 did not populate conflictFiles (e.g. diff failed or returned
// nothing) but Layer 3 does detect unmerged paths, parse the status lines
// to extract filenames and assign them to conflictFiles so callers always
// receive an accurate file list when conflicts are present.
if (hasUnmergedPaths && conflictFiles === undefined) {
const parsedFiles = unmergedLines
.map((line) => line.slice(2).trim())
.filter((f) => f.length > 0);
// Deduplicate (e.g. rename entries can appear twice)
conflictFiles = [...new Set(parsedFiles)];
}
} catch {
// git status failing is itself a sign something is wrong; leave
// hasUnmergedPaths as false and rely on the other layers.
}
const hasConflicts = textIndicatesConflict || diffIndicatesConflict || hasUnmergedPaths;
if (hasConflicts) {
// Emit merge:conflict event with conflict details
emitter?.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles });
return {
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
hasConflicts: true,
conflictFiles,
};
}
// Emit merge:error for non-conflict errors before re-throwing
emitter?.emit('merge:error', {
branchName,
targetBranch: mergeTo,
error: err.message || String(mergeError),
});
// Re-throw non-conflict errors
throw mergeError;
}
// If squash merge, need to commit (using safe array-based command)
if (options?.squash) {
const squashMessage = options?.message || `Merge ${branchName} (squash)`;
try {
await execGitCommand(['commit', '-m', squashMessage], projectPath);
} catch (commitError: unknown) {
const err = commitError as { message?: string };
// Emit merge:error so subscribers always receive either merge:success or merge:error
emitter?.emit('merge:error', {
branchName,
targetBranch: mergeTo,
error: err.message || String(commitError),
});
throw commitError;
}
}
// Optionally delete the worktree and branch after merging
let worktreeDeleted = false;
let branchDeleted = false;
if (options?.deleteWorktreeAndBranch) {
// Remove the worktree
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
worktreeDeleted = true;
} catch {
// Try with prune if remove fails
try {
await execGitCommand(['worktree', 'prune'], projectPath);
worktreeDeleted = true;
} catch {
logger.warn(`Failed to remove worktree: ${worktreePath}`);
}
}
// Delete the branch (but not main/master)
if (branchName !== 'main' && branchName !== 'master') {
try {
await execGitCommand(['branch', '-D', branchName], projectPath);
branchDeleted = true;
} catch {
logger.warn(`Failed to delete branch: ${branchName}`);
}
}
}
// Emit merge:success with merged branch, target branch, and deletion info
emitter?.emit('merge:success', {
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
});
return {
success: true,
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
};
}