mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +00:00
feat: Add git log parsing and rebase endpoint with input validation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
139
apps/server/src/services/rebase-service.ts
Normal file
139
apps/server/src/services/rebase-service.ts
Normal 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();
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user