feat: Fix new branch issues and address code review comments

This commit is contained in:
gsxdsm
2026-02-18 21:36:00 -08:00
parent 2d907938cc
commit 53d07fefb8
30 changed files with 1604 additions and 367 deletions

View File

@@ -2,11 +2,9 @@
* POST /stage-files endpoint - Stage or unstage files in the main project
*/
import fs from 'fs';
import path from 'path';
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js';
export function createStageFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -51,67 +49,17 @@ export function createStageFilesHandler() {
return;
}
// Resolve the canonical (symlink-dereferenced) project path so that
// startsWith(base) reliably prevents symlink traversal attacks.
// If projectPath does not exist or is unreadable, realpath rejects and
// we return a 400 instead of letting the error propagate as a 500.
let canonicalRoot: string;
try {
canonicalRoot = await fs.promises.realpath(projectPath);
} catch {
res.status(400).json({
success: false,
error: `Invalid projectPath (non-existent or unreadable): ${projectPath}`,
});
return;
}
// Validate and sanitize each file path to prevent path traversal attacks
const base = path.resolve(canonicalRoot) + path.sep;
const sanitizedFiles: string[] = [];
for (const file of files) {
// Reject absolute paths
if (path.isAbsolute(file)) {
res.status(400).json({
success: false,
error: `Invalid file path (absolute paths not allowed): ${file}`,
});
return;
}
// Reject entries containing '..'
if (file.includes('..')) {
res.status(400).json({
success: false,
error: `Invalid file path (path traversal not allowed): ${file}`,
});
return;
}
// Ensure the resolved path stays within the project directory
const resolved = path.resolve(path.join(canonicalRoot, file));
if (resolved !== path.resolve(canonicalRoot) && !resolved.startsWith(base)) {
res.status(400).json({
success: false,
error: `Invalid file path (outside project directory): ${file}`,
});
return;
}
sanitizedFiles.push(file);
}
if (operation === 'stage') {
await execGitCommand(['add', '--', ...sanitizedFiles], canonicalRoot);
} else {
await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], canonicalRoot);
}
const result = await stageFiles(projectPath, files, operation);
res.json({
success: true,
result: {
operation,
filesCount: sanitizedFiles.length,
},
result,
});
} catch (error) {
if (error instanceof StageFilesValidationError) {
res.status(400).json({ success: false, error: error.message });
return;
}
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}

View File

