mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
feat: Add git log parsing and rebase endpoint with input validation
This commit is contained in:
55
apps/server/src/lib/git-log-parser.ts
Normal file
55
apps/server/src/lib/git-log-parser.ts
Normal 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 || [],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
110
apps/server/src/routes/worktree/routes/rebase.ts
Normal file
110
apps/server/src/routes/worktree/routes/rebase.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
139
apps/server/src/services/rebase-service.ts
Normal file
139
apps/server/src/services/rebase-service.ts
Normal 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();
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
60
apps/server/test/git-log-parser.test.js
Normal file
60
apps/server/test/git-log-parser.test.js
Normal 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!');
|
||||
107
apps/server/test/test-nul-delimiter-fixed.js
Normal file
107
apps/server/test/test-nul-delimiter-fixed.js
Normal 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');
|
||||
48
apps/server/test/test-nul-delimiter.js
Normal file
48
apps/server/test/test-nul-delimiter.js
Normal 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');
|
||||
165
apps/server/test/test-proper-nul-format.js
Normal file
165
apps/server/test/test-proper-nul-format.js
Normal 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');
|
||||
37
apps/server/test/test-simple-nul.js
Normal file
37
apps/server/test/test-simple-nul.js
Normal 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}"`);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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> — Creates a task to analyze and
|
||||
resolve conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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> — Creates a task to{' '}
|
||||
{isRebase ? 'rebase and ' : ''}resolve conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> —{' '}
|
||||
{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>
|
||||
|
||||
@@ -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> — Creates a task to analyze and resolve
|
||||
conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — 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>
|
||||
|
||||
@@ -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> — Creates a task to analyze and resolve
|
||||
conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
32
apps/ui/src/types/electron.d.ts
vendored
32
apps/ui/src/types/electron.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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
51
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user