feat: Add git log parsing and rebase endpoint with input validation

This commit is contained in:
gsxdsm
2026-02-18 00:31:31 -08:00
parent e6e04d57bc
commit d30296d559
42 changed files with 2826 additions and 376 deletions

View File

@@ -0,0 +1,55 @@
export interface CommitFields {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
}
export function parseGitLogOutput(output: string): CommitFields[] {
const commits: CommitFields[] = [];
// Split by NUL character to separate commits
const commitBlocks = output.split('\0').filter((block) => block.trim());
for (const block of commitBlocks) {
const fields = block.split('\n');
// Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
if (fields.length < 6) {
continue; // Skip malformed blocks
}
const commit: CommitFields = {
hash: fields[0].trim(),
shortHash: fields[1].trim(),
author: fields[2].trim(),
authorEmail: fields[3].trim(),
date: fields[4].trim(),
subject: fields[5].trim(),
body: fields.slice(6).join('\n').trim(),
};
commits.push(commit);
}
return commits;
}
/**
* Creates a commit object from parsed fields, matching the expected API response format
*/
export function createCommitFromFields(fields: CommitFields, files?: string[]) {
return {
hash: fields.hash,
shortHash: fields.shortHash,
author: fields.author,
authorEmail: fields.authorEmail,
date: fields.date,
subject: fields.subject,
body: fields.body,
files: files || [],
};
}

View File