@@ -2,7 +2,7 @@
* Common utilities for worktree routes
*/
import { createLogger } from '@automaker/utils';
import { createLogger, isValidBranchName, MAX_BRANCH_NAME_LENGTH } from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
@@ -14,12 +14,9 @@ export { execGitCommand } from '../../lib/git.js';
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
// ============================================================================
// Constants
// ============================================================================
/** Maximum allowed length for git branch names */
export const MAX_BRANCH_NAME_LENGTH = 250;
// Re-export git validation utilities from the canonical shared module so
// existing consumers that import from this file continue to work.
export { isValidBranchName, MAX_BRANCH_NAME_LENGTH };
// ============================================================================
// Extended PATH configuration for Electron apps
@@ -63,22 +60,6 @@ export const execEnv = {
PATH: extendedPath,
};
// ============================================================================
// Validation utilities
// ============================================================================
/**
* Validate branch name to prevent command injection.
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
* We also reject shell metacharacters for safety.
* The first character must not be '-' to prevent git argument injection.
*/
export function isValidBranchName(name: string): boolean {
// First char must be alphanumeric, dot, underscore, or slash (not dash)
// to prevent git option injection via names like "-flag" or "--option".
return /^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
}
/**
* Validate git remote name to prevent command injection.
* Allowed characters: alphanumerics, hyphen, underscore, dot, and slash.

View File

@@ -66,6 +66,7 @@ import { createRebaseHandler } from './routes/rebase.js';
import { createAbortOperationHandler } from './routes/abort-operation.js';
import { createContinueOperationHandler } from './routes/continue-operation.js';
import { createStageFilesHandler } from './routes/stage-files.js';
import { createCheckChangesHandler } from './routes/check-changes.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -121,7 +122,13 @@ export function createWorktreeRoutes(
'/checkout-branch',
validatePathParams('worktreePath'),
requireValidWorktree,
createCheckoutBranchHandler()
createCheckoutBranchHandler(events)
);
router.post(
'/check-changes',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createCheckChangesHandler()
);
router.post(
'/list-branches',

View File

@@ -16,31 +16,7 @@ import type { Request, Response } from 'express';
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);
}
import { isValidBranchName } from '@automaker/utils';
export function createBranchCommitLogHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -0,0 +1,100 @@
/**
* POST /check-changes endpoint - Check for uncommitted changes in a worktree
*
* Returns a summary of staged, unstaged, and untracked files to help
* the user decide whether to stash before a branch operation.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
/**
* Parse `git status --porcelain` output into categorised file lists.
*
* Porcelain format gives two status characters per line:
* XY filename
* where X is the index (staged) status and Y is the worktree (unstaged) status.
*
* - '?' in both columns → untracked
* - Non-space/non-'?' in X → staged change
* - Non-space/non-'?' in Y (when not untracked) → unstaged change
*
* A file can appear in both staged and unstaged if it was partially staged.
*/
function parseStatusOutput(stdout: string): {
staged: string[];
unstaged: string[];
untracked: string[];
} {
const staged: string[] = [];
const unstaged: string[] = [];
const untracked: string[] = [];
const lines = stdout.trim().split('\n').filter(Boolean);
for (const line of lines) {
if (line.length < 3) continue;
const x = line[0]; // index status
const y = line[1]; // worktree status
// Handle renames which use " -> " separator
const rawPath = line.slice(3);
const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ')[1] : rawPath;
if (x === '?' && y === '?') {
untracked.push(filePath);
} else {
if (x !== ' ' && x !== '?') {
staged.push(filePath);
}
if (y !== ' ' && y !== '?') {
unstaged.push(filePath);
}
}
}
return { staged, unstaged, untracked };
}
export function createCheckChangesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Get porcelain status (includes staged, unstaged, and untracked files)
const stdout = await execGitCommand(['status', '--porcelain'], worktreePath);
const { staged, unstaged, untracked } = parseStatusOutput(stdout);
const hasChanges = staged.length > 0 || unstaged.length > 0 || untracked.length > 0;
res.json({
success: true,
result: {
hasChanges,
staged,
unstaged,
untracked,
totalFiles: staged.length + unstaged.length + untracked.length,
},
});
} catch (error) {
logError(error, 'Check changes failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,6 +1,14 @@
/**
* POST /checkout-branch endpoint - Create and checkout a new branch
*
* Supports automatic stash handling: when `stashChanges` is true, local changes
* are stashed before creating the branch and reapplied after. If the stash pop
* results in merge conflicts, returns a special response so the UI can create a
* conflict resolution task.
*
* Git business logic is delegated to checkout-branch-service.ts when stash
* handling is requested. Otherwise, falls back to the original simple flow.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts.
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
@@ -12,14 +20,20 @@ import path from 'path';
import { stat } from 'fs/promises';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import type { EventEmitter } from '../../../lib/events.js';
import { performCheckoutBranch } from '../../../services/checkout-branch-service.js';
export function createCheckoutBranchHandler() {
export function createCheckoutBranchHandler(events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName, baseBranch } = req.body as {
const { worktreePath, branchName, baseBranch, stashChanges, includeUntracked } = req.body as {
worktreePath: string;
branchName: string;
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
baseBranch?: string;
/** When true, stash local changes before checkout and reapply after */
stashChanges?: boolean;
/** When true, include untracked files in the stash (defaults to true) */
includeUntracked?: boolean;
};
if (!worktreePath) {
@@ -59,8 +73,6 @@ export function createCheckoutBranchHandler() {
}
// Resolve and validate worktreePath to prevent traversal attacks.
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
// but we also resolve the path and verify it exists as a directory.
const resolvedPath = path.resolve(worktreePath);
try {
const stats = await stat(resolvedPath);
@@ -79,7 +91,42 @@ export function createCheckoutBranchHandler() {
return;
}
// Get current branch for reference (using argument array to avoid shell injection)
// Use the service for stash-aware checkout
if (stashChanges) {
const result = await performCheckoutBranch(
resolvedPath,
branchName,
baseBranch,
{
stashChanges: true,
includeUntracked: includeUntracked ?? true,
},
events
);
if (!result.success) {
const statusCode = isBranchError(result.error) ? 400 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
...(result.stashPopConflicts !== undefined && {
stashPopConflicts: result.stashPopConflicts,
}),
...(result.stashPopConflictMessage && {
stashPopConflictMessage: result.stashPopConflictMessage,
}),
});
return;
}
res.json({
success: true,
result: result.result,
});
return;
}
// Original simple flow (no stash handling)
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
resolvedPath
@@ -89,7 +136,6 @@ export function createCheckoutBranchHandler() {
// Check if branch already exists
try {
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
// Branch exists
res.status(400).json({
success: false,
error: `Branch '${branchName}' already exists`,
@@ -112,8 +158,7 @@ export function createCheckoutBranchHandler() {
}
}
// Create and checkout the new branch (using argument array to avoid shell injection)
// If baseBranch is provided, create the branch from that starting point
// Create and checkout the new branch
const checkoutArgs = ['checkout', '-b', branchName];
if (baseBranch) {
checkoutArgs.push(baseBranch);
@@ -129,8 +174,24 @@ export function createCheckoutBranchHandler() {
},
});
} catch (error) {
events?.emit('switch:error', {
error: getErrorMessage(error),
});
logError(error, 'Checkout branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Determine whether an error message represents a client error (400)
*/
function isBranchError(error?: string): boolean {
if (!error) return false;
return (
error.includes('already exists') ||
error.includes('does not exist') ||
error.includes('Failed to stash')
);
}