mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
feat: Fix new branch issues and address code review comments
This commit is contained in:
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
100
apps/server/src/routes/worktree/routes/check-changes.ts
Normal file
100
apps/server/src/routes/worktree/routes/check-changes.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user