fix: Address code review comments

This commit is contained in:
gsxdsm
2026-02-17 23:04:42 -08:00
parent 43c19c70ca
commit dd4c738e91
43 changed files with 1128 additions and 359 deletions

View File

@@ -185,14 +185,17 @@ export class AutoLoopCoordinator {
// Load all features for dependency checking (if callback provided)
const allFeatures = this.loadAllFeaturesFn
? await this.loadAllFeaturesFn(projectPath)
: pendingFeatures;
: undefined;
// Filter to eligible features: not running, not finished, and dependencies satisfied
// Filter to eligible features: not running, not finished, and dependencies satisfied.
// When loadAllFeaturesFn is not provided, allFeatures is undefined and we bypass
// dependency checks (returning true) to avoid false negatives caused by completed
// features being absent from pendingFeatures.
const eligibleFeatures = pendingFeatures.filter(
(f) =>
!this.isFeatureRunningFn(f.id) &&
!this.isFeatureFinishedFn(f) &&
areDependenciesSatisfied(f, allFeatures)
(this.loadAllFeaturesFn ? areDependenciesSatisfied(f, allFeatures!) : true)
);
// Sort eligible features by priority (lower number = higher priority, default 2)
@@ -412,10 +415,12 @@ export class AutoLoopCoordinator {
const projectId = settings.projects?.find((p) => p.path === projectPath)?.id;
const autoModeByWorktree = settings.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
// branchName is already normalized to null for the primary branch by callers
// (e.g., checkWorktreeCapacity, startAutoLoopForProject), so we only
// need to convert null to '__main__' for the worktree key lookup
const normalizedBranch = branchName === null ? '__main__' : branchName;
// Normalize both null and 'main' to '__main__' to match the same
// canonicalization used by getWorktreeAutoLoopKey, ensuring that
// lookups for the primary branch always use the '__main__' sentinel
// regardless of whether the caller passed null or the string 'main'.
const normalizedBranch =
branchName === null || branchName === 'main' ? '__main__' : branchName;
const worktreeId = `${projectId}::${normalizedBranch}`;
if (
worktreeId in autoModeByWorktree &&

View File

@@ -0,0 +1,119 @@
/**
* Service for fetching branch commit log data.
*
* Extracts the heavy Git command execution and parsing logic from the
* branch-commit-log route handler so the handler only validates input,
* invokes this service, streams lifecycle events, and sends the response.
*/
import { execGitCommand } from '../routes/worktree/common.js';
// ============================================================================
// Types
// ============================================================================
export interface BranchCommit {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
export interface BranchCommitLogResult {
branch: string;
commits: BranchCommit[];
total: number;
}
// ============================================================================
// Service
// ============================================================================
/**
* 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.
*
* @param worktreePath - Absolute path to the worktree / repository
* @param branchName - Branch to query (omit or pass undefined for HEAD)
* @param limit - Maximum number of commits to return (clamped 1-100)
*/
export async function getBranchCommitLog(
worktreePath: string,
branchName: string | undefined,
limit: number
): Promise<BranchCommitLogResult> {
// Clamp limit to a reasonable range
const parsedLimit = Number(limit);
const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100);
// Use the specified branch or default to HEAD
const targetRef = branchName || 'HEAD';
// Get detailed commit log for the specified branch
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---',
],
worktreePath
);
// Parse the output into structured commit objects
const commits: BranchCommit[] = [];
const commitBlocks = logOutput.split('---END---\n').filter((block) => block.trim());
for (const block of commitBlocks) {
const lines = block.split('\n');
if (lines.length >= 6) {
const hash = lines[0].trim();
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
['diff-tree', '--no-commit-id', '--name-only', '-r', hash],
worktreePath
);
files = filesOutput
.trim()
.split('\n')
.filter((f) => f.trim());
} catch {
// Ignore errors getting file list
}
commits.push({
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,
});
}
}
// If branchName wasn't specified, get current branch for display
let displayBranch = branchName;
if (!displayBranch) {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
displayBranch = branchOutput.trim();
}
return {
branch: displayBranch,
commits,
total: commits.length,
};
}

View File

