feat: Add GPT-5 model variants and improve Codex execution logic. Addressed code review comments

This commit is contained in:
gsxdsm
2026-02-18 11:15:38 -08:00
parent d30296d559
commit 5c441f2313
64 changed files with 3628 additions and 2223 deletions

View File

@@ -17,10 +17,11 @@ import { promisify } from 'util';
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, stripProviderPrefix } from '@automaker/types';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir, spawnProcess } from '@automaker/platform';
import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js';
import { execGitCommand } from '../../lib/git.js';
import { TypedEventBus } from '../typed-event-bus.js';
import { ConcurrencyManager } from '../concurrency-manager.js';
import { WorktreeResolver } from '../worktree-resolver.js';
@@ -49,24 +50,6 @@ import type {
const execAsync = promisify(exec);
const logger = createLogger('AutoModeServiceFacade');
/**
* 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);
}
}
/**
* AutoModeServiceFacade provides a clean interface for auto-mode functionality.
*

View File

@@ -6,7 +6,7 @@
* invokes this service, streams lifecycle events, and sends the response.
*/
import { execGitCommand } from '../routes/worktree/common.js';
import { execGitCommand } from '../lib/git.js';
// ============================================================================
// Types
@@ -68,8 +68,18 @@ export async function getBranchCommitLog(
// 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---';
// Use ASCII control characters as record separators these cannot appear in
// git commit messages, so these delimiters are safe regardless of commit
// body content. %x00 and %x01 in git's format string emit literal NUL /
// SOH bytes respectively.
//
// COMMIT_SEP (\x00) marks the start of each commit record.
// META_END (\x01) separates commit metadata from the --name-only file list.
//
// Full per-commit layout emitted by git:
// \x00\n<hash>\n<shorthash>\n...\n<subject>\n<body>\x01<files...>
const COMMIT_SEP = '\x00';
const META_END = '\x01';
const fetchLimit = commitLimit * 2;
const logOutput = await execGitCommand(
@@ -79,13 +89,13 @@ export async function getBranchCommitLog(
`--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}`,
`--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`,
],
worktreePath
);
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with ---COMMIT---).
// (the output starts with a NUL commit separator).
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
// Use a Map to deduplicate merge commit entries (which appear once per
@@ -96,7 +106,7 @@ export async function getBranchCommitLog(
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// --- Parse metadata (everything before ---META_END---) ---
// --- Parse metadata (everything before the META_END delimiter) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
@@ -108,14 +118,15 @@ export async function getBranchCommitLog(
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();
if (!hash) continue; // defensive: skip if hash is empty
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---) ---
// --- Parse file list (everything after the META_END delimiter) ---
const filesRaw = block.substring(metaEndIdx + META_END.length);
const blockFiles = filesRaw
.trim()

View File

@@ -7,7 +7,8 @@
*/
import { createLogger } from '@automaker/utils';
import { execGitCommand } from '../routes/worktree/common.js';
import { execGitCommand, getCurrentBranch } from '../lib/git.js';
import { type EventEmitter } from '../lib/events.js';
const logger = createLogger('CherryPickService');
@@ -39,16 +40,19 @@ export interface CherryPickResult {
*
* @param worktreePath - Path to the git worktree
* @param commitHashes - Array of commit hashes to verify
* @param emitter - Optional event emitter for lifecycle events
* @returns The first invalid commit hash, or null if all are valid
*/
export async function verifyCommits(
worktreePath: string,
commitHashes: string[]
commitHashes: string[],
emitter?: EventEmitter
): Promise<string | null> {
for (const hash of commitHashes) {
try {
await execGitCommand(['rev-parse', '--verify', hash], worktreePath);
} catch {
emitter?.emit('cherry-pick:verify-failed', { worktreePath, hash });
return hash;
}
}
@@ -61,12 +65,14 @@ export async function verifyCommits(
* @param worktreePath - Path to the git worktree
* @param commitHashes - Array of commit hashes to cherry-pick (in order)
* @param options - Cherry-pick options (e.g., noCommit)
* @param emitter - Optional event emitter for lifecycle events
* @returns CherryPickResult with success/failure information
*/
export async function runCherryPick(
worktreePath: string,
commitHashes: string[],
options?: CherryPickOptions
options?: CherryPickOptions,
emitter?: EventEmitter
): Promise<CherryPickResult> {
const args = ['cherry-pick'];
if (options?.noCommit) {
@@ -74,28 +80,34 @@ export async function runCherryPick(
}
args.push(...commitHashes);
emitter?.emit('cherry-pick:started', { worktreePath, commitHashes });
try {
await execGitCommand(args, worktreePath);
const branch = await getCurrentBranch(worktreePath);
if (options?.noCommit) {
return {
const result: CherryPickResult = {
success: true,
cherryPicked: false,
commitHashes,
branch,
message: `Staged changes from ${commitHashes.length} commit(s); no commit created due to --no-commit`,
};
emitter?.emit('cherry-pick:success', { worktreePath, commitHashes, branch });
return result;
}
return {
const result: CherryPickResult = {
success: true,
cherryPicked: true,
commitHashes,
branch,
message: `Successfully cherry-picked ${commitHashes.length} commit(s)`,
};
emitter?.emit('cherry-pick:success', { worktreePath, commitHashes, branch });
return result;
} catch (cherryPickError: unknown) {
// Check if this is a cherry-pick conflict
const err = cherryPickError as { stdout?: string; stderr?: string; message?: string };
@@ -107,7 +119,7 @@ export async function runCherryPick(
if (hasConflicts) {
// Abort the cherry-pick to leave the repo in a clean state
const aborted = await abortCherryPick(worktreePath);
const aborted = await abortCherryPick(worktreePath, emitter);
if (!aborted) {
logger.error(
@@ -116,6 +128,14 @@ export async function runCherryPick(
);
}
emitter?.emit('cherry-pick:conflict', {
worktreePath,
commitHashes,
aborted,
stdout: err.stdout,
stderr: err.stderr,
});
return {
success: false,
error: aborted
@@ -135,25 +155,25 @@ export async function runCherryPick(
* Abort an in-progress cherry-pick operation.
*
* @param worktreePath - Path to the git worktree
* @param emitter - Optional event emitter for lifecycle events
* @returns true if abort succeeded, false if it failed (logged as warning)
*/
export async function abortCherryPick(worktreePath: string): Promise<boolean> {
export async function abortCherryPick(
worktreePath: string,
emitter?: EventEmitter
): Promise<boolean> {
try {
await execGitCommand(['cherry-pick', '--abort'], worktreePath);
emitter?.emit('cherry-pick:abort', { worktreePath, aborted: true });
return true;
} catch {
} catch (err: unknown) {
const error = err as { message?: string };
logger.warn('Failed to abort cherry-pick after conflict');
emitter?.emit('cherry-pick:abort', {
worktreePath,
aborted: false,
error: error.message ?? 'Unknown error during cherry-pick abort',
});
return false;
}
}
/**
* Get the current branch name for the worktree.
*
* @param worktreePath - Path to the git worktree
* @returns The current branch name
*/
export async function getCurrentBranch(worktreePath: string): Promise<string> {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
return branchOutput.trim();
}

View File

@@ -0,0 +1,161 @@
/**
* Service for fetching commit log data from a worktree.
*
* Extracts the heavy Git command execution and parsing logic from the
* commit-log route handler so the handler only validates input,
* invokes this service, streams lifecycle events, and sends the response.
*
* Follows the same 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.
*/
import { execGitCommand } from '../lib/git.js';
// ============================================================================
// Types
// ============================================================================
export interface CommitLogEntry {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
export interface CommitLogResult {
branch: string;
commits: CommitLogEntry[];
total: number;
}
// ============================================================================
// Service
// ============================================================================
/**
* Fetch the commit log for a worktree (HEAD).
*
* Runs a single `git log --name-only` invocation plus `git rev-parse`
* inside the given worktree path and returns a structured result.
*
* @param worktreePath - Absolute path to the worktree / repository
* @param limit - Maximum number of commits to return (clamped 1-100)
*/
export async function getCommitLog(worktreePath: string, limit: number): Promise<CommitLogResult> {
// Clamp limit to a reasonable range
const parsedLimit = Number(limit);
const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100);
// 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.
// Use ASCII control characters as record separators these cannot appear in
// git commit messages, so these delimiters are safe regardless of commit
// body content. %x00 and %x01 in git's format string emit literal NUL /
// SOH bytes respectively.
//
// COMMIT_SEP (\x00) marks the start of each commit record.
// META_END (\x01) separates commit metadata from the --name-only file list.
//
// Full per-commit layout emitted by git:
// \x00\n<hash>\n<shorthash>\n...\n<subject>\n<body>\x01<files...>
const COMMIT_SEP = '\x00';
const META_END = '\x01';
const fetchLimit = commitLimit * 2;
const logOutput = await execGitCommand(
[
'log',
`--max-count=${fetchLimit}`,
'-m',
'--name-only',
`--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`,
],
worktreePath
);
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with a NUL commit separator).
const commitBlocks = logOutput.split(COMMIT_SEP).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, CommitLogEntry>();
for (const block of commitBlocks) {
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// --- Parse metadata (everything before the META_END delimiter) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
// 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();
if (!hash) continue; // defensive: skip if hash is empty
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 the META_END delimiter) ---
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,
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'], worktreePath);
const branch = branchOutput.trim();
return {
branch,
commits,
total: commits.length,
};
}

View File

@@ -5,7 +5,8 @@
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
import { createEventEmitter } from '../lib/events';
import { execGitCommand } from '../lib/git.js';
const logger = createLogger('MergeService');
export interface MergeOptions {
@@ -27,33 +28,14 @@ export interface MergeServiceResult {
};
}
/**
* 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 || result.stdout || `Git command failed with code ${result.exitCode}`;
throw Object.assign(new Error(errorMessage), {
stdout: result.stdout,
stderr: result.stderr,
});
}
}
/**
* Validate branch name to prevent command injection.
* The first character must not be '-' to prevent git argument injection
* via names like "-flag" or "--option".
*/
function isValidBranchName(name: string): boolean {
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250;
// First char must be alphanumeric, dot, underscore, or slash (not dash)
return /^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < 250;
}
/**
@@ -72,6 +54,8 @@ export async function performMerge(
targetBranch: string = 'main',
options?: MergeOptions
): Promise<MergeServiceResult> {
const emitter = createEventEmitter();
if (!projectPath || !branchName || !worktreePath) {
return {
success: false,
@@ -115,6 +99,9 @@ export async function performMerge(
};
}
// Emit merge:start after validating inputs
emitter.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });
// Merge the feature branch into the target branch (using safe array-based commands)
const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`;
const mergeArgs = options?.squash
@@ -131,7 +118,7 @@ export async function performMerge(
if (hasConflicts) {
// Get list of conflicted files
let conflictFiles: string[] = [];
let conflictFiles: string[] | undefined;
try {
const diffOutput = await execGitCommand(
['diff', '--name-only', '--diff-filter=U'],
@@ -142,9 +129,13 @@ export async function performMerge(
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// If we can't get the file list, that's okay - continue without it
// If we can't get the file list, leave conflictFiles undefined so callers
// can distinguish "no conflicts" (empty array) from "unknown due to diff failure" (undefined)
}
// Emit merge:conflict event with conflict details
emitter.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles });
return {
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
@@ -153,6 +144,13 @@ export async function performMerge(
};
}
// Emit merge:error for non-conflict errors before re-throwing
emitter.emit('merge:error', {
branchName,
targetBranch: mergeTo,
error: err.message || String(mergeError),
});
// Re-throw non-conflict errors
throw mergeError;
}
@@ -197,6 +195,13 @@ export async function performMerge(
}
}
// Emit merge:success with merged branch, target branch, and deletion info
emitter.emit('merge:success', {
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
});
return {
success: true,
mergedBranch: branchName,

View File

@@ -0,0 +1,457 @@
/**
* PullService - Pull git operations without HTTP
*
* Encapsulates the full git pull workflow including:
* - Branch name and detached HEAD detection
* - Fetching from remote
* - Status parsing and local change detection
* - Stash push/pop logic
* - Upstream verification (rev-parse / --verify)
* - Pull execution and conflict detection
* - Conflict file list collection
*
* Extracted from the worktree pull route to improve organization
* and testability. Follows the same pattern as rebase-service.ts
* and cherry-pick-service.ts.
*/
import { createLogger } from '@automaker/utils';
import { execGitCommand } from '../lib/git.js';
import { getErrorMessage } from '../routes/worktree/common.js';
const logger = createLogger('PullService');
// ============================================================================
// Types
// ============================================================================
export interface PullOptions {
/** Remote name to pull from (defaults to 'origin') */
remote?: string;
/** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean;
}
export interface PullResult {
success: boolean;
error?: string;
branch?: string;
pulled?: boolean;
hasLocalChanges?: boolean;
localChangedFiles?: string[];
stashed?: boolean;
stashRestored?: boolean;
stashRecoveryFailed?: boolean;
hasConflicts?: boolean;
conflictSource?: 'pull' | 'stash';
conflictFiles?: string[];
message?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Get the current branch name for the worktree.
*
* @param worktreePath - Path to the git worktree
* @returns The current branch name (returns 'HEAD' for detached HEAD state)
*/
export async function getCurrentBranch(worktreePath: string): Promise<string> {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
return branchOutput.trim();
}
/**
* Fetch the latest refs from a remote.
*
* @param worktreePath - Path to the git worktree
* @param remote - Remote name (e.g. 'origin')
*/
export async function fetchRemote(worktreePath: string, remote: string): Promise<void> {
await execGitCommand(['fetch', remote], worktreePath);
}
/**
* Parse `git status --porcelain` output into a list of changed file paths.
*
* @param worktreePath - Path to the git worktree
* @returns Object with hasLocalChanges flag and list of changed file paths
*/
export async function getLocalChanges(
worktreePath: string
): Promise<{ hasLocalChanges: boolean; localChangedFiles: string[] }> {
const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath);
const hasLocalChanges = statusOutput.trim().length > 0;
let localChangedFiles: string[] = [];
if (hasLocalChanges) {
localChangedFiles = statusOutput
.trim()
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => line.substring(3).trim());
}
return { hasLocalChanges, localChangedFiles };
}
/**
* Stash local changes with a descriptive message.
*
* @param worktreePath - Path to the git worktree
* @param branchName - Current branch name (used in stash message)
* @returns true if stash was created
*/
export async function stashChanges(worktreePath: string, branchName: string): Promise<void> {
const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`;
await execGitCommand(['stash', 'push', '--include-untracked', '-m', stashMessage], worktreePath);
}
/**
* Pop the top stash entry.
*
* @param worktreePath - Path to the git worktree
* @returns The stdout from stash pop
*/
export async function popStash(worktreePath: string): Promise<string> {
return await execGitCommand(['stash', 'pop'], worktreePath);
}
/**
* Try to pop the stash, returning whether the pop succeeded.
*
* @param worktreePath - Path to the git worktree
* @returns true if stash pop succeeded, false if it failed
*/
async function tryPopStash(worktreePath: string): Promise<boolean> {
try {
await execGitCommand(['stash', 'pop'], worktreePath);
return true;
} catch (stashPopError) {
// Stash pop failed - leave it in stash list for manual recovery
logger.error('Failed to reapply stash during error recovery', {
worktreePath,
error: getErrorMessage(stashPopError),
});
return false;
}
}
/**
* Check whether the branch has an upstream tracking ref, or whether
* the remote branch exists.
*
* @param worktreePath - Path to the git worktree
* @param branchName - Current branch name
* @param remote - Remote name
* @returns true if upstream or remote branch exists
*/
export async function hasUpstreamOrRemoteBranch(
worktreePath: string,
branchName: string,
remote: string
): Promise<boolean> {
try {
await execGitCommand(['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`], worktreePath);
return true;
} catch {
// No upstream tracking - check if the remote branch exists
try {
await execGitCommand(['rev-parse', '--verify', `${remote}/${branchName}`], worktreePath);
return true;
} catch {
return false;
}
}
}
/**
* Get the list of files with unresolved merge 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 [];
}
}
/**
* Check whether an error output string indicates a merge conflict.
*/
function isConflictError(errorOutput: string): boolean {
return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed');
}
/**
* Check whether an output string indicates a stash conflict.
*/
function isStashConflict(output: string): boolean {
return output.includes('CONFLICT') || output.includes('Merge conflict');
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Perform a full git pull workflow on the given worktree.
*
* The workflow:
* 1. Get current branch name (detect detached HEAD)
* 2. Fetch from remote
* 3. Check for local changes
* 4. If local changes and stashIfNeeded, stash them
* 5. Verify upstream tracking or remote branch exists
* 6. Execute `git pull`
* 7. If stash was created and pull succeeded, reapply stash
* 8. Detect and report conflicts from pull or stash reapplication
*
* @param worktreePath - Path to the git worktree
* @param options - Pull options (remote, stashIfNeeded)
* @returns PullResult with detailed status information
*/
export async function performPull(
worktreePath: string,
options?: PullOptions
): Promise<PullResult> {
const targetRemote = options?.remote || 'origin';
const stashIfNeeded = options?.stashIfNeeded ?? false;
// 1. Get current branch name
const branchName = await getCurrentBranch(worktreePath);
// 2. Check for detached HEAD state
if (branchName === 'HEAD') {
return {
success: false,
error: 'Cannot pull in detached HEAD state. Please checkout a branch first.',
};
}
// 3. Fetch latest from remote
try {
await fetchRemote(worktreePath, targetRemote);
} catch (fetchError) {
return {
success: false,
error: `Failed to fetch from remote '${targetRemote}': ${getErrorMessage(fetchError)}`,
};
}
// 4. Check for local changes
const { hasLocalChanges, localChangedFiles } = await getLocalChanges(worktreePath);
// 5. If there are local changes and stashIfNeeded is not requested, return info
if (hasLocalChanges && !stashIfNeeded) {
return {
success: true,
branch: branchName,
pulled: false,
hasLocalChanges: true,
localChangedFiles,
message:
'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.',
};
}
// 6. Stash local changes if needed
let didStash = false;
if (hasLocalChanges && stashIfNeeded) {
try {
await stashChanges(worktreePath, branchName);
didStash = true;
} catch (stashError) {
return {
success: false,
error: `Failed to stash local changes: ${getErrorMessage(stashError)}`,
};
}
}
// 7. Verify upstream tracking or remote branch exists
const hasUpstream = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
if (!hasUpstream) {
let stashRecoveryFailed = false;
if (didStash) {
const stashPopped = await tryPopStash(worktreePath);
stashRecoveryFailed = !stashPopped;
}
return {
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}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
stashRecoveryFailed: stashRecoveryFailed || undefined,
};
}
// 8. Pull latest changes
let pullConflict = false;
let pullConflictFiles: string[] = [];
try {
const pullOutput = await execGitCommand(['pull', targetRemote, branchName], worktreePath);
const alreadyUpToDate = pullOutput.includes('Already up to date');
// If no stash to reapply, return success
if (!didStash) {
return {
success: true,
branch: branchName,
pulled: !alreadyUpToDate,
hasLocalChanges: false,
stashed: false,
stashRestored: false,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
};
}
} catch (pullError: unknown) {
const err = pullError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
if (isConflictError(errorOutput)) {
pullConflict = true;
pullConflictFiles = await getConflictFiles(worktreePath);
} else {
// Non-conflict pull error
let stashRecoveryFailed = false;
if (didStash) {
const stashPopped = await tryPopStash(worktreePath);
stashRecoveryFailed = !stashPopped;
}
// Check for common errors
const errorMsg = err.stderr || err.message || 'Pull failed';
if (errorMsg.includes('no tracking information')) {
return {
success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
stashRecoveryFailed: stashRecoveryFailed || undefined,
};
}
return {
success: false,
error: `${errorMsg}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
stashRecoveryFailed: stashRecoveryFailed || undefined,
};
}
}
// 9. If pull had conflicts, return conflict info (don't try stash pop)
if (pullConflict) {
return {
success: true,
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(),
};
}
// 10. Pull succeeded, now try to reapply stash
if (didStash) {
return await reapplyStash(worktreePath, branchName);
}
// Shouldn't reach here, but return a safe default
return {
success: true,
branch: branchName,
pulled: true,
message: 'Pulled latest changes',
};
}
/**
* Attempt to reapply stashed changes after a successful pull.
* Handles both clean reapplication and conflict scenarios.
*
* @param worktreePath - Path to the git worktree
* @param branchName - Current branch name
* @returns PullResult reflecting stash reapplication status
*/
async function reapplyStash(worktreePath: string, branchName: string): Promise<PullResult> {
try {
const stashPopOutput = await popStash(worktreePath);
const stashPopCombined = stashPopOutput || '';
// Check if stash pop had conflicts
if (isStashConflict(stashPopCombined)) {
const stashConflictFiles = await getConflictFiles(worktreePath);
return {
success: true,
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.',
};
}
// Stash pop succeeded cleanly
return {
success: true,
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 (isStashConflict(errorOutput)) {
const stashConflictFiles = await getConflictFiles(worktreePath);
return {
success: true,
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.',
};
}
// Non-conflict stash pop error - stash is still in the stash list
logger.warn('Failed to reapply stash after pull', { worktreePath, error: errorOutput });
return {
success: true,
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.',
};
}
}

View File

@@ -5,8 +5,10 @@
* Follows the same pattern as merge-service.ts and cherry-pick-service.ts.
*/
import fs from 'fs/promises';
import path from 'path';
import { createLogger } from '@automaker/utils';
import { execGitCommand } from '../routes/worktree/common.js';
import { execGitCommand, getCurrentBranch } from '../lib/git.js';
const logger = createLogger('RebaseService');
@@ -37,11 +39,23 @@ export interface RebaseResult {
* @returns RebaseResult with success/failure information
*/
export async function runRebase(worktreePath: string, ontoBranch: string): Promise<RebaseResult> {
// Reject branch names that start with a dash to prevent them from being
// misinterpreted as git options.
if (ontoBranch.startsWith('-')) {
return {
success: false,
error: `Invalid branch name: "${ontoBranch}" must not start with a dash.`,
};
}
// Get current branch name before rebase
const currentBranch = await getCurrentBranch(worktreePath);
try {
await execGitCommand(['rebase', ontoBranch], worktreePath);
// Pass ontoBranch after '--' so git treats it as a ref, not an option.
// Set LC_ALL=C so git always emits English output regardless of the system
// locale, making text-based conflict detection reliable.
await execGitCommand(['rebase', '--', ontoBranch], worktreePath, { LC_ALL: 'C' });
return {
success: true,
@@ -50,15 +64,82 @@ export async function runRebase(worktreePath: string, ontoBranch: string): Promi
message: `Successfully rebased ${currentBranch} onto ${ontoBranch}`,
};
} catch (rebaseError: unknown) {
// Check if this is a rebase conflict
// Check if this is a rebase conflict. We use a multi-layer strategy so
// that detection is reliable even when locale settings vary or git's text
// output changes across versions:
//
// 1. Primary (text-based): scan the error output for well-known English
// conflict markers. Because we pass LC_ALL=C above these strings are
// always in English, but we keep the check as one layer among several.
//
// 2. Repository-state check: run `git rev-parse --git-dir` to find the
// actual .git directory, then verify whether the in-progress rebase
// state directories (.git/rebase-merge or .git/rebase-apply) exist.
// These are created by git at the start of a rebase and are the most
// reliable indicator that a rebase is still in progress (i.e. stopped
// due to conflicts).
//
// 3. Unmerged-path check: run `git status --porcelain` (machine-readable,
// locale-independent) and look for lines whose first two characters
// indicate an unmerged state (UU, AA, DD, AU, UA, DU, UD).
//
// hasConflicts is true when ANY of the three layers returns positive.
const err = rebaseError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
// Layer 1 text matching (locale-safe because we set LC_ALL=C above).
const textIndicatesConflict =
output.includes('CONFLICT') ||
output.includes('could not apply') ||
output.includes('Resolve all conflicts') ||
output.includes('fix conflicts');
// Layers 2 & 3 repository state inspection (locale-independent).
let rebaseStateExists = false;
let hasUnmergedPaths = false;
try {
// Find the canonical .git directory for this worktree.
const gitDir = (await execGitCommand(['rev-parse', '--git-dir'], worktreePath)).trim();
// git rev-parse --git-dir returns a path relative to cwd when the repo is
// a worktree, so we resolve it against worktreePath.
const resolvedGitDir = path.resolve(worktreePath, gitDir);
// Layer 2: check for rebase state directories.
const rebaseMergeDir = path.join(resolvedGitDir, 'rebase-merge');
const rebaseApplyDir = path.join(resolvedGitDir, 'rebase-apply');
const [rebaseMergeExists, rebaseApplyExists] = await Promise.all([
fs
.access(rebaseMergeDir)
.then(() => true)
.catch(() => false),
fs
.access(rebaseApplyDir)
.then(() => true)
.catch(() => false),
]);
rebaseStateExists = rebaseMergeExists || rebaseApplyExists;
} catch {
// If rev-parse fails the repo may be in an unexpected state; fall back to
// text-based detection only.
}
try {
// Layer 3: check for unmerged paths via machine-readable git status.
const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath, {
LC_ALL: 'C',
});
// Unmerged status codes occupy the first two characters of each line.
// Standard unmerged codes: UU, AA, DD, AU, UA, DU, UD.
hasUnmergedPaths = statusOutput
.split('\n')
.some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
} catch {
// git status failing is itself a sign something is wrong; leave
// hasUnmergedPaths as false and rely on the other layers.
}
const hasConflicts = textIndicatesConflict || rebaseStateExists || hasUnmergedPaths;
if (hasConflicts) {
// Get list of conflicted files
const conflictFiles = await getConflictFiles(worktreePath);
@@ -100,8 +181,8 @@ 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');
} catch (err) {
logger.warn('Failed to abort rebase after conflict', err instanceof Error ? err.message : err);
return false;
}
}
@@ -126,14 +207,3 @@ export async function getConflictFiles(worktreePath: string): Promise<string[]>
return [];
}
}
/**
* Get the current branch name for the worktree.
*
* @param worktreePath - Path to the git worktree
* @returns The current branch name
*/
export async function getCurrentBranch(worktreePath: string): Promise<string> {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
return branchOutput.trim();
}

View File

@@ -0,0 +1,462 @@
/**
* StashService - Stash operations without HTTP
*
* Encapsulates stash workflows including:
* - Push (create) stashes with optional message and file selection
* - List all stash entries with metadata and changed files
* - Apply or pop a stash entry with conflict detection
* - Drop (delete) a stash entry
* - Conflict detection from command output and git diff
* - Lifecycle event emission (start, progress, conflicts, success, failure)
*
* Extracted from the worktree stash route handlers to improve organisation
* and testability. Follows the same pattern as pull-service.ts and
* merge-service.ts.
*/
import { createLogger } from '@automaker/utils';
import { createEventEmitter } from '../lib/events.js';
import { execGitCommand } from '../lib/git.js';
import { getErrorMessage, logError } from '../routes/worktree/common.js';
const logger = createLogger('StashService');
// ============================================================================
// Types
// ============================================================================
export interface StashApplyOptions {
/** When true, remove the stash entry after applying (git stash pop) */
pop?: boolean;
}
export interface StashApplyResult {
success: boolean;
error?: string;
applied?: boolean;
hasConflicts?: boolean;
conflictFiles?: string[];
operation?: 'apply' | 'pop';
stashIndex?: number;
message?: string;
}
export interface StashPushResult {
success: boolean;
error?: string;
stashed: boolean;
branch?: string;
message?: string;
}
export interface StashEntry {
index: number;
message: string;
branch: string;
date: string;
files: string[];
}
export interface StashListResult {
success: boolean;
error?: string;
stashes: StashEntry[];
total: number;
}
export interface StashDropResult {
success: boolean;
error?: string;
dropped: boolean;
stashIndex?: number;
message?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Retrieve the list of files with unmerged (conflicted) entries using git diff.
*
* @param worktreePath - Path to the git worktree
* @returns Array of file paths that have unresolved conflicts
*/
export async function getConflictedFiles(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 {
// If we cannot get the file list, return an empty array
return [];
}
}
/**
* Determine whether command output indicates a merge conflict.
*/
function isConflictOutput(output: string): boolean {
return output.includes('CONFLICT') || output.includes('Merge conflict');
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Apply or pop a stash entry in the given worktree.
*
* The workflow:
* 1. Validate inputs
* 2. Emit stash:start event
* 3. Run `git stash apply` or `git stash pop`
* 4. Emit stash:progress event with raw command output
* 5. Check output for conflict markers; if conflicts found, collect files and
* emit stash:conflicts event
* 6. Emit stash:success or stash:failure depending on outcome
* 7. Return a structured StashApplyResult
*
* @param worktreePath - Absolute path to the git worktree
* @param stashIndex - Zero-based stash index (stash@{N})
* @param options - Optional flags (pop)
* @returns StashApplyResult with detailed status information
*/
export async function applyOrPop(
worktreePath: string,
stashIndex: number,
options?: StashApplyOptions
): Promise<StashApplyResult> {
const emitter = createEventEmitter();
const operation: 'apply' | 'pop' = options?.pop ? 'pop' : 'apply';
const stashRef = `stash@{${stashIndex}}`;
logger.info(`[StashService] ${operation} ${stashRef} in ${worktreePath}`);
// 1. Emit start event
emitter.emit('stash:start', { worktreePath, stashIndex, stashRef, operation });
try {
// 2. Run git stash apply / pop
let stdout = '';
try {
stdout = await execGitCommand(['stash', operation, stashRef], worktreePath);
} catch (gitError: unknown) {
const err = gitError as { stdout?: string; stderr?: string; message?: string };
const errStdout = err.stdout || '';
const errStderr = err.stderr || err.message || '';
const combinedOutput = `${errStdout}\n${errStderr}`;
// 3. Emit progress with raw output
emitter.emit('stash:progress', {
worktreePath,
stashIndex,
operation,
output: combinedOutput,
});
// 4. Check if the error is a conflict
if (isConflictOutput(combinedOutput)) {
const conflictFiles = await getConflictedFiles(worktreePath);
emitter.emit('stash:conflicts', {
worktreePath,
stashIndex,
operation,
conflictFiles,
});
const result: StashApplyResult = {
success: true,
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
};
emitter.emit('stash:success', {
worktreePath,
stashIndex,
operation,
hasConflicts: true,
conflictFiles,
});
return result;
}
// 5. Non-conflict git error re-throw so the outer catch logs and handles it
throw gitError;
}
// 6. Command succeeded check stdout for conflict markers (some git versions
// exit 0 even when conflicts occur during apply)
const combinedOutput = stdout;
emitter.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput });
if (isConflictOutput(combinedOutput)) {
const conflictFiles = await getConflictedFiles(worktreePath);
emitter.emit('stash:conflicts', {
worktreePath,
stashIndex,
operation,
conflictFiles,
});
const result: StashApplyResult = {
success: true,
applied: true,
hasConflicts: true,
conflictFiles,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
};
emitter.emit('stash:success', {
worktreePath,
stashIndex,
operation,
hasConflicts: true,
conflictFiles,
});
return result;
}
// 7. Clean success
const result: StashApplyResult = {
success: true,
applied: true,
hasConflicts: false,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} successfully`,
};
emitter.emit('stash:success', {
worktreePath,
stashIndex,
operation,
hasConflicts: false,
});
return result;
} catch (error) {
const errorMessage = getErrorMessage(error);
logError(error, `Stash ${operation} failed`);
emitter.emit('stash:failure', {
worktreePath,
stashIndex,
operation,
error: errorMessage,
});
return {
success: false,
error: errorMessage,
applied: false,
operation,
stashIndex,
};
}
}
// ============================================================================
// Push Stash
// ============================================================================
/**
* Stash uncommitted changes (including untracked files) with an optional
* message and optional file selection.
*
* Workflow:
* 1. Check for uncommitted changes via `git status --porcelain`
* 2. If no changes, return early with stashed: false
* 3. Build and run `git stash push --include-untracked [-m message] [-- files]`
* 4. Retrieve the current branch name
* 5. Return a structured StashPushResult
*
* @param worktreePath - Absolute path to the git worktree
* @param options - Optional message and files to selectively stash
* @returns StashPushResult with stash status and branch info
*/
export async function pushStash(
worktreePath: string,
options?: { message?: string; files?: string[] }
): Promise<StashPushResult> {
const message = options?.message;
const files = options?.files;
logger.info(`[StashService] push stash in ${worktreePath}`);
// 1. Check for any changes to stash
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
if (!status.trim()) {
return {
success: true,
stashed: false,
message: 'No changes to stash',
};
}
// 2. Build stash push command args
const args = ['stash', 'push', '--include-untracked'];
if (message && message.trim()) {
args.push('-m', message.trim());
}
// If specific files are provided, add them as pathspecs after '--'
if (files && files.length > 0) {
args.push('--');
args.push(...files);
}
// 3. Execute stash push
await execGitCommand(args, worktreePath);
// 4. Get current branch name
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
const branchName = branchOutput.trim();
return {
success: true,
stashed: true,
branch: branchName,
message: message?.trim() || `WIP on ${branchName}`,
};
}
// ============================================================================
// List Stashes
// ============================================================================
/**
* List all stash entries for a worktree with metadata and changed files.
*
* Workflow:
* 1. Run `git stash list` with a custom format to get index, message, and date
* 2. Parse each stash line into a structured StashEntry
* 3. For each entry, fetch the list of files changed via `git stash show`
* 4. Return the full list as a StashListResult
*
* @param worktreePath - Absolute path to the git worktree
* @returns StashListResult with all stash entries and their metadata
*/
export async function listStash(worktreePath: string): Promise<StashListResult> {
logger.info(`[StashService] list stashes in ${worktreePath}`);
// 1. Get stash list with format: index, message, date
// Use %aI (strict ISO 8601) instead of %ai to ensure cross-browser compatibility
const stashOutput = await execGitCommand(
['stash', 'list', '--format=%gd|||%s|||%aI'],
worktreePath
);
if (!stashOutput.trim()) {
return {
success: true,
stashes: [],
total: 0,
};
}
const stashLines = stashOutput
.trim()
.split('\n')
.filter((l) => l.trim());
const stashes: StashEntry[] = [];
for (const line of stashLines) {
const parts = line.split('|||');
if (parts.length < 3) continue;
const refSpec = parts[0].trim(); // e.g., "stash@{0}"
const stashMessage = parts[1].trim();
const date = parts[2].trim();
// Extract index from stash@{N}; skip entries that don't match the expected format
const indexMatch = refSpec.match(/stash@\{(\d+)\}/);
if (!indexMatch) continue;
const index = parseInt(indexMatch[1], 10);
// Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message")
let branch = '';
const branchMatch = stashMessage.match(/^(?:WIP on|On) ([^:]+):/);
if (branchMatch) {
branch = branchMatch[1];
}
// Get list of files in this stash
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
['stash', 'show', refSpec, '--name-only'],
worktreePath
);
files = filesOutput
.trim()
.split('\n')
.filter((f) => f.trim());
} catch {
// Ignore errors getting file list
}
stashes.push({
index,
message: stashMessage,
branch,
date,
files,
});
}
return {
success: true,
stashes,
total: stashes.length,
};
}
// ============================================================================
// Drop Stash
// ============================================================================
/**
* Drop (delete) a stash entry by index.
*
* @param worktreePath - Absolute path to the git worktree
* @param stashIndex - Zero-based stash index (stash@{N})
* @returns StashDropResult with drop status
*/
export async function dropStash(
worktreePath: string,
stashIndex: number
): Promise<StashDropResult> {
const stashRef = `stash@{${stashIndex}}`;
logger.info(`[StashService] drop ${stashRef} in ${worktreePath}`);
await execGitCommand(['stash', 'drop', stashRef], worktreePath);
return {
success: true,
dropped: true,
stashIndex,
message: `Stash ${stashRef} dropped successfully`,
};
}

View File

@@ -0,0 +1,441 @@
/**
* WorktreeBranchService - Switch branch operations without HTTP
*
* Handles branch switching with automatic stash/reapply of local changes.
* If there are uncommitted changes, they are stashed before switching and
* reapplied after. If the stash pop results in merge conflicts, returns
* a special response so the UI can create a conflict resolution task.
*
* For remote branches (e.g., "origin/feature"), automatically creates a
* local tracking branch and checks it out.
*
* Also fetches the latest remote refs after switching.
*
* Extracted from the worktree switch-branch route to improve organization
* and testability. Follows the same pattern as pull-service.ts and
* rebase-service.ts.
*/
import { createLogger } from '@automaker/utils';
import { execGitCommand } from '../lib/git.js';
import { getErrorMessage } from '../routes/worktree/common.js';
import type { EventEmitter } from '../lib/events.js';
const logger = createLogger('WorktreeBranchService');
// ============================================================================
// Types
// ============================================================================
export interface SwitchBranchResult {
success: boolean;
error?: string;
result?: {
previousBranch: string;
currentBranch: string;
message: string;
hasConflicts?: boolean;
stashedChanges?: boolean;
};
/** Set when checkout fails and stash pop produced conflicts during recovery */
stashPopConflicts?: boolean;
/** Human-readable message when stash pop conflicts occur during error recovery */
stashPopConflictMessage?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
function isExcludedWorktreeLine(line: string): boolean {
return line.includes('.worktrees/') || line.endsWith('.worktrees');
}
/**
* Check if there are any changes at all (including untracked) that should be stashed
*/
async function hasAnyChanges(cwd: string): Promise<boolean> {
try {
const stdout = await execGitCommand(['status', '--porcelain'], cwd);
const lines = stdout
.trim()
.split('\n')
.filter((line) => {
if (!line.trim()) return false;
if (isExcludedWorktreeLine(line)) return false;
return true;
});
return lines.length > 0;
} catch {
return false;
}
}
/**
* Stash all local changes (including untracked files)
* Returns true if a stash was created, false if there was nothing to stash
*/
async function stashChanges(cwd: string, message: string): Promise<boolean> {
try {
// Get stash count before
const beforeOutput = await execGitCommand(['stash', 'list'], cwd);
const countBefore = beforeOutput
.trim()
.split('\n')
.filter((l) => l.trim()).length;
// Stash including untracked files
await execGitCommand(['stash', 'push', '--include-untracked', '-m', message], cwd);
// Get stash count after to verify something was stashed
const afterOutput = await execGitCommand(['stash', 'list'], cwd);
const countAfter = afterOutput
.trim()
.split('\n')
.filter((l) => l.trim()).length;
return countAfter > countBefore;
} catch {
return false;
}
}
/**
* Pop the most recent stash entry
* Returns an object indicating success and whether there were conflicts
*/
async function popStash(
cwd: string
): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> {
try {
const stdout = await execGitCommand(['stash', 'pop'], cwd);
// Check for conflict markers in the output
if (stdout.includes('CONFLICT') || stdout.includes('Merge conflict')) {
return { success: false, hasConflicts: true };
}
return { success: true, hasConflicts: false };
} catch (error) {
const errorMsg = getErrorMessage(error);
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
return { success: false, hasConflicts: true, error: errorMsg };
}
return { success: false, hasConflicts: false, error: errorMsg };
}
}
/**
* Fetch latest from all remotes (silently, with timeout)
*/
async function fetchRemotes(cwd: string): Promise<void> {
try {
await execGitCommand(['fetch', '--all', '--quiet'], cwd);
} catch {
// Ignore fetch errors - we may be offline
}
}
/**
* Parse a remote branch name like "origin/feature-branch" into its parts
*/
function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null {
const slashIndex = branchName.indexOf('/');
if (slashIndex === -1) return null;
return {
remote: branchName.substring(0, slashIndex),
branch: branchName.substring(slashIndex + 1),
};
}
/**
* Check if a branch name refers to a remote branch
*/
async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean> {
try {
const stdout = await execGitCommand(['branch', '-r', '--format=%(refname:short)'], cwd);
const remoteBranches = stdout
.trim()
.split('\n')
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
.filter((b) => b);
return remoteBranches.includes(branchName);
} catch {
return false;
}
}
/**
* Check if a local branch already exists
*/
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
try {
await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], cwd);
return true;
} catch {
return false;
}
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Perform a full branch switch workflow on the given worktree.
*
* The workflow:
* 1. Get current branch name
* 2. Detect remote vs local branch and determine target
* 3. Return early if already on target branch
* 4. Validate branch existence
* 5. Stash local changes if any
* 6. Checkout the target branch
* 7. Fetch latest from remotes
* 8. Reapply stashed changes (detect conflicts)
* 9. Handle error recovery (restore stash if checkout fails)
*
* @param worktreePath - Path to the git worktree
* @param branchName - Branch to switch to (can be local or remote like "origin/feature")
* @param events - Optional event emitter for lifecycle events
* @returns SwitchBranchResult with detailed status information
*/
export async function performSwitchBranch(
worktreePath: string,
branchName: string,
events?: EventEmitter
): Promise<SwitchBranchResult> {
// Emit start event
events?.emit('switch:start', { worktreePath, branchName });
// 1. Get current branch
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const previousBranch = currentBranchOutput.trim();
// 2. Determine the actual target branch name for checkout
let targetBranch = branchName;
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;
parsedRemote = parseRemoteBranch(branchName);
if (parsedRemote) {
targetBranch = parsedRemote.branch;
} else {
events?.emit('switch:error', {
worktreePath,
branchName,
error: `Failed to parse remote branch name '${branchName}'`,
});
return {
success: false,
error: `Failed to parse remote branch name '${branchName}'`,
};
}
}
// 3. Return early if already on the target branch
if (previousBranch === targetBranch) {
events?.emit('switch:done', {
worktreePath,
previousBranch,
currentBranch: targetBranch,
alreadyOnBranch: true,
});
return {
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: `Already on branch '${targetBranch}'`,
},
};
}
// 4. Check if target branch exists (locally or as remote ref)
if (!isRemote) {
try {
await execGitCommand(['rev-parse', '--verify', branchName], worktreePath);
} catch {
events?.emit('switch:error', {
worktreePath,
branchName,
error: `Branch '${branchName}' does not exist`,
});
return {
success: false,
error: `Branch '${branchName}' does not exist`,
};
}
}
// 5. Stash local changes if any exist
const hadChanges = await hasAnyChanges(worktreePath);
let didStash = false;
if (hadChanges) {
events?.emit('switch:stash', {
worktreePath,
previousBranch,
targetBranch,
action: 'push',
});
const stashMessage = `automaker-branch-switch: ${previousBranch}${targetBranch}`;
didStash = await stashChanges(worktreePath, stashMessage);
}
try {
// 6. Switch to the target branch
events?.emit('switch:checkout', {
worktreePath,
targetBranch,
isRemote,
previousBranch,
});
if (isRemote) {
if (!parsedRemote) {
throw new Error(`Failed to parse remote branch name '${branchName}'`);
}
if (await localBranchExists(worktreePath, parsedRemote.branch)) {
// Local branch exists, just checkout
await execGitCommand(['checkout', parsedRemote.branch], worktreePath);
} else {
// Create local tracking branch from remote
await execGitCommand(['checkout', '-b', parsedRemote.branch, branchName], worktreePath);
}
} else {
await execGitCommand(['checkout', targetBranch], worktreePath);
}
// 7. Fetch latest from remotes after switching
await fetchRemotes(worktreePath);
// 8. Reapply stashed changes if we stashed earlier
let hasConflicts = false;
let conflictMessage = '';
let stashReapplied = false;
if (didStash) {
events?.emit('switch:pop', {
worktreePath,
targetBranch,
action: 'pop',
});
const popResult = await popStash(worktreePath);
hasConflicts = popResult.hasConflicts;
if (popResult.hasConflicts) {
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;
}
}
if (hasConflicts) {
events?.emit('switch:done', {
worktreePath,
previousBranch,
currentBranch: targetBranch,
hasConflicts: true,
});
return {
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: conflictMessage,
hasConflicts: true,
stashedChanges: true,
},
};
} else if (didStash && !stashReapplied) {
// Stash pop failed for a non-conflict reason — stash is still present
events?.emit('switch:done', {
worktreePath,
previousBranch,
currentBranch: targetBranch,
stashPopFailed: true,
});
return {
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: conflictMessage,
hasConflicts: false,
stashedChanges: true,
},
};
} else {
const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : '';
events?.emit('switch:done', {
worktreePath,
previousBranch,
currentBranch: targetBranch,
stashReapplied,
});
return {
success: true,
result: {
previousBranch,
currentBranch: targetBranch,
message: `Switched to branch '${targetBranch}'${stashNote}`,
hasConflicts: false,
stashedChanges: stashReapplied,
},
};
}
} catch (checkoutError) {
// 9. If checkout failed and we stashed, try to restore the stash
if (didStash) {
const popResult = await popStash(worktreePath);
if (popResult.hasConflicts) {
// Stash pop itself produced merge conflicts — the working tree is now in a
// conflicted state even though the checkout failed. Surface this clearly so
// the caller can prompt the user (or AI) to resolve conflicts rather than
// simply retrying the branch switch.
const checkoutErrorMsg = getErrorMessage(checkoutError);
events?.emit('switch:error', {
worktreePath,
branchName,
error: checkoutErrorMsg,
stashPopConflicts: true,
});
return {
success: false,
error: checkoutErrorMsg,
stashPopConflicts: true,
stashPopConflictMessage:
'Stash pop resulted in conflicts: your stashed changes were partially reapplied ' +
'but produced merge conflicts. Please resolve the conflicts before retrying the branch switch.',
};
} else if (!popResult.success) {
// Stash pop failed for a non-conflict reason; the stash entry is still intact.
// Include this detail alongside the original checkout error.
const checkoutErrorMsg = getErrorMessage(checkoutError);
const combinedMessage =
`${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` +
`${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`;
events?.emit('switch:error', {
worktreePath,
branchName,
error: combinedMessage,
});
return {
success: false,
error: combinedMessage,
stashPopConflicts: false,
};
}
// popResult.success === true: stash was cleanly restored, re-throw the checkout error
}
throw checkoutError;
}
}