mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +00:00
feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6
This commit is contained in:
@@ -6,12 +6,14 @@ import { Router } from 'express';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createDiffsHandler } from './routes/diffs.js';
|
||||
import { createFileDiffHandler } from './routes/file-diff.js';
|
||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||
|
||||
export function createGitRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
||||
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
||||
router.post('/stage-files', validatePathParams('projectPath'), createStageFilesHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
60
apps/server/src/routes/git/routes/stage-files.ts
Normal file
60
apps/server/src/routes/git/routes/stage-files.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* POST /stage-files endpoint - Stage or unstage files in the main project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
|
||||
export function createStageFilesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, files, operation } = req.body as {
|
||||
projectPath: string;
|
||||
files: string[];
|
||||
operation: 'stage' | 'unstage';
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'files array required and must not be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation !== 'stage' && operation !== 'unstage') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'operation must be "stage" or "unstage"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === 'stage') {
|
||||
await execGitCommand(['add', '--', ...files], projectPath);
|
||||
} else {
|
||||
await execGitCommand(['reset', 'HEAD', '--', ...files], projectPath);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
filesCount: files.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export function createVerifyClaudeAuthHandler() {
|
||||
const stream = query({
|
||||
prompt: "Reply with only the word 'ok'",
|
||||
options: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
model: 'claude-sonnet-4-6',
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
abortController,
|
||||
|
||||
@@ -63,6 +63,9 @@ 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 { createAbortOperationHandler } from './routes/abort-operation.js';
|
||||
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createWorktreeRoutes(
|
||||
@@ -276,5 +279,29 @@ export function createWorktreeRoutes(
|
||||
createRebaseHandler(events)
|
||||
);
|
||||
|
||||
// Abort in-progress merge/rebase/cherry-pick
|
||||
router.post(
|
||||
'/abort-operation',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createAbortOperationHandler(events)
|
||||
);
|
||||
|
||||
// Continue in-progress merge/rebase/cherry-pick after resolving conflicts
|
||||
router.post(
|
||||
'/continue-operation',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createContinueOperationHandler(events)
|
||||
);
|
||||
|
||||
// Stage/unstage files route
|
||||
router.post(
|
||||
'/stage-files',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createStageFilesHandler()
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
117
apps/server/src/routes/worktree/routes/abort-operation.ts
Normal file
117
apps/server/src/routes/worktree/routes/abort-operation.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* POST /abort-operation endpoint - Abort an in-progress merge, rebase, or cherry-pick
|
||||
*
|
||||
* Detects which operation (merge, rebase, or cherry-pick) is in progress
|
||||
* and aborts it, returning the repository to a clean state.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getErrorMessage, logError, execAsync } from '../common.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
|
||||
/**
|
||||
* Detect what type of conflict operation is currently in progress
|
||||
*/
|
||||
async function detectOperation(
|
||||
worktreePath: string
|
||||
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
|
||||
try {
|
||||
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
|
||||
await Promise.all([
|
||||
fs
|
||||
.access(path.join(gitDir, 'rebase-merge'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'rebase-apply'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
|
||||
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
|
||||
if (mergeHeadExists) return 'merge';
|
||||
if (cherryPickHeadExists) return 'cherry-pick';
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAbortOperationHandler(events: EventEmitter) {
|
||||
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 is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedWorktreePath = path.resolve(worktreePath);
|
||||
|
||||
// Detect what operation is in progress
|
||||
const operation = await detectOperation(resolvedWorktreePath);
|
||||
|
||||
if (!operation) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No merge, rebase, or cherry-pick in progress',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort the operation
|
||||
let abortCommand: string;
|
||||
switch (operation) {
|
||||
case 'merge':
|
||||
abortCommand = 'git merge --abort';
|
||||
break;
|
||||
case 'rebase':
|
||||
abortCommand = 'git rebase --abort';
|
||||
break;
|
||||
case 'cherry-pick':
|
||||
abortCommand = 'git cherry-pick --abort';
|
||||
break;
|
||||
}
|
||||
|
||||
await execAsync(abortCommand, { cwd: resolvedWorktreePath });
|
||||
|
||||
// Emit event
|
||||
events.emit('conflict:aborted', {
|
||||
worktreePath: resolvedWorktreePath,
|
||||
operation,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} aborted successfully`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Abort operation failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
151
apps/server/src/routes/worktree/routes/continue-operation.ts
Normal file
151
apps/server/src/routes/worktree/routes/continue-operation.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* POST /continue-operation endpoint - Continue an in-progress merge, rebase, or cherry-pick
|
||||
*
|
||||
* After conflicts have been resolved, this endpoint continues the operation.
|
||||
* For merge: performs git commit (merge is auto-committed after conflict resolution)
|
||||
* For rebase: runs git rebase --continue
|
||||
* For cherry-pick: runs git cherry-pick --continue
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getErrorMessage, logError, execAsync } from '../common.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
|
||||
/**
|
||||
* Detect what type of conflict operation is currently in progress
|
||||
*/
|
||||
async function detectOperation(
|
||||
worktreePath: string
|
||||
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
|
||||
try {
|
||||
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
|
||||
await Promise.all([
|
||||
fs
|
||||
.access(path.join(gitDir, 'rebase-merge'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'rebase-apply'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
fs
|
||||
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
|
||||
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
|
||||
if (mergeHeadExists) return 'merge';
|
||||
if (cherryPickHeadExists) return 'cherry-pick';
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are still unmerged paths (unresolved conflicts)
|
||||
*/
|
||||
async function hasUnmergedPaths(worktreePath: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
return statusOutput.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createContinueOperationHandler(events: EventEmitter) {
|
||||
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 is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedWorktreePath = path.resolve(worktreePath);
|
||||
|
||||
// Detect what operation is in progress
|
||||
const operation = await detectOperation(resolvedWorktreePath);
|
||||
|
||||
if (!operation) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'No merge, rebase, or cherry-pick in progress',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for unresolved conflicts
|
||||
if (await hasUnmergedPaths(resolvedWorktreePath)) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error:
|
||||
'There are still unresolved conflicts. Please resolve all conflicts before continuing.',
|
||||
hasUnresolvedConflicts: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage all resolved files first
|
||||
await execAsync('git add -A', { cwd: resolvedWorktreePath });
|
||||
|
||||
// Continue the operation
|
||||
let continueCommand: string;
|
||||
switch (operation) {
|
||||
case 'merge':
|
||||
// For merge, we need to commit after resolving conflicts
|
||||
continueCommand = 'git commit --no-edit';
|
||||
break;
|
||||
case 'rebase':
|
||||
continueCommand = 'git rebase --continue';
|
||||
break;
|
||||
case 'cherry-pick':
|
||||
continueCommand = 'git cherry-pick --continue';
|
||||
break;
|
||||
}
|
||||
|
||||
await execAsync(continueCommand, {
|
||||
cwd: resolvedWorktreePath,
|
||||
env: { ...process.env, GIT_EDITOR: 'true' }, // Prevent editor from opening
|
||||
});
|
||||
|
||||
// Emit event
|
||||
events.emit('conflict:resolved', {
|
||||
worktreePath: resolvedWorktreePath,
|
||||
operation,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} continued successfully`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Continue operation failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -58,6 +58,88 @@ interface WorktreeInfo {
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
|
||||
/** Whether a merge or rebase is in progress (has conflicts) */
|
||||
hasConflicts?: boolean;
|
||||
/** Type of conflict operation in progress */
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
/** List of files with conflicts */
|
||||
conflictFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a merge, rebase, or cherry-pick is in progress for a worktree.
|
||||
* Checks for the presence of state files/directories that git creates
|
||||
* during these operations.
|
||||
*/
|
||||
async function detectConflictState(worktreePath: string): Promise<{
|
||||
hasConflicts: boolean;
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
conflictFiles?: string[];
|
||||
}> {
|
||||
try {
|
||||
// Find the canonical .git directory for this worktree
|
||||
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
// Check for merge, rebase, and cherry-pick state files/directories
|
||||
const [mergeHeadExists, rebaseMergeExists, rebaseApplyExists, cherryPickHeadExists] =
|
||||
await Promise.all([
|
||||
secureFs
|
||||
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
secureFs
|
||||
.access(path.join(gitDir, 'rebase-merge'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
secureFs
|
||||
.access(path.join(gitDir, 'rebase-apply'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
secureFs
|
||||
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
|
||||
let conflictType: 'merge' | 'rebase' | 'cherry-pick' | undefined;
|
||||
if (rebaseMergeExists || rebaseApplyExists) {
|
||||
conflictType = 'rebase';
|
||||
} else if (mergeHeadExists) {
|
||||
conflictType = 'merge';
|
||||
} else if (cherryPickHeadExists) {
|
||||
conflictType = 'cherry-pick';
|
||||
}
|
||||
|
||||
if (!conflictType) {
|
||||
return { hasConflicts: false };
|
||||
}
|
||||
|
||||
// Get list of conflicted files using machine-readable git status
|
||||
let conflictFiles: string[] = [];
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
conflictFiles = statusOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((f) => f.trim().length > 0);
|
||||
} catch {
|
||||
// Fall back to empty list if diff fails
|
||||
}
|
||||
|
||||
return {
|
||||
hasConflicts: true,
|
||||
conflictType,
|
||||
conflictFiles,
|
||||
};
|
||||
} catch {
|
||||
// If anything fails, assume no conflicts
|
||||
return { hasConflicts: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
@@ -373,7 +455,7 @@ export function createListHandler() {
|
||||
// Read all worktree metadata to get PR info
|
||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||
|
||||
// If includeDetails is requested, fetch change status for each worktree
|
||||
// If includeDetails is requested, fetch change status and conflict state for each worktree
|
||||
if (includeDetails) {
|
||||
for (const worktree of worktrees) {
|
||||
try {
|
||||
@@ -390,6 +472,18 @@ export function createListHandler() {
|
||||
worktree.hasChanges = false;
|
||||
worktree.changedFilesCount = 0;
|
||||
}
|
||||
|
||||
// Detect merge/rebase/cherry-pick in progress
|
||||
try {
|
||||
const conflictState = await detectConflictState(worktree.path);
|
||||
if (conflictState.hasConflicts) {
|
||||
worktree.hasConflicts = true;
|
||||
worktree.conflictType = conflictState.conflictType;
|
||||
worktree.conflictFiles = conflictState.conflictFiles;
|
||||
}
|
||||
} catch {
|
||||
// Ignore conflict detection errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
69
apps/server/src/routes/worktree/routes/stage-files.ts
Normal file
69
apps/server/src/routes/worktree/routes/stage-files.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* POST /stage-files endpoint - Stage or unstage files in a worktree
|
||||
*
|
||||
* Supports two operations:
|
||||
* 1. Stage files: `git add <files>` (adds files to the staging area)
|
||||
* 2. Unstage files: `git reset HEAD -- <files>` (removes files from staging area)
|
||||
*
|
||||
* 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';
|
||||
|
||||
export function createStageFilesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, files, operation } = req.body as {
|
||||
worktreePath: string;
|
||||
files: string[];
|
||||
operation: 'stage' | 'unstage';
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'files array required and must not be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation !== 'stage' && operation !== 'unstage') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'operation must be "stage" or "unstage"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === 'stage') {
|
||||
// Stage the specified files
|
||||
await execGitCommand(['add', '--', ...files], worktreePath);
|
||||
} else {
|
||||
// Unstage the specified files
|
||||
await execGitCommand(['reset', 'HEAD', '--', ...files], worktreePath);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
operation,
|
||||
filesCount: files.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function createStashApplyHandler(events: EventEmitter) {
|
||||
const result = await applyOrPop(worktreePath, idx, { pop }, events);
|
||||
|
||||
if (!result.success) {
|
||||
logError(new Error(result.error ?? 'Stash apply failed'), 'Stash apply failed');
|
||||
// applyOrPop already logs the error internally via logError — no need to double-log here
|
||||
res.status(500).json({ success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user