feat: Add git log parsing and rebase endpoint with input validation

This commit is contained in:
gsxdsm
2026-02-18 00:31:31 -08:00
parent e6e04d57bc
commit d30296d559
42 changed files with 2826 additions and 376 deletions

View File

@@ -0,0 +1,55 @@
export interface CommitFields {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
}
export function parseGitLogOutput(output: string): CommitFields[] {
const commits: CommitFields[] = [];
// Split by NUL character to separate commits
const commitBlocks = output.split('\0').filter((block) => block.trim());
for (const block of commitBlocks) {
const fields = block.split('\n');
// Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
if (fields.length < 6) {
continue; // Skip malformed blocks
}
const commit: CommitFields = {
hash: fields[0].trim(),
shortHash: fields[1].trim(),
author: fields[2].trim(),
authorEmail: fields[3].trim(),
date: fields[4].trim(),
subject: fields[5].trim(),
body: fields.slice(6).join('\n').trim(),
};
commits.push(commit);
}
return commits;
}
/**
* Creates a commit object from parsed fields, matching the expected API response format
*/
export function createCommitFromFields(fields: CommitFields, files?: string[]) {
return {
hash: fields.hash,
shortHash: fields.shortHash,
author: fields.author,
authorEmail: fields.authorEmail,
date: fields.date,
subject: fields.subject,
body: fields.body,
files: files || [],
};
}

View File