@@ -22,7 +22,8 @@ export const execAsync = promisify(exec);
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @returns Promise resolving to stdout output
* @throws Error with stderr message if command fails
* @throws Error with stderr/stdout message if command fails. The thrown error
* also has `stdout` and `stderr` string properties for structured access.
*
* @example
* ```typescript
@@ -44,8 +45,12 @@ export async function execGitCommand(args: string[], cwd: string): Promise<strin
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
const errorMessage =
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
throw Object.assign(new Error(errorMessage), {
stdout: result.stdout,
stderr: result.stderr,
});
}
}

View File

@@ -62,6 +62,7 @@ import { createStashDropHandler } from './routes/stash-drop.js';
import { createCherryPickHandler } from './routes/cherry-pick.js';
import { createBranchCommitLogHandler } from './routes/branch-commit-log.js';
import { createGeneratePRDescriptionHandler } from './routes/generate-pr-description.js';
import { createRebaseHandler } from './routes/rebase.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -262,5 +263,13 @@ export function createWorktreeRoutes(
createBranchCommitLogHandler(events)
);
// Rebase route
router.post(
'/rebase',
validatePathParams('worktreePath'),
requireValidWorktree,
createRebaseHandler(events)
);
return router;
}

View File

@@ -17,6 +17,31 @@ import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js';
/**
* Validates a branchName value before it is forwarded to execGitCommand.
*
* Rejects values that:
* - Start with '-' (would be interpreted as a git flag/option)
* - Contain NUL bytes (\0)
* - Contain path-traversal sequences (..)
*
* Only allows characters from a safe whitelist:
* alphanumerics, dot (.), slash (/), underscore (_), dash (-), plus (+),
* at-sign (@), tilde (~), caret (^), and colon (:).
*
* Returns `true` when the value is safe to pass to execGitCommand.
*/
function isValidBranchName(branchName: string): boolean {
// Must not start with '-' (git option injection)
if (branchName.startsWith('-')) return false;
// Must not contain NUL bytes
if (branchName.includes('\0')) return false;
// Must not contain path-traversal sequences
if (branchName.includes('..')) return false;
// Whitelist: alphanumerics and common ref characters
return /^[a-zA-Z0-9._/\-+@~^:]+$/.test(branchName);
}
export function createBranchCommitLogHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -38,6 +63,18 @@ export function createBranchCommitLogHandler(events: EventEmitter) {
return;
}
// Validate branchName before forwarding to execGitCommand.
// Reject values that start with '-', contain NUL, contain path-traversal
// sequences, or include characters outside the safe whitelist.
// An absent branchName is allowed (the service defaults it to HEAD).
if (branchName !== undefined && !isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: 'Invalid branchName: value contains unsafe characters or sequences',
});
return;
}
// Emit start event so the frontend can observe progress
events.emit('branchCommitLog:start', {
worktreePath,

View File

@@ -1,6 +1,10 @@
/**
* POST /commit-log endpoint - Get recent commit history for a worktree
*
* Uses the same robust parsing approach as branch-commit-log-service:
* a single `git log --name-only` call with custom separators to fetch
* both commit metadata and file lists, avoiding N+1 git invocations.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
@@ -8,6 +12,17 @@
import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js';
interface CommitResult {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
export function createCommitLogHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -27,77 +42,93 @@ export function createCommitLogHandler() {
// Clamp limit to a reasonable range
const commitLimit = Math.min(Math.max(1, Number(limit) || 20), 100);
// Get detailed commit log using the secure execGitCommand helper
// Use custom separators to parse both metadata and file lists from
// a single git log invocation (same approach as branch-commit-log-service).
//
// -m causes merge commits to be diffed against each parent so all
// files touched by the merge are listed (without -m, --name-only
// produces no file output for merge commits because they have 2+ parents).
// This means merge commits appear multiple times in the output (once per
// parent), so we deduplicate by hash below and merge their file lists.
// We over-fetch (2x the limit) to compensate for -m duplicating merge
// commit entries, then trim the result to the requested limit.
const COMMIT_SEP = '---COMMIT---';
const META_END = '---META_END---';
const fetchLimit = commitLimit * 2;
const logOutput = await execGitCommand(
['log', `--max-count=${commitLimit}`, '--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%n---END---'],
[
'log',
`--max-count=${fetchLimit}`,
'-m',
'--name-only',
`--format=${COMMIT_SEP}%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b${META_END}`,
],
worktreePath
);
// Parse the output into structured commit objects
const commits: Array<{
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}> = [];
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with ---COMMIT---).
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
const commitBlocks = logOutput.split('---END---').filter((block) => block.trim());
// Use a Map to deduplicate merge commit entries (which appear once per
// parent when -m is used) while preserving insertion order.
const commitMap = new Map<string, CommitResult>();
for (const block of commitBlocks) {
const allLines = block.split('\n');
// Skip leading empty lines that result from the split.
// After splitting on ---END---, subsequent blocks start with a newline,
// which creates an empty first element that shifts all field indices
// (hash becomes empty, shortHash becomes hash, etc.).
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const lines = allLines.slice(startIndex);
if (lines.length >= 6) {
const hash = lines[0].trim();
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
// -m causes merge commits to be diffed against each parent,
// showing all files touched by the merge (without -m, diff-tree
// produces no output for merge commits because they have 2+ parents)
['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', hash],
worktreePath
);
// Deduplicate: -m can list the same file multiple times
// (once per parent diff for merge commits)
files = [
...new Set(
filesOutput
.trim()
.split('\n')
.filter((f) => f.trim())
),
];
} catch {
// Ignore errors getting file list
}
// --- Parse metadata (everything before ---META_END---) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
commits.push({
// The first line may be empty (newline right after COMMIT_SEP), skip it
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
if (nonEmptyStart === -1) continue;
const fields = metaLines.slice(nonEmptyStart);
if (fields.length < 6) continue; // need at least hash..subject
const hash = fields[0].trim();
const shortHash = fields[1].trim();
const author = fields[2].trim();
const authorEmail = fields[3].trim();
const date = fields[4].trim();
const subject = fields[5].trim();
const body = fields.slice(6).join('\n').trim();
// --- Parse file list (everything after ---META_END---) ---
const filesRaw = block.substring(metaEndIdx + META_END.length);
const blockFiles = filesRaw
.trim()
.split('\n')
.filter((f) => f.trim());
// Merge file lists for duplicate entries (merge commits with -m)
const existing = commitMap.get(hash);
if (existing) {
// Add new files to the existing entry's file set
const fileSet = new Set(existing.files);
for (const f of blockFiles) fileSet.add(f);
existing.files = [...fileSet];
} else {
commitMap.set(hash, {
hash,
shortHash: lines[1].trim(),
author: lines[2].trim(),
authorEmail: lines[3].trim(),
date: lines[4].trim(),
subject: lines[5].trim(),
body: lines.slice(6).join('\n').trim(),
files,
shortHash,
author,
authorEmail,
date,
subject,
body,
files: [...new Set(blockFiles)],
});
}
}
// Trim to the requested limit (we over-fetched to account for -m duplicates)
const commits = [...commitMap.values()].slice(0, commitLimit);
// Get current branch name
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],

View File

@@ -75,11 +75,27 @@ export function createMergeHandler() {
output.includes('CONFLICT') || output.includes('Automatic merge failed');
if (hasConflicts) {
// Get list of conflicted files
let conflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
projectPath
);
conflictFiles = diffOutput
.trim()
.split('\n')
.filter((f: string) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
// Return conflict-specific error message that frontend can detect
res.status(409).json({
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
hasConflicts: true,
conflictFiles,
});
return;
}

View File

@@ -1,23 +1,29 @@
/**
* POST /pull endpoint - Pull latest changes for a worktree/branch
*
* Enhanced pull flow with stash management and conflict detection:
* 1. Checks for uncommitted local changes (staged and unstaged)
* 2. If local changes exist AND stashIfNeeded is true, automatically stashes them
* 3. Performs the git pull
* 4. If changes were stashed, attempts to reapply via git stash pop
* 5. Detects merge conflicts from both pull and stash reapplication
* 6. Returns structured conflict information for AI-assisted resolution
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
import { execGitCommand, getErrorMessage, logError } from '../common.js';
export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote } = req.body as {
const { worktreePath, remote, stashIfNeeded } = req.body as {
worktreePath: string;
remote?: string;
/** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean;
};
if (!worktreePath) {
@@ -29,65 +35,318 @@ export function createPullHandler() {
}
// Get current branch name
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const branchName = branchOutput.trim();
// Check for detached HEAD state
if (branchName === 'HEAD') {
res.status(400).json({
success: false,
error: 'Cannot pull in detached HEAD state. Please checkout a branch first.',
});
return;
}
// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
// Fetch latest from remote
await execAsync(`git fetch ${targetRemote}`, { cwd: worktreePath });
// Check if there are local changes that would be overwritten
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
const hasLocalChanges = status.trim().length > 0;
if (hasLocalChanges) {
res.status(400).json({
try {
await execGitCommand(['fetch', targetRemote], worktreePath);
} catch (fetchError) {
const errorMsg = getErrorMessage(fetchError);
res.status(500).json({
success: false,
error: 'You have local changes. Please commit them before pulling.',
error: `Failed to fetch from remote '${targetRemote}': ${errorMsg}`,
});
return;
}
// Pull latest changes
try {
const { stdout: pullOutput } = await execAsync(`git pull ${targetRemote} ${branchName}`, {
cwd: worktreePath,
});
// Check if there are local changes that would be overwritten
const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath);
const hasLocalChanges = statusOutput.trim().length > 0;
// Check if we pulled any changes
const alreadyUpToDate = pullOutput.includes('Already up to date');
// Parse changed files for the response
let localChangedFiles: string[] = [];
if (hasLocalChanges) {
localChangedFiles = statusOutput
.trim()
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => line.substring(3).trim());
}
// If there are local changes and stashIfNeeded is not requested, return info
if (hasLocalChanges && !stashIfNeeded) {
res.json({
success: true,
result: {
branch: branchName,
pulled: !alreadyUpToDate,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
pulled: false,
hasLocalChanges: true,
localChangedFiles,
message:
'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.',
},
});
} catch (pullError: unknown) {
const err = pullError as { stderr?: string; message?: string };
const errorMsg = err.stderr || err.message || 'Pull failed';
return;
}
// Check for common errors
if (errorMsg.includes('no tracking information')) {
res.status(400).json({
// Stash local changes if needed
let didStash = false;
if (hasLocalChanges && stashIfNeeded) {
try {
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
await execGitCommand(
['stash', 'push', '--include-untracked', '-m', stashMessage],
worktreePath
);
didStash = true;
} catch (stashError) {
const errorMsg = getErrorMessage(stashError);
res.status(500).json({
success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
error: `Failed to stash local changes: ${errorMsg}`,
});
return;
}
}
res.status(500).json({
success: false,
error: errorMsg,
// Check if the branch has upstream tracking
let hasUpstream = false;
try {
await execGitCommand(
['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`],
worktreePath
);
hasUpstream = true;
} catch {
// No upstream tracking - check if the remote branch exists
try {
await execGitCommand(
['rev-parse', '--verify', `${targetRemote}/${branchName}`],
worktreePath
);
hasUpstream = true; // Remote branch exists, we can pull from it
} catch {
// Remote branch doesn't exist either
if (didStash) {
// Reapply stash since we won't be pulling
try {
await execGitCommand(['stash', 'pop'], worktreePath);
} catch {
// Stash pop failed - leave it in stash list for manual recovery
}
}
res.status(400).json({
success: false,
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
});
return;
}
}
// Pull latest changes
let pullConflict = false;
let pullConflictFiles: string[] = [];
try {
const pullOutput = await execGitCommand(['pull', targetRemote, branchName], worktreePath);
// Check if we pulled any changes
const alreadyUpToDate = pullOutput.includes('Already up to date');
// If no stash to reapply, return success
if (!didStash) {
res.json({
success: true,
result: {
branch: branchName,
pulled: !alreadyUpToDate,
hasLocalChanges: false,
stashed: false,
stashRestored: false,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
},
});
return;
}
} catch (pullError: unknown) {
const err = pullError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// Check for merge conflicts from the pull itself
if (errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed')) {
pullConflict = true;
// Get list of conflicted files
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
pullConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
} else {
// Non-conflict pull error
if (didStash) {
// Try to restore stash since pull failed
try {
await execGitCommand(['stash', 'pop'], worktreePath);
} catch {
// Leave stash in place for manual recovery
}
}
// Check for common errors
const errorMsg = err.stderr || err.message || 'Pull failed';
if (errorMsg.includes('no tracking information')) {
res.status(400).json({
success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
});
return;
}
res.status(500).json({
success: false,
error: errorMsg,
});
return;
}
}
// If pull had conflicts, return conflict info (don't try stash pop)
if (pullConflict) {
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'pull',
conflictFiles: pullConflictFiles,
stashed: didStash,
stashRestored: false,
message:
`Pull resulted in merge conflicts. ${didStash ? 'Your local changes are still stashed.' : ''}`.trim(),
},
});
return;
}
// Pull succeeded, now try to reapply stash
if (didStash) {
try {
const stashPopOutput = await execGitCommand(['stash', 'pop'], worktreePath);
const stashPopCombined = stashPopOutput || '';
// Check if stash pop had conflicts
if (
stashPopCombined.includes('CONFLICT') ||
stashPopCombined.includes('Merge conflict')
) {
// Get conflicted files
let stashConflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
stashConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'stash',
conflictFiles: stashConflictFiles,
stashed: true,
stashRestored: true, // Stash was applied but with conflicts
message:
'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
},
});
return;
}
// Stash pop succeeded cleanly
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: false,
stashed: true,
stashRestored: true,
message: 'Pulled latest changes and restored your stashed changes.',
},
});
} catch (stashPopError: unknown) {
const err = stashPopError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// Check if stash pop failed due to conflicts
if (errorOutput.includes('CONFLICT') || errorOutput.includes('Merge conflict')) {
let stashConflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
stashConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'stash',
conflictFiles: stashConflictFiles,
stashed: true,
stashRestored: true,
message:
'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
},
});
return;
}
// Non-conflict stash pop error - stash is still in the stash list
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: false,
stashed: true,
stashRestored: false,
message:
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
},
});
}
}
} catch (error) {
logError(error, 'Pull failed');

View File

@@ -0,0 +1,110 @@
/**
* POST /rebase endpoint - Rebase the current branch onto a target branch
*
* Rebases the current worktree branch onto a specified target branch
* (e.g., origin/main) for a linear history. Detects conflicts and
* returns structured conflict information for AI-assisted resolution.
*
* Git business logic is delegated to rebase-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import path from 'path';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { runRebase } from '../../../services/rebase-service.js';
export function createRebaseHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, ontoBranch } = req.body as {
worktreePath: string;
/** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */
ontoBranch: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
if (!ontoBranch) {
res.status(400).json({
success: false,
error: 'ontoBranch is required',
});
return;
}
// Normalize the path to prevent path traversal and ensure consistent paths
const resolvedWorktreePath = path.resolve(worktreePath);
// Validate the branch name (allow remote refs like origin/main)
if (!isValidBranchName(ontoBranch)) {
res.status(400).json({
success: false,
error: `Invalid branch name: "${ontoBranch}"`,
});
return;
}
// Emit started event
events.emit('rebase:started', {
worktreePath: resolvedWorktreePath,
ontoBranch,
});
// Execute the rebase via the service
const result = await runRebase(resolvedWorktreePath, ontoBranch);
if (result.success) {
// Emit success event
events.emit('rebase:success', {
worktreePath: resolvedWorktreePath,
branch: result.branch,
ontoBranch: result.ontoBranch,
});
res.json({
success: true,
result: {
branch: result.branch,
ontoBranch: result.ontoBranch,
message: result.message,
},
});
} else if (result.hasConflicts) {
// Emit conflict event
events.emit('rebase:conflict', {
worktreePath: resolvedWorktreePath,
ontoBranch,
conflictFiles: result.conflictFiles,
aborted: result.aborted,
});
res.status(409).json({
success: false,
error: result.error,
hasConflicts: true,
conflictFiles: result.conflictFiles,
aborted: result.aborted,
});
}
} catch (error) {
// Emit failure event
events.emit('rebase:failure', {
error: getErrorMessage(error),
});
logError(error, 'Rebase failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -15,6 +15,24 @@ import { getErrorMessage, logError } from '../common.js';
const execFileAsync = promisify(execFile);
/**
* Retrieves the list of files with unmerged (conflicted) entries using git diff.
*/
async function getConflictedFiles(worktreePath: string): Promise<string[]> {
try {
const { stdout } = await execFileAsync('git', ['diff', '--name-only', '--diff-filter=U'], {
cwd: worktreePath,
});
return stdout
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, return empty array
return [];
}
}
export function createStashApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -62,11 +80,13 @@ export function createStashApplyHandler() {
// Check for conflict markers in the output
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
const conflictFiles = await getConflictedFiles(worktreePath);
res.json({
success: true,
result: {
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
@@ -90,11 +110,13 @@ export function createStashApplyHandler() {
// Check if the error is due to conflicts
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
const conflictFiles = await getConflictedFiles(worktreePath);
res.json({
success: true,
result: {
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,

View File

@@ -193,11 +193,18 @@ export function createSwitchBranchHandler() {
let isRemote = false;
// Check if this is a remote branch (e.g., "origin/feature-branch")
let parsedRemote: { remote: string; branch: string } | null = null;
if (await isRemoteBranch(worktreePath, branchName)) {
isRemote = true;
const parsed = parseRemoteBranch(branchName);
if (parsed) {
targetBranch = parsed.branch;
parsedRemote = parseRemoteBranch(branchName);
if (parsedRemote) {
targetBranch = parsedRemote.branch;
} else {
res.status(400).json({
success: false,
error: `Failed to parse remote branch name '${branchName}'`,
});
return;
}
}
@@ -240,17 +247,17 @@ export function createSwitchBranchHandler() {
try {
// Switch to the target branch
if (isRemote) {
const parsed = parseRemoteBranch(branchName);
if (parsed) {
if (await localBranchExists(worktreePath, parsed.branch)) {
// Local branch exists, just checkout
await execFileAsync('git', ['checkout', parsed.branch], { cwd: worktreePath });
} else {
// Create local tracking branch from remote
await execFileAsync('git', ['checkout', '-b', parsed.branch, branchName], {
cwd: worktreePath,
});
}
if (!parsedRemote) {
throw new Error(`Failed to parse remote branch name '${branchName}'`);
}
if (await localBranchExists(worktreePath, parsedRemote.branch)) {
// Local branch exists, just checkout
await execFileAsync('git', ['checkout', parsedRemote.branch], { cwd: worktreePath });
} else {
// Create local tracking branch from remote
await execFileAsync('git', ['checkout', '-b', parsedRemote.branch, branchName], {
cwd: worktreePath,
});
}
} else {
await execFileAsync('git', ['checkout', targetBranch], { cwd: worktreePath });
@@ -262,15 +269,18 @@ export function createSwitchBranchHandler() {
// Reapply stashed changes if we stashed earlier
let hasConflicts = false;
let conflictMessage = '';
let stashReapplied = false;
if (didStash) {
const popResult = await popStash(worktreePath);
hasConflicts = popResult.hasConflicts;
if (popResult.hasConflicts) {
hasConflicts = true;
conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`;
} else if (!popResult.success) {
// Stash pop failed for a non-conflict reason - the stash is still there
conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`;
} else {
stashReapplied = true;
}
}
@@ -285,8 +295,20 @@ export function createSwitchBranchHandler() {
stashedChanges: true,
},
});
} else if (didStash && !stashReapplied) {
// Stash pop failed for a non-conflict reason — stash is still present
res.json({
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: conflictMessage,
hasConflicts: false,
stashedChanges: true,
},
});
} else {
const stashNote = didStash ? ' (local changes stashed and reapplied)' : '';
const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : '';
res.json({
success: true,
result: {
@@ -294,7 +316,7 @@ export function createSwitchBranchHandler() {
currentBranch: targetBranch,
message: `Switched to branch '${targetBranch}'${stashNote}`,
hasConflicts: false,
stashedChanges: didStash,
stashedChanges: stashReapplied,
},
});
}

View File

@@ -36,8 +36,9 @@ export interface BranchCommitLogResult {
/**
* Fetch the commit log for a specific branch (or HEAD).
*
* Runs `git log`, `git diff-tree`, and `git rev-parse` inside
* the given worktree path and returns a structured result.
* Runs a single `git log --name-only` invocation (plus `git rev-parse`
* when branchName is omitted) inside the given worktree path and
* returns a structured result.
*
* @param worktreePath - Absolute path to the worktree / repository
* @param branchName - Branch to query (omit or pass undefined for HEAD)
@@ -55,73 +56,96 @@ export async function getBranchCommitLog(
// Use the specified branch or default to HEAD
const targetRef = branchName || 'HEAD';
// Get detailed commit log for the specified branch
// Fetch commit metadata AND file lists in a single git call.
// Uses custom record separators so we can parse both metadata and
// --name-only output from one invocation, eliminating the previous
// N+1 pattern that spawned a separate `git diff-tree` per commit.
//
// -m causes merge commits to be diffed against each parent so all
// files touched by the merge are listed (without -m, --name-only
// produces no file output for merge commits because they have 2+ parents).
// This means merge commits appear multiple times in the output (once per
// parent), so we deduplicate by hash below and merge their file lists.
// We over-fetch (2× the limit) to compensate for -m duplicating merge
// commit entries, then trim the result to the requested limit.
const COMMIT_SEP = '---COMMIT---';
const META_END = '---META_END---';
const fetchLimit = commitLimit * 2;
const logOutput = await execGitCommand(
[
'log',
targetRef,
`--max-count=${commitLimit}`,
'--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%n---END---',
`--max-count=${fetchLimit}`,
'-m',
'--name-only',
`--format=${COMMIT_SEP}%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b${META_END}`,
],
worktreePath
);
// Parse the output into structured commit objects
const commits: BranchCommit[] = [];
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with ---COMMIT---).
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
const commitBlocks = logOutput.split('---END---').filter((block) => block.trim());
// Use a Map to deduplicate merge commit entries (which appear once per
// parent when -m is used) while preserving insertion order.
const commitMap = new Map<string, BranchCommit>();
for (const block of commitBlocks) {
const allLines = block.split('\n');
// Skip leading empty lines that result from the split.
// After splitting on ---END---, subsequent blocks start with a newline,
// which creates an empty first element that shifts all field indices
// (hash becomes empty, shortHash becomes hash, etc.).
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const lines = allLines.slice(startIndex);
if (lines.length >= 6) {
const hash = lines[0].trim();
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
// -m causes merge commits to be diffed against each parent,
// showing all files touched by the merge (without -m, diff-tree
// produces no output for merge commits because they have 2+ parents)
['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', hash],
worktreePath
);
// Deduplicate: -m can list the same file multiple times
// (once per parent diff for merge commits)
files = [
...new Set(
filesOutput
.trim()
.split('\n')
.filter((f) => f.trim())
),
];
} catch {
// Ignore errors getting file list
}
// --- Parse metadata (everything before ---META_END---) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
commits.push({
// The first line may be empty (newline right after COMMIT_SEP), skip it
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
if (nonEmptyStart === -1) continue;
const fields = metaLines.slice(nonEmptyStart);
if (fields.length < 6) continue; // need at least hash..subject
const hash = fields[0].trim();
const shortHash = fields[1].trim();
const author = fields[2].trim();
const authorEmail = fields[3].trim();
const date = fields[4].trim();
const subject = fields[5].trim();
const body = fields.slice(6).join('\n').trim();
// --- Parse file list (everything after ---META_END---) ---
const filesRaw = block.substring(metaEndIdx + META_END.length);
const blockFiles = filesRaw
.trim()
.split('\n')
.filter((f) => f.trim());
// Merge file lists for duplicate entries (merge commits with -m)
const existing = commitMap.get(hash);
if (existing) {
// Add new files to the existing entry's file set
const fileSet = new Set(existing.files);
for (const f of blockFiles) fileSet.add(f);
existing.files = [...fileSet];
} else {
commitMap.set(hash, {
hash,
shortHash: lines[1].trim(),
author: lines[2].trim(),
authorEmail: lines[3].trim(),
date: lines[4].trim(),
subject: lines[5].trim(),
body: lines.slice(6).join('\n').trim(),
files,
shortHash,
author,
authorEmail,
date,
subject,
body,
files: [...new Set(blockFiles)],
});
}
}
// Trim to the requested limit (we over-fetched to account for -m duplicates)
const commits = [...commitMap.values()].slice(0, commitLimit);
// If branchName wasn't specified, get current branch for display
let displayBranch = branchName;
if (!displayBranch) {

View File

@@ -7,7 +7,7 @@
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
import { execGitCommand } from '../routes/worktree/common.js';
const logger = createLogger('CherryPickService');
@@ -30,28 +30,6 @@ export interface CherryPickResult {
message?: string;
}
// ============================================================================
// Internal git command execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
*/
async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
// ============================================================================
// Service Functions
// ============================================================================
@@ -101,6 +79,16 @@ export async function runCherryPick(
const branch = await getCurrentBranch(worktreePath);
if (options?.noCommit) {
return {
success: true,
cherryPicked: false,
commitHashes,
branch,
message: `Staged changes from ${commitHashes.length} commit(s); no commit created due to --no-commit`,
};
}
return {
success: true,
cherryPicked: true,
@@ -119,13 +107,22 @@ export async function runCherryPick(
if (hasConflicts) {
// Abort the cherry-pick to leave the repo in a clean state
await abortCherryPick(worktreePath);
const aborted = await abortCherryPick(worktreePath);
if (!aborted) {
logger.error(
'Failed to abort cherry-pick after conflict; repository may be in a dirty state',
{ worktreePath }
);
}
return {
success: false,
error: 'Cherry-pick aborted due to conflicts; no changes were applied.',
error: aborted
? 'Cherry-pick aborted due to conflicts; no changes were applied.'
: 'Cherry-pick failed due to conflicts and the abort also failed; repository may be in a dirty state.',
hasConflicts: true,
aborted: true,
aborted,
};
}

View File

@@ -18,6 +18,7 @@ export interface MergeServiceResult {
success: boolean;
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
mergedBranch?: string;
targetBranch?: string;
deleted?: {
@@ -39,8 +40,12 @@ async function execGitCommand(args: string[], cwd: string): Promise<string> {
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
const errorMessage =
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
throw Object.assign(new Error(errorMessage), {
stdout: result.stdout,
stderr: result.stderr,
});
}
}
@@ -125,10 +130,26 @@ export async function performMerge(
const hasConflicts = output.includes('CONFLICT') || output.includes('Automatic merge failed');
if (hasConflicts) {
// Get list of conflicted files
let conflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
projectPath
);
conflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay - continue without it
}
return {
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
hasConflicts: true,
conflictFiles,
};
}

View File

@@ -0,0 +1,139 @@
/**
* RebaseService - Rebase git operations without HTTP
*
* Handles git rebase operations with conflict detection and reporting.
* Follows the same pattern as merge-service.ts and cherry-pick-service.ts.
*/
import { createLogger } from '@automaker/utils';
import { execGitCommand } from '../routes/worktree/common.js';
const logger = createLogger('RebaseService');
// ============================================================================
// Types
// ============================================================================
export interface RebaseResult {
success: boolean;
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
aborted?: boolean;
branch?: string;
ontoBranch?: string;
message?: string;
}
// ============================================================================
// Service Functions
// ============================================================================
/**
* Run a git rebase operation on the given worktree.
*
* @param worktreePath - Path to the git worktree
* @param ontoBranch - The branch to rebase onto (e.g., 'origin/main')
* @returns RebaseResult with success/failure information
*/
export async function runRebase(worktreePath: string, ontoBranch: string): Promise<RebaseResult> {
// Get current branch name before rebase
const currentBranch = await getCurrentBranch(worktreePath);
try {
await execGitCommand(['rebase', ontoBranch], worktreePath);
return {
success: true,
branch: currentBranch,
ontoBranch,
message: `Successfully rebased ${currentBranch} onto ${ontoBranch}`,
};
} catch (rebaseError: unknown) {
// Check if this is a rebase conflict
const err = rebaseError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') ||
output.includes('could not apply') ||
output.includes('Resolve all conflicts') ||
output.includes('fix conflicts');
if (hasConflicts) {
// Get list of conflicted files
const conflictFiles = await getConflictFiles(worktreePath);
// Abort the rebase to leave the repo in a clean state
const aborted = await abortRebase(worktreePath);
if (!aborted) {
logger.error('Failed to abort rebase after conflict; repository may be in a dirty state', {
worktreePath,
});
}
return {
success: false,
error: aborted
? `Rebase of "${currentBranch}" onto "${ontoBranch}" aborted due to conflicts; no changes were applied.`
: `Rebase of "${currentBranch}" onto "${ontoBranch}" failed due to conflicts and the abort also failed; repository may be in a dirty state.`,
hasConflicts: true,
conflictFiles,
aborted,
branch: currentBranch,
ontoBranch,
};
}
// Non-conflict error - propagate
throw rebaseError;
}
}
/**
* Abort an in-progress rebase operation.
*
* @param worktreePath - Path to the git worktree
* @returns true if abort succeeded, false if it failed (logged as warning)
*/
export async function abortRebase(worktreePath: string): Promise<boolean> {
try {
await execGitCommand(['rebase', '--abort'], worktreePath);
return true;
} catch {
logger.warn('Failed to abort rebase after conflict');
return false;
}
}
/**
* Get the list of files with unresolved conflicts.
*
* @param worktreePath - Path to the git worktree
* @returns Array of file paths with conflicts
*/
export async function getConflictFiles(worktreePath: string): Promise<string[]> {
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
return diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
return [];
}
}
/**
* Get the current branch name for the worktree.
*
* @param worktreePath - Path to the git worktree
* @returns The current branch name
*/
export async function getCurrentBranch(worktreePath: string): Promise<string> {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
return branchOutput.trim();
}

View File

@@ -63,6 +63,14 @@ export class WorktreeService {
for (const relativePath of copyFiles) {
// Security: prevent path traversal
const normalized = path.normalize(relativePath);
if (normalized === '' || normalized === '.') {
const reason = 'Suspicious path rejected (empty or current-dir)';
emitter.emit('worktree:copy-files:skipped', {
path: relativePath,
reason,
});
continue;
}
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
const reason = 'Suspicious path rejected (traversal or absolute)';
emitter.emit('worktree:copy-files:skipped', {