@@ -0,0 +1,162 @@
/**
* CherryPickService - Cherry-pick git operations without HTTP
*
* Extracted from worktree cherry-pick route to encapsulate all git
* cherry-pick business logic in a single service. Follows the same
* pattern as merge-service.ts.
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
const logger = createLogger('CherryPickService');
// ============================================================================
// Types
// ============================================================================
export interface CherryPickOptions {
noCommit?: boolean;
}
export interface CherryPickResult {
success: boolean;
error?: string;
hasConflicts?: boolean;
aborted?: boolean;
cherryPicked?: boolean;
commitHashes?: string[];
branch?: string;
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
// ============================================================================
/**
* Verify that each commit hash exists in the repository.
*
* @param worktreePath - Path to the git worktree
* @param commitHashes - Array of commit hashes to verify
* @returns The first invalid commit hash, or null if all are valid
*/
export async function verifyCommits(
worktreePath: string,
commitHashes: string[]
): Promise<string | null> {
for (const hash of commitHashes) {
try {
await execGitCommand(['rev-parse', '--verify', hash], worktreePath);
} catch {
return hash;
}
}
return null;
}
/**
* Run the cherry-pick operation on the given worktree.
*
* @param worktreePath - Path to the git worktree
* @param commitHashes - Array of commit hashes to cherry-pick (in order)
* @param options - Cherry-pick options (e.g., noCommit)
* @returns CherryPickResult with success/failure information
*/
export async function runCherryPick(
worktreePath: string,
commitHashes: string[],
options?: CherryPickOptions
): Promise<CherryPickResult> {
const args = ['cherry-pick'];
if (options?.noCommit) {
args.push('--no-commit');
}
args.push(...commitHashes);
try {
await execGitCommand(args, worktreePath);
const branch = await getCurrentBranch(worktreePath);
return {
success: true,
cherryPicked: true,
commitHashes,
branch,
message: `Successfully cherry-picked ${commitHashes.length} commit(s)`,
};
} catch (cherryPickError: unknown) {
// Check if this is a cherry-pick conflict
const err = cherryPickError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') ||
output.includes('cherry-pick failed') ||
output.includes('could not apply');
if (hasConflicts) {
// Abort the cherry-pick to leave the repo in a clean state
await abortCherryPick(worktreePath);
return {
success: false,
error: 'Cherry-pick aborted due to conflicts; no changes were applied.',
hasConflicts: true,
aborted: true,
};
}
// Non-conflict error - propagate
throw cherryPickError;
}
}
/**
* Abort an in-progress cherry-pick operation.
*
* @param worktreePath - Path to the git worktree
* @returns true if abort succeeded, false if it failed (logged as warning)
*/
export async function abortCherryPick(worktreePath: string): Promise<boolean> {
try {
await execGitCommand(['cherry-pick', '--abort'], worktreePath);
return true;
} catch {
logger.warn('Failed to abort cherry-pick after conflict');
return false;
}
}
/**
* 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

@@ -0,0 +1,119 @@
/**
* WorktreeService - File-system operations for git worktrees
*
* Extracted from the worktree create route to centralise file-copy logic,
* surface errors through an EventEmitter instead of swallowing them, and
* make the behaviour testable in isolation.
*/
import path from 'path';
import fs from 'fs/promises';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
/**
* Error thrown when one or more file copy operations fail during
* `copyConfiguredFiles`. The caller can inspect `failures` for details.
*/
export class CopyFilesError extends Error {
constructor(public readonly failures: Array<{ path: string; error: string }>) {
super(`Failed to copy ${failures.length} file(s): ${failures.map((f) => f.path).join(', ')}`);
this.name = 'CopyFilesError';
}
}
/**
* WorktreeService encapsulates file-system operations that run against
* git worktrees (e.g. copying project-configured files into a new worktree).
*
* All operations emit typed events so the frontend can stream progress to the
* user. Errors are collected and surfaced to the caller rather than silently
* swallowed.
*/
export class WorktreeService {
/**
* Copy files / directories listed in the project's `worktreeCopyFiles`
* setting from `projectPath` into `worktreePath`.
*
* Security: paths containing `..` segments or absolute paths are rejected.
*
* Events emitted via `emitter`:
* - `worktree:copy-files:copied` a file or directory was successfully copied
* - `worktree:copy-files:skipped` a source file was not found (ENOENT)
* - `worktree:copy-files:failed` an unexpected error occurred copying a file
*
* @throws {CopyFilesError} if any copy operation fails for a reason other
* than ENOENT (missing source file).
*/
async copyConfiguredFiles(
projectPath: string,
worktreePath: string,
settingsService: SettingsService | undefined,
emitter: EventEmitter
): Promise<void> {
if (!settingsService) return;
const projectSettings = await settingsService.getProjectSettings(projectPath);
const copyFiles = projectSettings.worktreeCopyFiles;
if (!copyFiles || copyFiles.length === 0) return;
const failures: Array<{ path: string; error: string }> = [];
for (const relativePath of copyFiles) {
// Security: prevent path traversal
const normalized = path.normalize(relativePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
const reason = 'Suspicious path rejected (traversal or absolute)';
emitter.emit('worktree:copy-files:skipped', {
path: relativePath,
reason,
});
continue;
}
const sourcePath = path.join(projectPath, normalized);
const destPath = path.join(worktreePath, normalized);
try {
// Check if source exists
const stat = await fs.stat(sourcePath);
// Ensure destination directory exists
const destDir = path.dirname(destPath);
await fs.mkdir(destDir, { recursive: true });
if (stat.isDirectory()) {
// Recursively copy directory
await fs.cp(sourcePath, destPath, { recursive: true, force: true });
} else {
// Copy single file
await fs.copyFile(sourcePath, destPath);
}
emitter.emit('worktree:copy-files:copied', {
path: normalized,
type: stat.isDirectory() ? 'directory' : 'file',
});
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
emitter.emit('worktree:copy-files:skipped', {
path: normalized,
reason: 'File not found in project root',
});
} else {
const errorMessage = err instanceof Error ? err.message : String(err);
emitter.emit('worktree:copy-files:failed', {
path: normalized,
error: errorMessage,
});
failures.push({ path: normalized, error: errorMessage });
}
}
}
if (failures.length > 0) {
throw new CopyFilesError(failures);
}
}
}