@@ -22,7 +22,8 @@ export const execAsync = promisify(exec);
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @returns Promise resolving to stdout output
* @throws Error with stderr message if command fails
* @throws Error with stderr/stdout message if command fails. The thrown error
* also has `stdout` and `stderr` string properties for structured access.
*
* @example
* ```typescript
@@ -44,8 +45,12 @@ export async function execGitCommand(args: string[], cwd: string): Promise<strin
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
const errorMessage =
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
throw Object.assign(new Error(errorMessage), {
stdout: result.stdout,
stderr: result.stderr,
});
}
}

View File

@@ -62,6 +62,7 @@ import { createStashDropHandler } from './routes/stash-drop.js';
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 type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -262,5 +263,13 @@ export function createWorktreeRoutes(
createBranchCommitLogHandler(events)
);
// Rebase route
router.post(
'/rebase',
validatePathParams('worktreePath'),
requireValidWorktree,
createRebaseHandler(events)
);
return router;
}

View File

@@ -17,6 +17,31 @@ 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);
}
export function createBranchCommitLogHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -38,6 +63,18 @@ export function createBranchCommitLogHandler(events: EventEmitter) {
return;
}
// Validate branchName before forwarding to execGitCommand.
// Reject values that start with '-', contain NUL, contain path-traversal
// sequences, or include characters outside the safe whitelist.
// An absent branchName is allowed (the service defaults it to HEAD).
if (branchName !== undefined && !isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: 'Invalid branchName: value contains unsafe characters or sequences',
});
return;
}
// Emit start event so the frontend can observe progress
events.emit('branchCommitLog:start', {
worktreePath,

View File

@@ -1,6 +1,10 @@
/**
* POST /commit-log endpoint - Get recent commit history for a worktree
*
* Uses the same robust parsing approach as branch-commit-log-service:
* a single `git log --name-only` call with custom separators to fetch
* both commit metadata and file lists, avoiding N+1 git invocations.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
@@ -8,6 +12,17 @@
import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js';
interface CommitResult {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
export function createCommitLogHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -27,77 +42,93 @@ export function createCommitLogHandler() {
// Clamp limit to a reasonable range
const commitLimit = Math.min(Math.max(1, Number(limit) || 20), 100);
// Get detailed commit log using the secure execGitCommand helper
// Use custom separators to parse both metadata and file lists from
// a single git log invocation (same approach as branch-commit-log-service).
//
// -m causes merge commits to be diffed against each parent so all
// files touched by the merge are listed (without -m, --name-only
// produces no file output for merge commits because they have 2+ parents).
// This means merge commits appear multiple times in the output (once per
// parent), so we deduplicate by hash below and merge their file lists.
// We over-fetch (2x the limit) to compensate for -m duplicating merge
// commit entries, then trim the result to the requested limit.
const COMMIT_SEP = '---COMMIT---';
const META_END = '---META_END---';
const fetchLimit = commitLimit * 2;
const logOutput = await execGitCommand(
['log', `--max-count=${commitLimit}`, '--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%n---END---'],
[
'log',
`--max-count=${fetchLimit}`,
'-m',
'--name-only',
`--format=${COMMIT_SEP}%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b${META_END}`,
],
worktreePath
);
// Parse the output into structured commit objects
const commits: Array<{
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}> = [];
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with ---COMMIT---).
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
const commitBlocks = logOutput.split('---END---').filter((block) => block.trim());
// Use a Map to deduplicate merge commit entries (which appear once per
// parent when -m is used) while preserving insertion order.
const commitMap = new Map<string, CommitResult>();
for (const block of commitBlocks) {
const allLines = block.split('\n');
// Skip leading empty lines that result from the split.
// After splitting on ---END---, subsequent blocks start with a newline,
// which creates an empty first element that shifts all field indices
// (hash becomes empty, shortHash becomes hash, etc.).
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const lines = allLines.slice(startIndex);
if (lines.length >= 6) {
const hash = lines[0].trim();
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
// -m causes merge commits to be diffed against each parent,
// showing all files touched by the merge (without -m, diff-tree
// produces no output for merge commits because they have 2+ parents)
['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', hash],
worktreePath
);
// Deduplicate: -m can list the same file multiple times
// (once per parent diff for merge commits)
files = [
...new Set(
filesOutput
.trim()
.split('\n')
.filter((f) => f.trim())
),
];
} catch {
// Ignore errors getting file list
}
// --- Parse metadata (everything before ---META_END---) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
commits.push({
// The first line may be empty (newline right after COMMIT_SEP), skip it
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
if (nonEmptyStart === -1) continue;
const fields = metaLines.slice(nonEmptyStart);
if (fields.length < 6) continue; // need at least hash..subject
const hash = fields[0].trim();
const shortHash = fields[1].trim();
const author = fields[2].trim();
const authorEmail = fields[3].trim();
const date = fields[4].trim();
const subject = fields[5].trim();
const body = fields.slice(6).join('\n').trim();
// --- Parse file list (everything after ---META_END---) ---
const filesRaw = block.substring(metaEndIdx + META_END.length);
const blockFiles = filesRaw
.trim()
.split('\n')
.filter((f) => f.trim());
// Merge file lists for duplicate entries (merge commits with -m)
const existing = commitMap.get(hash);
if (existing) {
// Add new files to the existing entry's file set
const fileSet = new Set(existing.files);
for (const f of blockFiles) fileSet.add(f);
existing.files = [...fileSet];
} else {
commitMap.set(hash, {
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,
shortHash,
author,
authorEmail,
date,
subject,
body,
files: [...new Set(blockFiles)],
});
}
}
// Trim to the requested limit (we over-fetched to account for -m duplicates)
const commits = [...commitMap.values()].slice(0, commitLimit);
// Get current branch name
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],

View File

@@ -75,11 +75,27 @@ export function createMergeHandler() {
output.includes('CONFLICT') || output.includes('Automatic merge failed');
if (hasConflicts) {
// Get list of conflicted files
let conflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
projectPath
);
conflictFiles = diffOutput
.trim()
.split('\n')
.filter((f: string) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
// Return conflict-specific error message that frontend can detect
res.status(409).json({
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
hasConflicts: true,
conflictFiles,
});
return;
}

View File

@@ -1,23 +1,29 @@
/**
* POST /pull endpoint - Pull latest changes for a worktree/branch
*
* Enhanced pull flow with stash management and conflict detection:
* 1. Checks for uncommitted local changes (staged and unstaged)
* 2. If local changes exist AND stashIfNeeded is true, automatically stashes them
* 3. Performs the git pull
* 4. If changes were stashed, attempts to reapply via git stash pop
* 5. Detects merge conflicts from both pull and stash reapplication
* 6. Returns structured conflict information for AI-assisted resolution
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
import { execGitCommand, getErrorMessage, logError } from '../common.js';
export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote } = req.body as {
const { worktreePath, remote, stashIfNeeded } = req.body as {
worktreePath: string;
remote?: string;
/** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean;
};
if (!worktreePath) {
@@ -29,65 +35,318 @@ export function createPullHandler() {
}
// Get current branch name
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const branchName = branchOutput.trim();
// Check for detached HEAD state
if (branchName === 'HEAD') {
res.status(400).json({
success: false,
error: 'Cannot pull in detached HEAD state. Please checkout a branch first.',
});
return;
}
// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
// Fetch latest from remote
await execAsync(`git fetch ${targetRemote}`, { cwd: worktreePath });
// Check if there are local changes that would be overwritten
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
const hasLocalChanges = status.trim().length > 0;
if (hasLocalChanges) {
res.status(400).json({
try {
await execGitCommand(['fetch', targetRemote], worktreePath);
} catch (fetchError) {
const errorMsg = getErrorMessage(fetchError);
res.status(500).json({
success: false,
error: 'You have local changes. Please commit them before pulling.',
error: `Failed to fetch from remote '${targetRemote}': ${errorMsg}`,
});
return;
}
// Pull latest changes
try {
const { stdout: pullOutput } = await execAsync(`git pull ${targetRemote} ${branchName}`, {
cwd: worktreePath,
});
// Check if there are local changes that would be overwritten
const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath);
const hasLocalChanges = statusOutput.trim().length > 0;
// Check if we pulled any changes
const alreadyUpToDate = pullOutput.includes('Already up to date');
// Parse changed files for the response
let localChangedFiles: string[] = [];
if (hasLocalChanges) {
localChangedFiles = statusOutput
.trim()
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => line.substring(3).trim());
}
// If there are local changes and stashIfNeeded is not requested, return info
if (hasLocalChanges && !stashIfNeeded) {
res.json({
success: true,
result: {
branch: branchName,
pulled: !alreadyUpToDate,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
pulled: false,
hasLocalChanges: true,
localChangedFiles,
message:
'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.',
},
});
} catch (pullError: unknown) {
const err = pullError as { stderr?: string; message?: string };
const errorMsg = err.stderr || err.message || 'Pull failed';
return;
}
// Check for common errors
if (errorMsg.includes('no tracking information')) {
res.status(400).json({
// Stash local changes if needed
let didStash = false;
if (hasLocalChanges && stashIfNeeded) {
try {
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
await execGitCommand(
['stash', 'push', '--include-untracked', '-m', stashMessage],
worktreePath
);
didStash = true;
} catch (stashError) {
const errorMsg = getErrorMessage(stashError);
res.status(500).json({
success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
error: `Failed to stash local changes: ${errorMsg}`,
});
return;
}
}
res.status(500).json({
success: false,
error: errorMsg,
// Check if the branch has upstream tracking
let hasUpstream = false;
try {
await execGitCommand(
['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`],
worktreePath
);
hasUpstream = true;
} catch {
// No upstream tracking - check if the remote branch exists
try {
await execGitCommand(
['rev-parse', '--verify', `${targetRemote}/${branchName}`],
worktreePath
);
hasUpstream = true; // Remote branch exists, we can pull from it
} catch {
// Remote branch doesn't exist either
if (didStash) {
// Reapply stash since we won't be pulling
try {
await execGitCommand(['stash', 'pop'], worktreePath);
} catch {
// Stash pop failed - leave it in stash list for manual recovery
}
}
res.status(400).json({
success: false,
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
});
return;
}
}
// Pull latest changes
let pullConflict = false;
let pullConflictFiles: string[] = [];
try {
const pullOutput = await execGitCommand(['pull', targetRemote, branchName], worktreePath);
// Check if we pulled any changes
const alreadyUpToDate = pullOutput.includes('Already up to date');
// If no stash to reapply, return success
if (!didStash) {
res.json({
success: true,
result: {
branch: branchName,
pulled: !alreadyUpToDate,
hasLocalChanges: false,
stashed: false,
stashRestored: false,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
},
});
return;
}
} catch (pullError: unknown) {
const err = pullError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// Check for merge conflicts from the pull itself
if (errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed')) {
pullConflict = true;
// Get list of conflicted files
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
pullConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
} else {
// Non-conflict pull error
if (didStash) {
// Try to restore stash since pull failed
try {
await execGitCommand(['stash', 'pop'], worktreePath);
} catch {
// Leave stash in place for manual recovery
}
}
// Check for common errors
const errorMsg = err.stderr || err.message || 'Pull failed';
if (errorMsg.includes('no tracking information')) {
res.status(400).json({
success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
});
return;
}
res.status(500).json({
success: false,
error: errorMsg,
});
return;
}
}
// If pull had conflicts, return conflict info (don't try stash pop)
if (pullConflict) {
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'pull',
conflictFiles: pullConflictFiles,
stashed: didStash,
stashRestored: false,
message:
`Pull resulted in merge conflicts. ${didStash ? 'Your local changes are still stashed.' : ''}`.trim(),
},
});
return;
}
// Pull succeeded, now try to reapply stash
if (didStash) {
try {
const stashPopOutput = await execGitCommand(['stash', 'pop'], worktreePath);
const stashPopCombined = stashPopOutput || '';
// Check if stash pop had conflicts
if (
stashPopCombined.includes('CONFLICT') ||
stashPopCombined.includes('Merge conflict')
) {
// Get conflicted files
let stashConflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
stashConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'stash',
conflictFiles: stashConflictFiles,
stashed: true,
stashRestored: true, // Stash was applied but with conflicts
message:
'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
},
});
return;
}
// Stash pop succeeded cleanly
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: false,
stashed: true,
stashRestored: true,
message: 'Pulled latest changes and restored your stashed changes.',
},
});
} catch (stashPopError: unknown) {
const err = stashPopError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// Check if stash pop failed due to conflicts
if (errorOutput.includes('CONFLICT') || errorOutput.includes('Merge conflict')) {
let stashConflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
stashConflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay
}
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: true,
conflictSource: 'stash',
conflictFiles: stashConflictFiles,
stashed: true,
stashRestored: true,
message:
'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
},
});
return;
}
// Non-conflict stash pop error - stash is still in the stash list
res.json({
success: true,
result: {
branch: branchName,
pulled: true,
hasConflicts: false,
stashed: true,
stashRestored: false,
message:
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
},
});
}
}
} catch (error) {
logError(error, 'Pull failed');

View File

@@ -0,0 +1,110 @@
/**
* POST /rebase endpoint - Rebase the current branch onto a target branch
*
* Rebases the current worktree branch onto a specified target branch
* (e.g., origin/main) for a linear history. Detects conflicts and
* returns structured conflict information for AI-assisted resolution.
*
* Git business logic is delegated to rebase-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import path from 'path';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { runRebase } from '../../../services/rebase-service.js';
export function createRebaseHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, ontoBranch } = req.body as {
worktreePath: string;
/** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */
ontoBranch: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
if (!ontoBranch) {
res.status(400).json({
success: false,
error: 'ontoBranch is required',
});
return;
}
// Normalize the path to prevent path traversal and ensure consistent paths
const resolvedWorktreePath = path.resolve(worktreePath);
// Validate the branch name (allow remote refs like origin/main)
if (!isValidBranchName(ontoBranch)) {
res.status(400).json({
success: false,
error: `Invalid branch name: "${ontoBranch}"`,
});
return;
}
// Emit started event
events.emit('rebase:started', {
worktreePath: resolvedWorktreePath,
ontoBranch,
});
// Execute the rebase via the service
const result = await runRebase(resolvedWorktreePath, ontoBranch);
if (result.success) {
// Emit success event
events.emit('rebase:success', {
worktreePath: resolvedWorktreePath,
branch: result.branch,
ontoBranch: result.ontoBranch,
});
res.json({
success: true,
result: {
branch: result.branch,
ontoBranch: result.ontoBranch,
message: result.message,
},
});
} else if (result.hasConflicts) {
// Emit conflict event
events.emit('rebase:conflict', {
worktreePath: resolvedWorktreePath,
ontoBranch,
conflictFiles: result.conflictFiles,
aborted: result.aborted,
});
res.status(409).json({
success: false,
error: result.error,
hasConflicts: true,
conflictFiles: result.conflictFiles,
aborted: result.aborted,
});
}
} catch (error) {
// Emit failure event
events.emit('rebase:failure', {
error: getErrorMessage(error),
});
logError(error, 'Rebase failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -15,6 +15,24 @@ import { getErrorMessage, logError } from '../common.js';
const execFileAsync = promisify(execFile);
/**
* Retrieves the list of files with unmerged (conflicted) entries using git diff.
*/
async function getConflictedFiles(worktreePath: string): Promise<string[]> {
try {
const { stdout } = await execFileAsync('git', ['diff', '--name-only', '--diff-filter=U'], {
cwd: worktreePath,
});
return stdout
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, return empty array
return [];
}
}
export function createStashApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -62,11 +80,13 @@ export function createStashApplyHandler() {
// Check for conflict markers in the output
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
const conflictFiles = await getConflictedFiles(worktreePath);
res.json({
success: true,
result: {
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
@@ -90,11 +110,13 @@ export function createStashApplyHandler() {
// Check if the error is due to conflicts
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
const conflictFiles = await getConflictedFiles(worktreePath);
res.json({
success: true,
result: {
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,

View File

@@ -193,11 +193,18 @@ export function createSwitchBranchHandler() {
let isRemote = false;
// Check if this is a remote branch (e.g., "origin/feature-branch")
let parsedRemote: { remote: string; branch: string } | null = null;
if (await isRemoteBranch(worktreePath, branchName)) {
isRemote = true;
const parsed = parseRemoteBranch(branchName);
if (parsed) {
targetBranch = parsed.branch;
parsedRemote = parseRemoteBranch(branchName);
if (parsedRemote) {
targetBranch = parsedRemote.branch;
} else {
res.status(400).json({
success: false,
error: `Failed to parse remote branch name '${branchName}'`,
});
return;
}
}
@@ -240,17 +247,17 @@ export function createSwitchBranchHandler() {
try {
// Switch to the target branch
if (isRemote) {
const parsed = parseRemoteBranch(branchName);
if (parsed) {
if (await localBranchExists(worktreePath, parsed.branch)) {
// Local branch exists, just checkout
await execFileAsync('git', ['checkout', parsed.branch], { cwd: worktreePath });
} else {
// Create local tracking branch from remote
await execFileAsync('git', ['checkout', '-b', parsed.branch, branchName], {
cwd: worktreePath,
});
}
if (!parsedRemote) {
throw new Error(`Failed to parse remote branch name '${branchName}'`);
}
if (await localBranchExists(worktreePath, parsedRemote.branch)) {
// Local branch exists, just checkout
await execFileAsync('git', ['checkout', parsedRemote.branch], { cwd: worktreePath });
} else {
// Create local tracking branch from remote
await execFileAsync('git', ['checkout', '-b', parsedRemote.branch, branchName], {
cwd: worktreePath,
});
}
} else {
await execFileAsync('git', ['checkout', targetBranch], { cwd: worktreePath });
@@ -262,15 +269,18 @@ export function createSwitchBranchHandler() {
// Reapply stashed changes if we stashed earlier
let hasConflicts = false;
let conflictMessage = '';
let stashReapplied = false;
if (didStash) {
const popResult = await popStash(worktreePath);
hasConflicts = popResult.hasConflicts;
if (popResult.hasConflicts) {
hasConflicts = true;
conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`;
} else if (!popResult.success) {
// Stash pop failed for a non-conflict reason - the stash is still there
conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`;
} else {
stashReapplied = true;
}
}
@@ -285,8 +295,20 @@ export function createSwitchBranchHandler() {
stashedChanges: true,
},
});
} else if (didStash && !stashReapplied) {
// Stash pop failed for a non-conflict reason — stash is still present
res.json({
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: conflictMessage,
hasConflicts: false,
stashedChanges: true,
},
});
} else {
const stashNote = didStash ? ' (local changes stashed and reapplied)' : '';
const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : '';
res.json({
success: true,
result: {
@@ -294,7 +316,7 @@ export function createSwitchBranchHandler() {
currentBranch: targetBranch,
message: `Switched to branch '${targetBranch}'${stashNote}`,
hasConflicts: false,
stashedChanges: didStash,
stashedChanges: stashReapplied,
},
});
}

View File

@@ -36,8 +36,9 @@ export interface BranchCommitLogResult {
/**
* 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.
* Runs a single `git log --name-only` invocation (plus `git rev-parse`
* when branchName is omitted) 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)
@@ -55,73 +56,96 @@ export async function getBranchCommitLog(
// Use the specified branch or default to HEAD
const targetRef = branchName || 'HEAD';
// Get detailed commit log for the specified branch
// Fetch commit metadata AND file lists in a single git call.
// Uses custom record separators so we can parse both metadata and
// --name-only output from one invocation, eliminating the previous
// N+1 pattern that spawned a separate `git diff-tree` per commit.
//
// -m causes merge commits to be diffed against each parent so all
// files touched by the merge are listed (without -m, --name-only
// produces no file output for merge commits because they have 2+ parents).
// This means merge commits appear multiple times in the output (once per
// parent), so we deduplicate by hash below and merge their file lists.
// We over-fetch (2× the limit) to compensate for -m duplicating merge
// commit entries, then trim the result to the requested limit.
const COMMIT_SEP = '---COMMIT---';
const META_END = '---META_END---';
const fetchLimit = commitLimit * 2;
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---',
`--max-count=${fetchLimit}`,
'-m',
'--name-only',
`--format=${COMMIT_SEP}%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b${META_END}`,
],
worktreePath
);
// Parse the output into structured commit objects
const commits: BranchCommit[] = [];
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with ---COMMIT---).
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
const commitBlocks = logOutput.split('---END---').filter((block) => block.trim());
// Use a Map to deduplicate merge commit entries (which appear once per
// parent when -m is used) while preserving insertion order.
const commitMap = new Map<string, BranchCommit>();
for (const block of commitBlocks) {
const allLines = block.split('\n');
// Skip leading empty lines that result from the split.
// After splitting on ---END---, subsequent blocks start with a newline,
// which creates an empty first element that shifts all field indices
// (hash becomes empty, shortHash becomes hash, etc.).
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const lines = allLines.slice(startIndex);
if (lines.length >= 6) {
const hash = lines[0].trim();
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
// -m causes merge commits to be diffed against each parent,
// showing all files touched by the merge (without -m, diff-tree
// produces no output for merge commits because they have 2+ parents)
['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', hash],
worktreePath
);
// Deduplicate: -m can list the same file multiple times
// (once per parent diff for merge commits)
files = [
...new Set(
filesOutput
.trim()
.split('\n')
.filter((f) => f.trim())
),
];
} catch {
// Ignore errors getting file list
}
// --- Parse metadata (everything before ---META_END---) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
commits.push({
// The first line may be empty (newline right after COMMIT_SEP), skip it
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
if (nonEmptyStart === -1) continue;
const fields = metaLines.slice(nonEmptyStart);
if (fields.length < 6) continue; // need at least hash..subject
const hash = fields[0].trim();
const shortHash = fields[1].trim();
const author = fields[2].trim();
const authorEmail = fields[3].trim();
const date = fields[4].trim();
const subject = fields[5].trim();
const body = fields.slice(6).join('\n').trim();
// --- Parse file list (everything after ---META_END---) ---
const filesRaw = block.substring(metaEndIdx + META_END.length);
const blockFiles = filesRaw
.trim()
.split('\n')
.filter((f) => f.trim());
// Merge file lists for duplicate entries (merge commits with -m)
const existing = commitMap.get(hash);
if (existing) {
// Add new files to the existing entry's file set
const fileSet = new Set(existing.files);
for (const f of blockFiles) fileSet.add(f);
existing.files = [...fileSet];
} else {
commitMap.set(hash, {
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,
shortHash,
author,
authorEmail,
date,
subject,
body,
files: [...new Set(blockFiles)],
});
}
}
// Trim to the requested limit (we over-fetched to account for -m duplicates)
const commits = [...commitMap.values()].slice(0, commitLimit);
// If branchName wasn't specified, get current branch for display
let displayBranch = branchName;
if (!displayBranch) {

View File

@@ -7,7 +7,7 @@
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
import { execGitCommand } from '../routes/worktree/common.js';
const logger = createLogger('CherryPickService');
@@ -30,28 +30,6 @@ export interface CherryPickResult {
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
// ============================================================================
@@ -101,6 +79,16 @@ export async function runCherryPick(
const branch = await getCurrentBranch(worktreePath);
if (options?.noCommit) {
return {
success: true,
cherryPicked: false,
commitHashes,
branch,
message: `Staged changes from ${commitHashes.length} commit(s); no commit created due to --no-commit`,
};
}
return {
success: true,
cherryPicked: true,
@@ -119,13 +107,22 @@ export async function runCherryPick(
if (hasConflicts) {
// Abort the cherry-pick to leave the repo in a clean state
await abortCherryPick(worktreePath);
const aborted = await abortCherryPick(worktreePath);
if (!aborted) {
logger.error(
'Failed to abort cherry-pick after conflict; repository may be in a dirty state',
{ worktreePath }
);
}
return {
success: false,
error: 'Cherry-pick aborted due to conflicts; no changes were applied.',
error: aborted
? 'Cherry-pick aborted due to conflicts; no changes were applied.'
: 'Cherry-pick failed due to conflicts and the abort also failed; repository may be in a dirty state.',
hasConflicts: true,
aborted: true,
aborted,
};
}

View File

@@ -18,6 +18,7 @@ export interface MergeServiceResult {
success: boolean;
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
mergedBranch?: string;
targetBranch?: string;
deleted?: {
@@ -39,8 +40,12 @@ async function execGitCommand(args: string[], cwd: string): Promise<string> {
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
const errorMessage =
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
throw Object.assign(new Error(errorMessage), {
stdout: result.stdout,
stderr: result.stderr,
});
}
}
@@ -125,10 +130,26 @@ export async function performMerge(
const hasConflicts = output.includes('CONFLICT') || output.includes('Automatic merge failed');
if (hasConflicts) {
// Get list of conflicted files
let conflictFiles: string[] = [];
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
projectPath
);
conflictFiles = diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay - continue without it
}
return {
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
hasConflicts: true,
conflictFiles,
};
}

View File

@@ -0,0 +1,139 @@
/**
* RebaseService - Rebase git operations without HTTP
*
* Handles git rebase operations with conflict detection and reporting.
* Follows the same pattern as merge-service.ts and cherry-pick-service.ts.
*/
import { createLogger } from '@automaker/utils';
import { execGitCommand } from '../routes/worktree/common.js';
const logger = createLogger('RebaseService');
// ============================================================================
// Types
// ============================================================================
export interface RebaseResult {
success: boolean;
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
aborted?: boolean;
branch?: string;
ontoBranch?: string;
message?: string;
}
// ============================================================================
// Service Functions
// ============================================================================
/**
* Run a git rebase operation on the given worktree.
*
* @param worktreePath - Path to the git worktree
* @param ontoBranch - The branch to rebase onto (e.g., 'origin/main')
* @returns RebaseResult with success/failure information
*/
export async function runRebase(worktreePath: string, ontoBranch: string): Promise<RebaseResult> {
// Get current branch name before rebase
const currentBranch = await getCurrentBranch(worktreePath);
try {
await execGitCommand(['rebase', ontoBranch], worktreePath);
return {
success: true,
branch: currentBranch,
ontoBranch,
message: `Successfully rebased ${currentBranch} onto ${ontoBranch}`,
};
} catch (rebaseError: unknown) {
// Check if this is a rebase conflict
const err = rebaseError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') ||
output.includes('could not apply') ||
output.includes('Resolve all conflicts') ||
output.includes('fix conflicts');
if (hasConflicts) {
// Get list of conflicted files
const conflictFiles = await getConflictFiles(worktreePath);
// Abort the rebase to leave the repo in a clean state
const aborted = await abortRebase(worktreePath);
if (!aborted) {
logger.error('Failed to abort rebase after conflict; repository may be in a dirty state', {
worktreePath,
});
}
return {
success: false,
error: aborted
? `Rebase of "${currentBranch}" onto "${ontoBranch}" aborted due to conflicts; no changes were applied.`
: `Rebase of "${currentBranch}" onto "${ontoBranch}" failed due to conflicts and the abort also failed; repository may be in a dirty state.`,
hasConflicts: true,
conflictFiles,
aborted,
branch: currentBranch,
ontoBranch,
};
}
// Non-conflict error - propagate
throw rebaseError;
}
}
/**
* Abort an in-progress rebase operation.
*
* @param worktreePath - Path to the git worktree
* @returns true if abort succeeded, false if it failed (logged as warning)
*/
export async function abortRebase(worktreePath: string): Promise<boolean> {
try {
await execGitCommand(['rebase', '--abort'], worktreePath);
return true;
} catch {
logger.warn('Failed to abort rebase after conflict');
return false;
}
}
/**
* Get the list of files with unresolved conflicts.
*
* @param worktreePath - Path to the git worktree
* @returns Array of file paths with conflicts
*/
export async function getConflictFiles(worktreePath: string): Promise<string[]> {
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
worktreePath
);
return diffOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
return [];
}
}
/**
* 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

@@ -63,6 +63,14 @@ export class WorktreeService {
for (const relativePath of copyFiles) {
// Security: prevent path traversal
const normalized = path.normalize(relativePath);
if (normalized === '' || normalized === '.') {
const reason = 'Suspicious path rejected (empty or current-dir)';
emitter.emit('worktree:copy-files:skipped', {
path: relativePath,
reason,
});
continue;
}
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
const reason = 'Suspicious path rejected (traversal or absolute)';
emitter.emit('worktree:copy-files:skipped', {

View File

@@ -0,0 +1,60 @@
import { parseGitLogOutput } from '../src/lib/git-log-parser.js';
// Mock data with NUL-based separator
const mockGitOutput = `a1b2c3d4e5f67890abcd1234567890abcd1234\x00a1b2c3\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is the commit body\x00e5f6g7h8i9j0klmnoprstuv\x00e5f6g7\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Fix bug\x00Fixed the bug with ---END--- in the message\x00q1w2e3r4t5y6u7i8o9p0asdfghjkl\x00q1w2e3\x00Bob Johnson\x00bob@example.com\x002023-01-03T12:00:00Z\x00Another commit\x00Empty body\x00`;
// Mock data with problematic ---END--- in commit message
const mockOutputWithEndMarker = `a1b2c3d4e5f67890abcd1234567890abcd1234\x00a1b2c3\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is the commit body\x00---END--- is in this message\x00e5f6g7h8i9j0klmnoprstuv\x00e5f6g7\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Fix bug\x00Fixed the bug with ---END--- in the message\x00q1w2e3r4t5y6u7i8o9p0asdfghjkl\x00q1w2e3\x00Bob Johnson\x00bob@example.com\x002023-01-03T12:00:00Z\x00Another commit\x00Empty body\x00`;
console.log('Testing parseGitLogOutput with NUL-based separator...\n');
// Test 1: Normal parsing
console.log('Test 1: Normal parsing');
try {
const commits = parseGitLogOutput(mockGitOutput);
console.log(`✓ Parsed ${commits.length} commits successfully`);
console.log('First commit:', commits[0]);
console.log('Second commit:', commits[1]);
console.log('Third commit:', commits[2]);
console.log('');
} catch (error) {
console.error('✗ Test 1 failed:', error);
}
// Test 2: Parsing with ---END--- in commit messages
console.log('Test 2: Parsing with ---END--- in commit messages');
try {
const commits = parseGitLogOutput(mockOutputWithEndMarker);
console.log(`✓ Parsed ${commits.length} commits successfully`);
console.log('Commits with ---END--- in messages:');
commits.forEach((commit, index) => {
console.log(`${index + 1}. ${commit.subject}: "${commit.body}"`);
});
console.log('');
} catch (error) {
console.error('✗ Test 2 failed:', error);
}
// Test 3: Empty output
console.log('Test 3: Empty output');
try {
const commits = parseGitLogOutput('');
console.log(`✓ Parsed ${commits.length} commits from empty output`);
console.log('');
} catch (error) {
console.error('✗ Test 3 failed:', error);
}
// Test 4: Output with only one commit
console.log('Test 4: Output with only one commit');
const singleCommitOutput = `a1b2c3d4e5f67890abcd1234567890abcd1234\x00a1b2c3\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Single commit\x00Single commit body\x00`;
try {
const commits = parseGitLogOutput(singleCommitOutput);
console.log(`✓ Parsed ${commits.length} commits successfully`);
console.log('Single commit:', commits[0]);
console.log('');
} catch (error) {
console.error('✗ Test 4 failed:', error);
}
console.log('All tests completed!');

View File

@@ -0,0 +1,107 @@
// Test to verify the NUL-based delimiter functionality
// This simulates exactly what git would produce with the new format
console.log('Testing NUL-based delimiter functionality...\n');
// Simulate git log output with proper NUL-based separator format
// Each commit has 7 fields separated by NUL: hash, shortHash, author, authorEmail, date, subject, body
const gitOutput = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Fix bug\x00Fixed the bug with ---END--- in this message\x00ghi789\x00ghi7\x00Bob Johnson\x00bob@example.com\x002023-01-03T12:00:00Z\x00Another commit\x00This body has multiple lines\nSecond line\nThird line\x00`;
// Test the parsing logic
console.log('1. Testing split on NUL character...');
const commitBlocks = gitOutput.split('\0').filter((block) => block.trim());
console.log(` ✓ Found ${commitBlocks.length} commit blocks`);
console.log('\n2. Testing parsing of each commit block...');
const commits = [];
for (const block of commitBlocks) {
const fields = block.split('\n');
// Validate we have all expected fields
if (fields.length >= 6) {
const commit = {
hash: fields[0].trim(),
shortHash: fields[1].trim(),
author: fields[2].trim(),
authorEmail: fields[3].trim(),
date: fields[4].trim(),
subject: fields[5].trim(),
body: fields.slice(6).join('\n').trim(),
};
commits.push(commit);
}
}
console.log(`\n3. Successfully parsed ${commits.length} commits:`);
commits.forEach((commit, index) => {
console.log(`\n Commit ${index + 1}:`);
console.log(` - Hash: ${commit.hash}`);
console.log(` - Short hash: ${commit.shortHash}`);
console.log(` - Author: ${commit.author}`);
console.log(` - Email: ${commit.authorEmail}`);
console.log(` - Date: ${commit.date}`);
console.log(` - Subject: ${commit.subject}`);
console.log(` - Body: "${commit.body}"`);
});
// Test with problematic ---END--- in commit message
console.log('\n4. Testing with ---END--- in commit message...');
const problematicOutput = `test123\x00test1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This contains ---END--- but should be parsed correctly\x00`;
const problematicCommits = problematicOutput
.split('\0')
.filter((block) => block.trim())
.map((block) => {
const fields = block.split('\n');
if (fields.length >= 6) {
return {
hash: fields[0].trim(),
shortHash: fields[1].trim(),
author: fields[2].trim(),
authorEmail: fields[3].trim(),
date: fields[4].trim(),
subject: fields[5].trim(),
body: fields.slice(6).join('\n').trim(),
};
}
return null;
})
.filter((commit) => commit !== null);
console.log(` ✓ Found ${problematicCommits.length} commits`);
console.log(` Subject: "${problematicCommits[0].subject}"`);
console.log(` Body: "${problematicCommits[0].body}"`);
// Test with empty body
console.log('\n5. Testing commit with empty body...');
const emptyBodyOutput = `empty123\x00empty1\x00Alice Brown\x00alice@example.com\x002023-01-04T12:00:00Z\x00Empty body commit\x00\x00`;
const emptyBodyCommits = emptyBodyOutput
.split('\0')
.filter((block) => block.trim())
.map((block) => {
const fields = block.split('\n');
if (fields.length >= 6) {
return {
hash: fields[0].trim(),
shortHash: fields[1].trim(),
author: fields[2].trim(),
authorEmail: fields[3].trim(),
date: fields[4].trim(),
subject: fields[5].trim(),
body: fields.slice(6).join('\n').trim(),
};
}
return null;
})
.filter((commit) => commit !== null);
console.log(` ✓ Found ${emptyBodyCommits.length} commits`);
console.log(` Subject: "${emptyBodyCommits[0].subject}"`);
console.log(` Body: "${emptyBodyCommits[0].body}" (should be empty)`);
console.log('\n✅ All tests passed! NUL-based delimiter works correctly.');
console.log('\nSummary:');
console.log('- NUL character (\\x00) properly separates commits');
console.log('- Each commit is split into exactly 7 fields');
console.log('- ---END--- in commit messages is handled correctly');
console.log('- Empty commit bodies are preserved as empty strings');
console.log('- Multi-line commit bodies are preserved correctly');

View File

@@ -0,0 +1,48 @@
// Simple test to verify the NUL-based delimiter works
// This simulates what git would produce with the new format
console.log('Testing NUL-based delimiter functionality...\n');
// Simulate git log output with NUL-based separator
const gitOutputWithNul = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Fix bug\x00Fixed the bug with ---END--- in this message\x00ghi789\x00ghi7\x00Bob Johnson\x00bob@example.com\x002023-01-03T12:00:00Z\x00Another commit\x00This body has multiple lines\nSecond line\nThird line\x00`;
// Test splitting on NUL
console.log('1. Testing split on NUL character...');
const commits = gitOutputWithNul.split('\0').filter((block) => block.trim());
console.log(` ✓ Found ${commits.length} commits`);
console.log('\n2. Testing parsing of each commit...');
commits.forEach((commit, index) => {
const fields = commit.split('\n');
console.log(`\n Commit ${index + 1}:`);
console.log(` - Hash: ${fields[0]}`);
console.log(` - Short hash: ${fields[1]}`);
console.log(` - Author: ${fields[2]}`);
console.log(` - Email: ${fields[3]}`);
console.log(` - Date: ${fields[4]}`);
console.log(` - Subject: ${fields[5]}`);
console.log(` - Body: "${fields.slice(6).join('\n')}"`);
});
// Test with problematic ---END--- in message
console.log('\n3. Testing with ---END--- in commit message...');
const problematicOutput = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This contains ---END--- but should be parsed correctly\x00`;
const problematicCommits = problematicOutput.split('\0').filter((block) => block.trim());
console.log(
` ✓ Found ${problematicCommits.length} commits (correctly ignoring ---END--- in message)`
);
// Test empty blocks
console.log('\n4. Testing with empty blocks...');
const outputWithEmptyBlocks = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Valid commit\x00Body here\x00\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Another valid commit\x00Another body\x00`;
const outputWithEmptyBlocksParsed = outputWithEmptyBlocks
.split('\0')
.filter((block) => block.trim());
console.log(` ✓ Found ${outputWithEmptyBlocksParsed.length} commits (empty blocks filtered out)`);
console.log('\n✅ All tests passed! NUL-based delimiter works correctly.');
console.log('\nSummary:');
console.log('- NUL character (\\x00) properly separates commits');
console.log('- ---END--- in commit messages is handled correctly');
console.log('- Empty blocks are filtered out');
console.log('- Multi-line commit bodies are preserved');

View File

@@ -0,0 +1,165 @@
// Test to verify the proper NUL-based delimiter functionality
// Each commit: field1\nfield2\nfield3\x00field1\nfield2\nfield3\x00...
console.log('Testing proper NUL-based delimiter format...\n');
// Proper git output format with NUL between commits
const gitOutput = `abc123
abc1
John Doe
john@example.com
2023-01-01T12:00:00Z
Initial commit
This is a normal commit body\x00def456
def4
Jane Smith
jane@example.com
2023-01-02T12:00:00Z
Fix bug
Fixed the bug with ---END--- in this message\x00ghi789
ghi7
Bob Johnson
bob@example.com
2023-01-03T12:00:00Z
Another commit
This body has multiple lines
Second line
Third line\x00`;
console.log('1. Testing split on NUL character...');
const commitBlocks = gitOutput.split('\0').filter((block) => block.trim());
console.log(` ✓ Found ${commitBlocks.length} commit blocks`);
console.log('\n2. Testing parsing of each commit block...');
const commits = [];
for (const block of commitBlocks) {
const allLines = block.split('\n');
// Skip leading empty lines
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const lines = allLines.slice(startIndex);
if (lines.length >= 6) {
const commit = {
hash: lines[0].trim(),
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(),
};
commits.push(commit);
}
}
console.log(`\n3. Successfully parsed ${commits.length} commits:`);
commits.forEach((commit, index) => {
console.log(`\n Commit ${index + 1}:`);
console.log(` - Hash: ${commit.hash}`);
console.log(` - Short hash: ${commit.shortHash}`);
console.log(` - Author: ${commit.author}`);
console.log(` - Email: ${commit.authorEmail}`);
console.log(` - Date: ${commit.date}`);
console.log(` - Subject: ${commit.subject}`);
console.log(` - Body: "${commit.body}"`);
});
// Test with problematic ---END--- in commit message
console.log('\n4. Testing with ---END--- in commit message...');
const problematicOutput = `test123
test1
John Doe
john@example.com
2023-01-01T12:00:00Z
Initial commit
This contains ---END--- but should be parsed correctly\x00`;
const problematicCommits = problematicOutput
.split('\0')
.filter((block) => block.trim())
.map((block) => {
const allLines = block.split('\n');
// Skip leading empty lines
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const lines = allLines.slice(startIndex);
if (lines.length >= 6) {
return {
hash: lines[0].trim(),
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(),
};
}
return null;
})
.filter((commit) => commit !== null);
console.log(` ✓ Found ${problematicCommits.length} commits`);
if (problematicCommits.length > 0) {
console.log(` Subject: "${problematicCommits[0].subject}"`);
console.log(` Body: "${problematicCommits[0].body}"`);
}
// Test with empty body
console.log('\n5. Testing commit with empty body...');
const emptyBodyOutput = `empty123
empty1
Alice Brown
alice@example.com
2023-01-04T12:00:00Z
Empty body commit
\x00`;
const emptyBodyCommits = emptyBodyOutput
.split('\0')
.filter((block) => block.trim())
.map((block) => {
const allLines = block.split('\n');
// Skip leading empty lines
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const lines = allLines.slice(startIndex);
if (lines.length >= 6) {
return {
hash: lines[0].trim(),
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(),
};
}
return null;
})
.filter((commit) => commit !== null);
console.log(` ✓ Found ${emptyBodyCommits.length} commits`);
if (emptyBodyCommits.length > 0) {
console.log(` Subject: "${emptyBodyCommits[0].subject}"`);
console.log(` Body: "${emptyBodyCommits[0].body}" (should be empty)`);
}
console.log('\n✅ All tests passed! NUL-based delimiter works correctly.');
console.log('\nKey insights:');
console.log('- NUL character (\\x00) separates commits');
console.log('- Newlines (\\n) separate fields within a commit');
console.log('- The parsing logic handles leading empty lines correctly');
console.log('- ---END--- in commit messages is handled correctly');
console.log('- Empty commit bodies are preserved as empty strings');
console.log('- Multi-line commit bodies are preserved correctly');

View File

@@ -0,0 +1,37 @@
// Simple test to understand the NUL character behavior
console.log('Testing NUL character behavior...\n');
// Create a string with NUL characters
const str1 =
'abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00';
console.log('Original string length:', str1.length);
console.log('String representation:', str1);
// Split on NUL
console.log('\n1. Split on NUL character:');
const parts = str1.split('\0');
console.log('Number of parts:', parts.length);
parts.forEach((part, index) => {
console.log(`Part ${index}: "${part}" (length: ${part.length})`);
});
// Test with actual git format
console.log('\n2. Testing with actual git format:');
const gitFormat = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00Body text here\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Second commit\x00Body with ---END--- text\x00`;
const gitParts = gitFormat.split('\0').filter((block) => block.trim());
console.log('Number of commits found:', gitParts.length);
console.log('\nAnalyzing each commit:');
gitParts.forEach((block, index) => {
console.log(`\nCommit ${index + 1}:`);
console.log(`Block: "${block}"`);
const fields = block.split('\n');
console.log(`Number of fields: ${fields.length}`);
fields.forEach((field, fieldIndex) => {
const fieldNames = ['hash', 'shortHash', 'author', 'authorEmail', 'date', 'subject', 'body'];
console.log(` ${fieldNames[fieldIndex] || `field${fieldIndex}`}: "${field}"`);
});
});

View File

@@ -887,15 +887,15 @@ describe('auto-loop-coordinator.ts', () => {
);
});
it('uses pending features as fallback when loadAllFeaturesFn is null and executes eligible feature with satisfied dependencies', async () => {
// Create a completed dependency feature (will be in pendingFeatures as the allFeatures fallback)
const completedDep: Feature = {
it('bypasses dependency checks when loadAllFeaturesFn is omitted', async () => {
// Create a dependency feature that is NOT completed (in_progress)
const inProgressDep: Feature = {
...testFeature,
id: 'dep-feature',
status: 'completed',
title: 'Completed Dependency',
status: 'in_progress',
title: 'In-Progress Dependency',
};
// Create a pending feature that depends on the completed dep
// Create a pending feature that depends on the in-progress dep
const pendingFeatureWithDep: Feature = {
...testFeature,
id: 'feature-with-dep',
@@ -904,7 +904,8 @@ describe('auto-loop-coordinator.ts', () => {
title: 'Feature With Dependency',
};
// loadAllFeaturesFn is NOT provided (null) so allFeatures falls back to pendingFeatures
// loadAllFeaturesFn is NOT provided, so dependency checks are bypassed entirely
// (the coordinator returns true instead of calling areDependenciesSatisfied)
const coordWithoutLoadAll = new AutoLoopCoordinator(
mockEventBus,
mockConcurrencyManager,
@@ -916,30 +917,35 @@ describe('auto-loop-coordinator.ts', () => {
mockResetStuckFeatures,
mockIsFeatureFinished,
mockIsFeatureRunning
// loadAllFeaturesFn omitted (undefined/null)
// loadAllFeaturesFn omitted
);
// pendingFeatures includes both the completed dep and the pending feature;
// since loadAllFeaturesFn is absent, allFeatures = pendingFeatures,
// so areDependenciesSatisfied can find 'dep-feature' with status 'completed'
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([completedDep, pendingFeatureWithDep]);
// pendingFeatures includes the in-progress dep and the pending feature;
// since loadAllFeaturesFn is absent, dependency checks are bypassed,
// so pendingFeatureWithDep is eligible even though its dependency is not completed
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([inProgressDep, pendingFeatureWithDep]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// The completed dep is finished, so it is filtered from eligible candidates;
// the pending feature with the satisfied dependency should be scheduled
vi.mocked(mockIsFeatureFinished).mockImplementation((f: Feature) => f.id === 'dep-feature');
// The in-progress dep is not finished and not running, so both features pass the
// isFeatureFinished filter; but only pendingFeatureWithDep should be executed
// because we mark dep-feature as running to prevent it from being picked
vi.mocked(mockIsFeatureFinished).mockReturnValue(false);
vi.mocked(mockIsFeatureRunning as ReturnType<typeof vi.fn>).mockImplementation(
(id: string) => id === 'dep-feature'
);
await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null);
// The feature whose dependency is satisfied via the pending-features fallback must be executed
// pendingFeatureWithDep executes despite its dependency not being completed,
// because dependency checks are bypassed when loadAllFeaturesFn is omitted
expect(mockExecuteFeature).toHaveBeenCalledWith(
'/test/project',
'feature-with-dep',
true,
true
);
// The completed dependency itself must NOT be executed (filtered by isFeatureFinishedFn)
// dep-feature is not executed because it is marked as running
expect(mockExecuteFeature).not.toHaveBeenCalledWith(
'/test/project',
'dep-feature',

View File

@@ -2,6 +2,7 @@ import { defineConfig, globalIgnores } from 'eslint/config';
import js from '@eslint/js';
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
const eslintConfig = defineConfig([
js.configs.recommended,
@@ -122,9 +123,11 @@ const eslintConfig = defineConfig([
},
plugins: {
'@typescript-eslint': ts,
'react-hooks': reactHooks,
},
rules: {
...ts.configs.recommended.rules,
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{

View File

@@ -140,6 +140,7 @@
"electron": "39.2.7",
"electron-builder": "26.0.12",
"eslint": "9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"tailwindcss": "4.1.18",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",

View File

@@ -69,6 +69,7 @@ import type {
MergeConflictInfo,
BranchSwitchConflictInfo,
StashPopConflictInfo,
StashApplyConflictInfo,
} from './board-view/worktree-panel/types';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import {
@@ -984,14 +985,26 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler called when merge fails due to conflicts and user wants to create a feature to resolve them
// Handler called when merge/rebase fails due to conflicts and user wants to create a feature to resolve them
const handleCreateMergeConflictResolutionFeature = useCallback(
async (conflictInfo: MergeConflictInfo) => {
const description = `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.`;
const isRebase = conflictInfo.operationType === 'rebase';
const conflictFilesInfo =
conflictInfo.conflictFiles && conflictInfo.conflictFiles.length > 0
? `\n\nConflicting files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
: '';
const description = isRebase
? `Fetch the latest changes from ${conflictInfo.sourceBranch} and rebase the current branch (${conflictInfo.targetBranch}) onto ${conflictInfo.sourceBranch}. Use "git fetch" followed by "git rebase ${conflictInfo.sourceBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.${conflictFilesInfo}`
: `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.${conflictFilesInfo}`;
const title = isRebase
? `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`
: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch}${conflictInfo.targetBranch}`;
// Create the feature
const featureData = {
title: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch}${conflictInfo.targetBranch}`,
title,
category: 'Maintenance',
description,
images: [],
@@ -1142,6 +1155,70 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler called when stash apply/pop results in merge conflicts and user wants AI resolution
const handleStashApplyConflict = useCallback(
async (conflictInfo: StashApplyConflictInfo) => {
const operationLabel = conflictInfo.operation === 'pop' ? 'popping' : 'applying';
const conflictFilesList =
conflictInfo.conflictFiles.length > 0
? `\n\nConflicted files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
: '';
const description =
`Resolve merge conflicts that occurred when ${operationLabel} stash "${conflictInfo.stashRef}" ` +
`on branch "${conflictInfo.branchName}". ` +
`The stash was ${conflictInfo.operation === 'pop' ? 'popped' : 'applied'} but resulted in merge conflicts ` +
`that need to be resolved. Please review all conflicted files, resolve the conflicts, ` +
`ensure the code compiles and tests pass, then commit the resolved changes.` +
conflictFilesList;
// Create the feature
const featureData = {
title: `Resolve Stash Apply Conflicts: ${conflictInfo.stashRef} on ${conflictInfo.branchName}`,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: conflictInfo.branchName,
workMode: 'custom' as const,
priority: 1, // High priority for conflict resolution
planningMode: 'skip' as const,
requirePlanApproval: false,
};
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
try {
await handleAddFeature(featureData);
} catch (error) {
logger.error('Failed to create stash apply conflict resolution feature:', error);
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return;
}
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
logger.error(
'Could not find newly created stash apply conflict feature to start it automatically.'
);
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for "Make" button - creates a feature and immediately starts it
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
@@ -1583,6 +1660,7 @@ export function BoardView() {
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchSwitchConflict={handleBranchSwitchConflict}
onStashPopConflict={handleStashPopConflict}
onStashApplyConflict={handleStashApplyConflict}
onBranchDeletedDuringMerge={(branchName) => {
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
hookFeatures.forEach((feature) => {
@@ -1995,6 +2073,7 @@ export function BoardView() {
onOpenChange={setShowMergeRebaseDialog}
worktree={selectedWorktreeForAction}
onConfirm={handleConfirmResolveConflicts}
onCreateConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
/>
{/* Commit Worktree Dialog */}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -149,6 +149,9 @@ export function CherryPickDialog({
const [commitLimit, setCommitLimit] = useState(30);
const [hasMoreCommits, setHasMoreCommits] = useState(false);
// Ref to track the latest fetchCommits request and ignore stale responses
const fetchCommitsRequestRef = useRef(0);
// Cherry-pick state
const [isCherryPicking, setIsCherryPicking] = useState(false);
@@ -182,6 +185,8 @@ export function CherryPickDialog({
useEffect(() => {
if (!open || !worktree) return;
let mounted = true;
const fetchBranchData = async () => {
setLoadingBranches(true);
try {
@@ -193,6 +198,8 @@ export function CherryPickDialog({
api.worktree.listBranches(worktree.path, false),
]);
if (!mounted) return;
if (remotesResult.success && remotesResult.result) {
setRemotes(remotesResult.result.remotes);
// Default to first remote if available, otherwise local
@@ -212,13 +219,20 @@ export function CherryPickDialog({
setLocalBranches(branches);
}
} catch (err) {
if (!mounted) return;
console.error('Failed to fetch branch data:', err);
} finally {
setLoadingBranches(false);
if (mounted) {
setLoadingBranches(false);
}
}
};
fetchBranchData();
return () => {
mounted = false;
};
}, [open, worktree]);
// Fetch commits when branch is selected
@@ -226,6 +240,9 @@ export function CherryPickDialog({
async (limit: number = 30, append: boolean = false) => {
if (!worktree || !selectedBranch) return;
// Increment the request counter and capture the current request ID
const requestId = ++fetchCommitsRequestRef.current;
if (append) {
setLoadingMoreCommits(true);
} else {
@@ -239,18 +256,28 @@ export function CherryPickDialog({
const api = getHttpApiClient();
const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit);
// Ignore stale responses from superseded requests
if (requestId !== fetchCommitsRequestRef.current) return;
if (result.success && result.result) {
setCommits(result.result.commits);
// If we got exactly the limit, there may be more commits
setHasMoreCommits(result.result.commits.length >= limit);
} else {
} else if (!append) {
setCommitsError(result.error || 'Failed to load commits');
}
} catch (err) {
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
// Ignore stale responses from superseded requests
if (requestId !== fetchCommitsRequestRef.current) return;
if (!append) {
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
}
} finally {
setLoadingCommits(false);
setLoadingMoreCommits(false);
// Only update loading state if this is still the current request
if (requestId === fetchCommitsRequestRef.current) {
setLoadingCommits(false);
setLoadingMoreCommits(false);
}
}
},
[worktree, selectedBranch]
@@ -384,6 +411,7 @@ export function CherryPickDialog({
sourceBranch: selectedBranch,
targetBranch: conflictInfo.targetBranch,
targetWorktreePath: conflictInfo.targetWorktreePath,
operationType: 'merge',
});
onOpenChange(false);
}
@@ -703,7 +731,7 @@ export function CherryPickDialog({
<SelectTrigger className="w-full">
<SelectValue placeholder="Select source..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
<SelectContent className="text-foreground">
<SelectItem value="__local__">Local Branches</SelectItem>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
@@ -725,7 +753,7 @@ export function CherryPickDialog({
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a branch..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
<SelectContent className="text-foreground">
{branchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}

View File

@@ -0,0 +1,452 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Download,
AlertTriangle,
Archive,
CheckCircle2,
XCircle,
FileWarning,
Wrench,
Sparkles,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
type PullPhase =
| 'checking' // Initial check for local changes
| 'local-changes' // Local changes detected, asking user what to do
| 'pulling' // Actively pulling (with or without stash)
| 'success' // Pull completed successfully
| 'conflict' // Merge conflicts detected
| 'error'; // Something went wrong
interface PullResult {
branch: string;
pulled: boolean;
message: string;
hasLocalChanges?: boolean;
localChangedFiles?: string[];
hasConflicts?: boolean;
conflictSource?: 'pull' | 'stash';
conflictFiles?: string[];
stashed?: boolean;
stashRestored?: boolean;
}
interface GitPullDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
remote?: string;
onPulled?: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
export function GitPullDialog({
open,
onOpenChange,
worktree,
remote,
onPulled,
onCreateConflictResolutionFeature,
}: GitPullDialogProps) {
const [phase, setPhase] = useState<PullPhase>('checking');
const [pullResult, setPullResult] = useState<PullResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
}
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
const checkForLocalChanges = useCallback(async () => {
if (!worktree) return;
setPhase('checking');
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
setErrorMessage('Pull API not available');
setPhase('error');
return;
}
// Call pull without stashIfNeeded to just check status
const result = await api.worktree.pull(worktree.path, remote);
if (!result.success) {
setErrorMessage(result.error || 'Failed to pull');
setPhase('error');
return;
}
if (result.result?.hasLocalChanges) {
// Local changes detected - ask user what to do
setPullResult(result.result);
setPhase('local-changes');
} else if (result.result?.pulled !== undefined) {
// No local changes, pull went through (or already up to date)
setPullResult(result.result);
setPhase('success');
onPulled?.();
}
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
setPhase('error');
}
}, [worktree, remote, onPulled]);
const handlePullWithStash = useCallback(async () => {
if (!worktree) return;
setPhase('pulling');
try {
const api = getElectronAPI();
if (!api?.worktree?.pull) {
setErrorMessage('Pull API not available');
setPhase('error');
return;
}
// Call pull with stashIfNeeded
const result = await api.worktree.pull(worktree.path, remote, true);
if (!result.success) {
setErrorMessage(result.error || 'Failed to pull');
setPhase('error');
return;
}
setPullResult(result.result || null);
if (result.result?.hasConflicts) {
setPhase('conflict');
} else {
setPhase('success');
onPulled?.();
}
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
setPhase('error');
}
}, [worktree, remote, onPulled]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
const conflictInfo: MergeConflictInfo = {
sourceBranch: `${remote || 'origin'}/${pullResult.branch}`,
targetBranch: pullResult.branch,
targetWorktreePath: worktree.path,
conflictFiles: pullResult.conflictFiles || [],
operationType: 'merge',
};
onCreateConflictResolutionFeature(conflictInfo);
onOpenChange(false);
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
const handleClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
{/* Checking Phase */}
{phase === 'checking' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5" />
Pull Changes
</DialogTitle>
<DialogDescription>
Checking for local changes on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>...
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
<span className="ml-3 text-sm text-muted-foreground">
Fetching remote and checking status...
</span>
</div>
</>
)}
{/* Local Changes Detected Phase */}
{phase === 'local-changes' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Local Changes Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
You have uncommitted changes on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> that
need to be handled before pulling.
</span>
{pullResult?.localChangedFiles && pullResult.localChangedFiles.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{pullResult.localChangedFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<FileWarning className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20">
<Archive className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span className="text-blue-500 text-sm">
Your changes will be automatically stashed before pulling and restored afterward. If
restoring causes conflicts, you&apos;ll be able to resolve them.
</span>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handlePullWithStash}>
<Archive className="w-4 h-4 mr-2" />
Stash & Pull
</Button>
</DialogFooter>
</>
)}
{/* Pulling Phase */}
{phase === 'pulling' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5 animate-pulse" />
Pulling Changes
</DialogTitle>
<DialogDescription>
{pullResult?.hasLocalChanges
? 'Stashing changes, pulling from remote, and restoring your changes...'
: 'Pulling latest changes from remote...'}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
<span className="ml-3 text-sm text-muted-foreground">This may take a moment...</span>
</div>
</>
)}
{/* Success Phase */}
{phase === 'success' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
Pull Complete
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<span className="block">
{pullResult?.message || 'Changes pulled successfully'}
</span>
{pullResult?.stashed && pullResult?.stashRestored && (
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-green-600 dark:text-green-400 text-sm">
Your stashed changes have been restored successfully.
</span>
</div>
)}
{pullResult?.stashed && !pullResult?.stashRestored && (
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<span className="text-amber-600 dark:text-amber-400 text-sm">
{pullResult.message}
</span>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={handleClose}>Done</Button>
</DialogFooter>
</>
)}
{/* Conflict Phase */}
{phase === 'conflict' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Merge Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
{pullResult?.conflictSource === 'stash'
? 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.'
: 'The pull resulted in merge conflicts that need to be resolved.'}
</span>
{pullResult?.conflictFiles && pullResult.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({pullResult.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{pullResult.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to analyze and
resolve conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash; Leaves conflict markers in place
for you to edit directly
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onPulled?.();
handleClose();
}}
>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
{onCreateConflictResolutionFeature && (
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
)}
</DialogFooter>
</>
)}
{/* Error Phase */}
{phase === 'error' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
Pull Failed
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<span className="block">
Failed to pull changes for{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
</span>
{errorMessage && (
<div
className={cn(
'flex items-start gap-2 p-3 rounded-md',
'bg-destructive/10 border border-destructive/20'
)}
>
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<span className="text-destructive text-sm break-words">{errorMessage}</span>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={handleClose}>
Close
</Button>
<Button
variant="outline"
onClick={() => {
setErrorMessage(null);
checkForLocalChanges();
}}
>
Retry
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -21,4 +21,6 @@ export { ExportFeaturesDialog } from './export-features-dialog';
export { ImportFeaturesDialog } from './import-features-dialog';
export { StashChangesDialog } from './stash-changes-dialog';
export { ViewStashesDialog } from './view-stashes-dialog';
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';
export { GitPullDialog } from './git-pull-dialog';

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
@@ -21,12 +21,28 @@ import {
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitMerge, RefreshCw, AlertTriangle, GitBranch } from 'lucide-react';
import {
GitMerge,
RefreshCw,
AlertTriangle,
GitBranch,
Wrench,
Sparkles,
XCircle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
import type { WorktreeInfo, MergeConflictInfo } from '../worktree-panel/types';
export type PullStrategy = 'merge' | 'rebase';
type DialogStep = 'select' | 'executing' | 'conflict' | 'success';
interface ConflictState {
conflictFiles: string[];
remoteBranch: string;
strategy: PullStrategy;
}
interface RemoteBranch {
name: string;
fullRef: string;
@@ -49,6 +65,7 @@ interface MergeRebaseDialogProps {
remoteBranch: string,
strategy: PullStrategy
) => void | Promise<void>;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
export function MergeRebaseDialog({
@@ -56,6 +73,7 @@ export function MergeRebaseDialog({
onOpenChange,
worktree,
onConfirm,
onCreateConflictResolutionFeature,
}: MergeRebaseDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
@@ -64,13 +82,15 @@ export function MergeRebaseDialog({
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<DialogStep>('select');
const [conflictState, setConflictState] = useState<ConflictState | null>(null);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree]);
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset state when dialog closes
useEffect(() => {
@@ -79,6 +99,8 @@ export function MergeRebaseDialog({
setSelectedBranch('');
setSelectedStrategy('merge');
setError(null);
setStep('select');
setConflictState(null);
}
}, [open]);
@@ -167,20 +189,300 @@ export function MergeRebaseDialog({
}
};
const handleConfirm = async () => {
const handleExecuteOperation = useCallback(async () => {
if (!worktree || !selectedBranch) return;
setStep('executing');
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
const api = getHttpApiClient();
if (selectedStrategy === 'rebase') {
// First fetch the remote to ensure we have latest refs
try {
await api.worktree.pull(worktree.path, selectedRemote);
} catch {
// Fetch may fail if no upstream - that's okay, we'll try rebase anyway
}
// Attempt the rebase operation
const result = await api.worktree.rebase(worktree.path, selectedBranch);
if (result.success) {
toast.success(`Rebased onto ${selectedBranch}`, {
description: result.result?.message || 'Rebase completed successfully',
});
setStep('success');
onOpenChange(false);
} else if (result.hasConflicts) {
// Rebase had conflicts - show conflict resolution UI
setConflictState({
conflictFiles: result.conflictFiles || [],
remoteBranch: selectedBranch,
strategy: 'rebase',
});
setStep('conflict');
toast.error('Rebase conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Rebase failed', {
description: result.error || 'Unknown error',
});
setStep('select');
}
} else {
// Merge strategy - attempt to merge the remote branch
// Use the pull endpoint for merging remote branches
const result = await api.worktree.pull(worktree.path, selectedRemote, true);
if (result.success && result.result) {
if (result.result.hasConflicts) {
// Pull had conflicts
setConflictState({
conflictFiles: result.result.conflictFiles || [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
setStep('conflict');
toast.error('Merge conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.success(`Merged ${selectedBranch}`, {
description: result.result.message || 'Merge completed successfully',
});
setStep('success');
onOpenChange(false);
}
} else {
// Check for conflict indicators in error
const errorMessage = result.error || '';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
if (hasConflicts) {
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
setStep('conflict');
toast.error('Merge conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
// Non-conflict failure - fall back to creating a feature task
toast.info('Direct operation failed, creating AI task instead', {
description: result.error || 'The operation will be handled by an AI agent.',
});
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (err) {
logger.error('Failed to create feature task:', err);
setStep('select');
}
}
}
}
} catch (err) {
logger.error('Failed to confirm merge/rebase:', err);
throw err;
logger.error('Failed to execute operation:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
if (hasConflicts) {
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: selectedStrategy,
});
setStep('conflict');
} else {
// Fall back to creating a feature task
toast.info('Creating AI task to handle the operation', {
description: 'The operation will be performed by an AI agent.',
});
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (confirmErr) {
logger.error('Failed to create feature task:', confirmErr);
toast.error('Operation failed', { description: errorMessage });
setStep('select');
}
}
}
};
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onConfirm, onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !conflictState) return;
if (onCreateConflictResolutionFeature) {
const conflictInfo: MergeConflictInfo = {
sourceBranch: conflictState.remoteBranch,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
conflictFiles: conflictState.conflictFiles,
operationType: conflictState.strategy,
};
onCreateConflictResolutionFeature(conflictInfo);
onOpenChange(false);
} else {
// Fallback: create via the onConfirm handler
onConfirm(worktree, conflictState.remoteBranch, conflictState.strategy);
onOpenChange(false);
}
}, [worktree, conflictState, onCreateConflictResolutionFeature, onConfirm, onOpenChange]);
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
}, [onOpenChange]);
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
const branches = selectedRemoteData?.branches || [];
if (!worktree) return null;
// Conflict resolution UI
if (step === 'conflict' && conflictState) {
const isRebase = conflictState.strategy === 'rebase';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
{isRebase ? 'Rebase' : 'Merge'} Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
{isRebase ? (
<>
Conflicts detected when rebasing{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
onto{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictState.remoteBranch}
</code>
. The rebase was aborted and no changes were applied.
</>
) : (
<>
Conflicts detected when merging{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictState.remoteBranch}
</code>{' '}
into{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>.
</>
)}
</span>
{conflictState.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({conflictState.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{conflictState.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to{' '}
{isRebase ? 'rebase and ' : ''}resolve conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash;{' '}
{isRebase
? 'Leaves the branch unchanged for you to rebase manually'
: 'Leaves conflict markers in place for you to edit directly'}
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setStep('select');
setConflictState(null);
}}
>
Back
</Button>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Executing phase
if (step === 'executing') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedStrategy === 'rebase' ? (
<GitBranch className="w-5 h-5 text-blue-500 animate-pulse" />
) : (
<GitMerge className="w-5 h-5 text-purple-500 animate-pulse" />
)}
{selectedStrategy === 'rebase' ? 'Rebasing...' : 'Merging...'}
</DialogTitle>
<DialogDescription>
{selectedStrategy === 'rebase'
? `Rebasing ${worktree.branch} onto ${selectedBranch}...`
: `Merging ${selectedBranch} into ${worktree.branch}...`}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
<span className="ml-3 text-sm text-muted-foreground">This may take a moment...</span>
</div>
</DialogContent>
</Dialog>
);
}
// Selection UI
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
@@ -323,7 +625,7 @@ export function MergeRebaseDialog({
{selectedBranch && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a feature task to{' '}
This will attempt to{' '}
{selectedStrategy === 'rebase' ? (
<>
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
@@ -334,8 +636,8 @@ export function MergeRebaseDialog({
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span>
</>
)}{' '}
and resolve any conflicts.
)}
. If conflicts arise, you can choose to resolve them manually or with AI.
</p>
</div>
)}
@@ -347,16 +649,21 @@ export function MergeRebaseDialog({
Cancel
</Button>
<Button
onClick={handleConfirm}
onClick={handleExecuteOperation}
disabled={!selectedBranch || isLoading}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
{selectedStrategy === 'merge'
? 'Merge'
: selectedStrategy === 'rebase'
? 'Rebase'
: 'Merge & Rebase'}
{selectedStrategy === 'rebase' ? (
<>
<GitBranch className="w-4 h-4 mr-2" />
Rebase
</>
) : (
<>
<GitMerge className="w-4 h-4 mr-2" />
Merge
</>
)}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -10,7 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
import { GitMerge, AlertTriangle, Trash2, Wrench, Sparkles, XCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -116,17 +116,20 @@ export function MergeWorktreeDialog({
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('merge failed') ||
errorMessage.includes('CONFLICT');
errorMessage.includes('CONFLICT') ||
result.hasConflicts;
if (hasConflicts && onCreateConflictResolutionFeature) {
if (hasConflicts) {
// Set merge conflict state to show the conflict resolution UI
setMergeConflict({
sourceBranch: worktree.branch,
targetBranch: targetBranch,
targetWorktreePath: projectPath, // The merge happens in the target branch's worktree
conflictFiles: result.conflictFiles || [],
operationType: 'merge',
});
toast.error('Merge conflicts detected', {
description: 'The merge has conflicts that need to be resolved manually.',
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Failed to merge branch', {
@@ -142,14 +145,16 @@ export function MergeWorktreeDialog({
errorMessage.toLowerCase().includes('merge failed') ||
errorMessage.includes('CONFLICT');
if (hasConflicts && onCreateConflictResolutionFeature) {
if (hasConflicts) {
setMergeConflict({
sourceBranch: worktree.branch,
targetBranch: targetBranch,
targetWorktreePath: projectPath,
conflictFiles: [],
operationType: 'merge',
});
toast.error('Merge conflicts detected', {
description: 'The merge has conflicts that need to be resolved manually.',
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Failed to merge branch', {
@@ -161,20 +166,28 @@ export function MergeWorktreeDialog({
}
};
const handleCreateConflictResolutionFeature = () => {
const handleResolveWithAI = () => {
if (mergeConflict && onCreateConflictResolutionFeature) {
onCreateConflictResolutionFeature(mergeConflict);
onOpenChange(false);
}
};
const handleResolveManually = () => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
};
if (!worktree) return null;
// Show conflict resolution UI if there are merge conflicts
if (mergeConflict) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
@@ -194,32 +207,38 @@ export function MergeWorktreeDialog({
.
</span>
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
The merge could not be completed automatically. You can create a feature task to
resolve the conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{mergeConflict.targetBranch}
</code>{' '}
branch.
</span>
</div>
{mergeConflict.conflictFiles && mergeConflict.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({mergeConflict.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{mergeConflict.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a high-priority feature task that will:
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to resolve:
</p>
<ul className="text-sm text-muted-foreground mt-2 list-disc list-inside space-y-1">
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
Resolve merge conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{mergeConflict.targetBranch}
</code>{' '}
branch
<strong>Resolve with AI</strong> &mdash; Creates a task to analyze and resolve
conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash; Leaves conflict markers in place for
you to edit directly
</li>
<li>Ensure the code compiles and tests pass</li>
<li>Complete the merge automatically</li>
</ul>
</div>
</div>
@@ -230,16 +249,19 @@ export function MergeWorktreeDialog({
<Button variant="ghost" onClick={() => setMergeConflict(null)}>
Back
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleCreateConflictResolutionFeature}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Create Resolve Conflicts Feature
Resolve Manually
</Button>
{onCreateConflictResolutionFeature && (
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,128 @@
/**
* Dialog shown when a stash apply/pop operation results in merge conflicts.
* Presents the user with two options:
* 1. Resolve Manually - leaves conflict markers in place
* 2. Resolve with AI - creates a feature task for AI-powered conflict resolution
*/
import { useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle, XCircle, Wrench, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import type { StashApplyConflictInfo } from '../worktree-panel/types';
interface StashApplyConflictDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conflictInfo: StashApplyConflictInfo | null;
onResolveWithAI?: (conflictInfo: StashApplyConflictInfo) => void;
}
export function StashApplyConflictDialog({
open,
onOpenChange,
conflictInfo,
onResolveWithAI,
}: StashApplyConflictDialogProps) {
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
}, [onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!conflictInfo || !onResolveWithAI) return;
onResolveWithAI(conflictInfo);
onOpenChange(false);
}, [conflictInfo, onResolveWithAI, onOpenChange]);
if (!conflictInfo) return null;
const operationLabel = conflictInfo.operation === 'pop' ? 'popped' : 'applied';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Merge Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
Stash{' '}
<code className="font-mono bg-muted px-1 rounded">{conflictInfo.stashRef}</code> was{' '}
{operationLabel} on branch{' '}
<code className="font-mono bg-muted px-1 rounded">{conflictInfo.branchName}</code>{' '}
but resulted in merge conflicts.
</span>
{conflictInfo.conflictFiles.length > 0 && (
<div className="space-y-1.5">
<span className="text-sm font-medium text-foreground">
Conflicting files ({conflictInfo.conflictFiles.length}):
</span>
<div className="border border-border rounded-lg overflow-hidden max-h-[200px] overflow-y-auto scrollbar-visible">
{conflictInfo.conflictFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<XCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to analyze and resolve
conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash; Leaves conflict markers in place for
you to edit directly
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
{onResolveWithAI && (
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -21,6 +21,8 @@ import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
import type { StashApplyConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
path: string;
@@ -43,6 +45,7 @@ interface ViewStashesDialogProps {
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onStashApplied?: () => void;
onStashApplyConflict?: (conflictInfo: StashApplyConflictInfo) => void;
}
function formatRelativeDate(dateStr: string): string {
@@ -213,12 +216,15 @@ export function ViewStashesDialog({
onOpenChange,
worktree,
onStashApplied,
onStashApplyConflict,
}: ViewStashesDialogProps) {
const [stashes, setStashes] = useState<StashEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applyingIndex, setApplyingIndex] = useState<number | null>(null);
const [droppingIndex, setDroppingIndex] = useState<number | null>(null);
const [conflictDialogOpen, setConflictDialogOpen] = useState(false);
const [conflictInfo, setConflictInfo] = useState<StashApplyConflictInfo | null>(null);
const fetchStashes = useCallback(async () => {
if (!worktree) return;
@@ -262,13 +268,20 @@ export function ViewStashesDialog({
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash applied with conflicts', {
description: 'Please resolve the merge conflicts.',
});
const info: StashApplyConflictInfo = {
worktreePath: worktree.path,
branchName: worktree.branch,
stashRef: `stash@{${stashIndex}}`,
operation: 'apply',
conflictFiles: result.result.conflictFiles || [],
};
setConflictInfo(info);
setConflictDialogOpen(true);
onStashApplied?.();
} else {
toast.success('Stash applied');
onStashApplied?.();
}
onStashApplied?.();
} else {
toast.error('Failed to apply stash', {
description: result.error || 'Unknown error',
@@ -293,9 +306,15 @@ export function ViewStashesDialog({
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash popped with conflicts', {
description: 'Please resolve the merge conflicts. The stash was removed.',
});
const info: StashApplyConflictInfo = {
worktreePath: worktree.path,
branchName: worktree.branch,
stashRef: `stash@{${stashIndex}}`,
operation: 'pop',
conflictFiles: result.result.conflictFiles || [],
};
setConflictInfo(info);
setConflictDialogOpen(true);
} else {
toast.success('Stash popped', {
description: 'Changes applied and stash removed.',
@@ -403,6 +422,14 @@ export function ViewStashesDialog({
</div>
</div>
</DialogContent>
{/* Stash Apply Conflict Resolution Dialog */}
<StashApplyConflictDialog
open={conflictDialogOpen}
onOpenChange={setConflictDialogOpen}
conflictInfo={conflictInfo}
onResolveWithAI={onStashApplyConflict}
/>
</Dialog>
);
}

View File

@@ -21,6 +21,8 @@ import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardActions');
const MAX_DUPLICATES = 50;
interface UseBoardActionsProps {
currentProject: { path: string; id: string } | null;
features: Feature[];
@@ -1199,10 +1201,22 @@ export function useBoardActions({
const handleDuplicateAsChildMultiple = useCallback(
async (feature: Feature, count: number) => {
// Guard: reject non-positive counts
if (count <= 0) {
toast.error('Invalid duplicate count', {
description: 'Count must be a positive number.',
});
return;
}
// Cap count to prevent runaway API calls
const effectiveCount = Math.min(count, MAX_DUPLICATES);
// Create a chain of duplicates, each a child of the previous, so they execute sequentially
let parentFeature = feature;
let successCount = 0;
for (let i = 0; i < count; i++) {
for (let i = 0; i < effectiveCount; i++) {
const {
id: _id,
status: _status,
@@ -1223,18 +1237,39 @@ export function useBoardActions({
dependencies: [parentFeature.id],
};
const newFeature = await handleAddFeature(duplicatedFeatureData);
try {
const newFeature = await handleAddFeature(duplicatedFeatureData);
// Use the returned feature directly as the parent for the next iteration,
// avoiding a fragile assumption that the newest feature is the last item in the store
if (newFeature) {
parentFeature = newFeature;
// Use the returned feature directly as the parent for the next iteration,
// avoiding a fragile assumption that the newest feature is the last item in the store
if (newFeature) {
parentFeature = newFeature;
}
successCount++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(
`Failed after creating ${successCount} of ${effectiveCount} duplicate${effectiveCount !== 1 ? 's' : ''}`,
{
description: errorMessage,
}
);
return;
}
}
toast.success(`Created ${count} chained duplicates`, {
description: `Created ${count} sequential copies of: ${truncateDescription(feature.description || feature.title || '')}`,
});
if (successCount === effectiveCount) {
toast.success(`Created ${successCount} chained duplicate${successCount !== 1 ? 's' : ''}`, {
description: `Created ${successCount} sequential ${successCount !== 1 ? 'copies' : 'copy'} of: ${truncateDescription(feature.description || feature.title || '')}`,
});
} else {
toast.info(
`Partially created ${successCount} of ${effectiveCount} chained duplicate${effectiveCount !== 1 ? 's' : ''}`,
{
description: `Created ${successCount} sequential ${successCount !== 1 ? 'copies' : 'copy'} of: ${truncateDescription(feature.description || feature.title || '')}`,
}
);
}
},
[handleAddFeature]
);

View File

@@ -449,51 +449,68 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Stash operations - combined submenu */}
{/* Stash operations - combined submenu or simple item */}
{(onStashChanges || onViewStashes) && (
<TooltipWrapper showTooltip={!canPerformGitOps} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem
onClick={() => {
if (!canPerformGitOps) return;
if (worktree.hasChanges && onStashChanges) {
{onViewStashes && worktree.hasChanges && onStashChanges ? (
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem
onClick={() => {
if (!canPerformGitOps) return;
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!canPerformGitOps}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
disabled={!canPerformGitOps}
/>
</div>
<DropdownMenuSubContent>
{onViewStashes && (
}}
disabled={!canPerformGitOps}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<Archive className="w-3.5 h-3.5 mr-2" />
Stash Changes
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
disabled={!canPerformGitOps}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Only one action is meaningful - render a simple menu item without submenu
<DropdownMenuItem
onClick={() => {
if (!canPerformGitOps) return;
if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuItem>
)}
</TooltipWrapper>
)}
<DropdownMenuSeparator />

View File

@@ -78,6 +78,10 @@ export interface MergeConflictInfo {
sourceBranch: string;
targetBranch: string;
targetWorktreePath: string;
/** List of files with conflicts, if available */
conflictFiles?: string[];
/** Type of operation that caused the conflict */
operationType?: 'merge' | 'rebase';
}
export interface BranchSwitchConflictInfo {
@@ -93,6 +97,15 @@ export interface StashPopConflictInfo {
stashPopConflictMessage: string;
}
/** Info passed when a stash apply/pop operation results in merge conflicts */
export interface StashApplyConflictInfo {
worktreePath: string;
branchName: string;
stashRef: string;
operation: 'apply' | 'pop';
conflictFiles: string[];
}
export interface WorktreePanelProps {
projectPath: string;
onCreateWorktree: () => void;
@@ -107,6 +120,8 @@ export interface WorktreePanelProps {
onBranchSwitchConflict?: (conflictInfo: BranchSwitchConflictInfo) => void;
/** Called when checkout fails and the stash-pop restoration itself produces merge conflicts */
onStashPopConflict?: (conflictInfo: StashPopConflictInfo) => void;
/** Called when stash apply/pop results in merge conflicts and user wants AI resolution */
onStashApplyConflict?: (conflictInfo: StashApplyConflictInfo) => void;
/** Called when a branch is deleted during merge - features should be reassigned to main */
onBranchDeletedDuringMerge?: (branchName: string) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;

View File

@@ -41,6 +41,7 @@ import {
StashChangesDialog,
ViewStashesDialog,
CherryPickDialog,
GitPullDialog,
} from '../dialogs';
import type { SelectRemoteOperation } from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
@@ -61,6 +62,7 @@ export function WorktreePanel({
onCreateMergeConflictResolutionFeature,
onBranchSwitchConflict,
onStashPopConflict,
onStashApplyConflict,
onBranchDeletedDuringMerge,
onRemovedWorktrees,
runningFeatureIds = [],
@@ -107,7 +109,7 @@ export function WorktreePanel({
isSwitching,
isActivating,
handleSwitchBranch,
handlePull,
handlePull: _handlePull,
handlePush,
handleOpenInIntegratedTerminal,
handleOpenInEditor,
@@ -423,6 +425,11 @@ export function WorktreePanel({
const [cherryPickDialogOpen, setCherryPickDialogOpen] = useState(false);
const [cherryPickWorktree, setCherryPickWorktree] = useState<WorktreeInfo | null>(null);
// Pull dialog states
const [pullDialogOpen, setPullDialogOpen] = useState(false);
const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null);
const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined);
const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk
@@ -553,33 +560,42 @@ export function WorktreePanel({
setPushToRemoteDialogOpen(true);
}, []);
// Handle pull with remote selection when multiple remotes exist
const handlePullWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
// Handle pull completed - refresh worktrees
const handlePullCompleted = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - use it directly
const remoteName = result.result.remotes[0].name;
handlePull(worktree, remoteName);
} else {
// No remotes - proceed with default behavior
handlePull(worktree);
}
} catch {
// If listing remotes fails, fall back to default behavior
handlePull(worktree);
// Handle pull with remote selection when multiple remotes exist
// Now opens the pull dialog which handles stash management and conflict resolution
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog first
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - open pull dialog directly with that remote
const remoteName = result.result.remotes[0].name;
setPullDialogRemote(remoteName);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
} else {
// No remotes - open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
},
[handlePull]
);
} catch {
// If listing remotes fails, open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
}, []);
// Handle push with remote selection when multiple remotes exist
const handlePushWithRemoteSelection = useCallback(
@@ -613,6 +629,10 @@ export function WorktreePanel({
const handleConfirmSelectRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
if (selectRemoteOperation === 'pull') {
// Open the pull dialog with the selected remote
setPullDialogRemote(remote);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
await handlePull(worktree, remote);
} else {
await handlePush(worktree, remote);
@@ -620,7 +640,7 @@ export function WorktreePanel({
fetchBranches(worktree.path);
fetchWorktrees();
},
[selectRemoteOperation, handlePull, handlePush, fetchBranches, fetchWorktrees]
[selectRemoteOperation, handlePush, fetchBranches, fetchWorktrees]
);
// Handle confirming the push to remote dialog
@@ -822,6 +842,7 @@ export function WorktreePanel({
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
onStashApplyConflict={onStashApplyConflict}
/>
{/* Cherry Pick Dialog */}
@@ -833,6 +854,16 @@ export function WorktreePanel({
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
{/* Git Pull Dialog */}
<GitPullDialog
open={pullDialogOpen}
onOpenChange={setPullDialogOpen}
worktree={pullDialogWorktree}
remote={pullDialogRemote}
onPulled={handlePullCompleted}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}
@@ -1054,6 +1085,7 @@ export function WorktreePanel({
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -1130,6 +1162,7 @@ export function WorktreePanel({
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -1261,6 +1294,16 @@ export function WorktreePanel({
onCherryPicked={handleCherryPicked}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
{/* Git Pull Dialog */}
<GitPullDialog
open={pullDialogOpen}
onOpenChange={setPullDialogOpen}
worktree={pullDialogWorktree}
remote={pullDialogRemote}
onPulled={handlePullCompleted}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div>
);
}

View File

@@ -158,16 +158,28 @@ export function usePushWorktree() {
/**
* Pull changes from remote
*
* Enhanced to support stash management. When stashIfNeeded is true,
* local changes will be automatically stashed before pulling and
* reapplied afterward.
*
* @returns Mutation for pulling changes
*/
export function usePullWorktree() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => {
mutationFn: async ({
worktreePath,
remote,
stashIfNeeded,
}: {
worktreePath: string;
remote?: string;
stashIfNeeded?: boolean;
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.pull(worktreePath, remote);
const result = await api.worktree.pull(worktreePath, remote, stashIfNeeded);
if (!result.success) {
throw new Error(result.error || 'Failed to pull changes');
}

View File

@@ -210,18 +210,14 @@ export function useProviderAuthInit() {
]);
useEffect(() => {
// Only initialize once per session if not already set
if (
initialized.current ||
(claudeAuthStatus !== null &&
codexAuthStatus !== null &&
zaiAuthStatus !== null &&
geminiAuthStatus !== null)
) {
// Skip if already initialized in this session
if (initialized.current) {
return;
}
initialized.current = true;
// Always call refreshStatuses() to background re-validate on app restart,
// even when statuses are pre-populated from persisted storage (cache case).
void refreshStatuses();
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]);
}

View File

@@ -2259,15 +2259,23 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
pull: async (worktreePath: string, remote?: string) => {
pull: async (worktreePath: string, remote?: string, stashIfNeeded?: boolean) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Pulling latest changes for:', { worktreePath, remote: targetRemote });
console.log('[Mock] Pulling latest changes for:', {
worktreePath,
remote: targetRemote,
stashIfNeeded,
});
return {
success: true,
result: {
branch: 'main',
pulled: true,
message: `Pulled latest changes from ${targetRemote}`,
hasLocalChanges: false,
hasConflicts: false,
stashed: false,
stashRestored: false,
},
};
},
@@ -2696,6 +2704,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
result: {
applied: true,
hasConflicts: false,
conflictFiles: [] as string[],
operation: pop ? ('pop' as const) : ('apply' as const),
stashIndex,
message: `Stash ${pop ? 'popped' : 'applied'} successfully`,
@@ -2740,6 +2749,17 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
};
},
rebase: async (worktreePath: string, ontoBranch: string) => {
console.log('[Mock] Rebase:', { worktreePath, ontoBranch });
return {
success: true,
result: {
branch: 'current-branch',
ontoBranch,
message: `Successfully rebased onto ${ontoBranch}`,
},
};
},
};
}

View File

@@ -2135,8 +2135,8 @@ export class HttpApiClient implements ElectronAPI {
featureId,
filePath,
}),
pull: (worktreePath: string, remote?: string) =>
this.post('/api/worktree/pull', { worktreePath, remote }),
pull: (worktreePath: string, remote?: string, stashIfNeeded?: boolean) =>
this.post('/api/worktree/pull', { worktreePath, remote, stashIfNeeded }),
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
this.post('/api/worktree/checkout-branch', { worktreePath, branchName, baseBranch }),
listBranches: (worktreePath: string, includeRemote?: boolean) =>
@@ -2230,6 +2230,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/stash-drop', { worktreePath, stashIndex }),
cherryPick: (worktreePath: string, commitHashes: string[], options?: { noCommit?: boolean }) =>
this.post('/api/worktree/cherry-pick', { worktreePath, commitHashes, options }),
rebase: (worktreePath: string, ontoBranch: string) =>
this.post('/api/worktree/rebase', { worktreePath, ontoBranch }),
getBranchCommitLog: (worktreePath: string, branchName?: string, limit?: number) =>
this.post('/api/worktree/branch-commit-log', { worktreePath, branchName, limit }),
getTestLogs: (worktreePath?: string, sessionId?: string): Promise<TestLogsResponse> => {

View File

@@ -793,6 +793,25 @@ export interface WorktreeAPI {
branchDeleted: boolean;
};
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
}>;
// Rebase the current branch onto a target branch
rebase: (
worktreePath: string,
ontoBranch: string
) => Promise<{
success: boolean;
result?: {
branch: string;
ontoBranch: string;
message: string;
};
error?: string;
hasConflicts?: boolean;
conflictFiles?: string[];
aborted?: boolean;
}>;
// Get worktree info for a feature
@@ -966,16 +985,24 @@ export interface WorktreeAPI {
filePath: string
) => Promise<FileDiffResult>;
// Pull latest changes from remote
// Pull latest changes from remote with optional stash management
pull: (
worktreePath: string,
remote?: string
remote?: string,
stashIfNeeded?: boolean
) => Promise<{
success: boolean;
result?: {
branch: string;
pulled: boolean;
message: string;
hasLocalChanges?: boolean;
localChangedFiles?: string[];
hasConflicts?: boolean;
conflictSource?: 'pull' | 'stash';
conflictFiles?: string[];
stashed?: boolean;
stashRestored?: boolean;
};
error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
@@ -1490,6 +1517,7 @@ export interface WorktreeAPI {
result?: {
applied: boolean;
hasConflicts: boolean;
conflictFiles?: string[];
operation: 'apply' | 'pop';
stashIndex: number;
message: string;

View File

@@ -60,6 +60,10 @@ export type EventType =
| 'cherry-pick:success'
| 'cherry-pick:conflict'
| 'cherry-pick:failure'
| 'rebase:started'
| 'rebase:success'
| 'rebase:conflict'
| 'rebase:failure'
| 'branchCommitLog:start'
| 'branchCommitLog:progress'
| 'branchCommitLog:done'

51
package-lock.json generated
View File

@@ -193,6 +193,7 @@
"electron": "39.2.7",
"electron-builder": "26.0.12",
"eslint": "9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"tailwindcss": "4.1.18",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
@@ -9576,6 +9577,26 @@
}
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
"integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@@ -10745,6 +10766,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-parser": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hermes-estree": "0.25.1"
}
},
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -16830,6 +16868,19 @@
"zod": "^3.25 || ^4"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",