feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6

This commit is contained in:
gsxdsm
2026-02-18 18:58:33 -08:00
parent df9a6314da
commit 983eb21faa
66 changed files with 2317 additions and 823 deletions

View File

@@ -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;
}

View 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) });
}
};
}

View File

@@ -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,

View File

@@ -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;
}

View 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) });
}
};
}

View 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) });
}
};
}

View File

@@ -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
}
}
}

View 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) });
}
};
}

View File

@@ -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;
}