mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
- Improved session handling by implementing ensureSession to load sessions from disk if not in memory, reducing "session not found" errors. - Enhanced error messages for non-existent sessions, providing clearer diagnostics. - Updated CodexProvider and OpencodeProvider to improve error handling and messaging. - Refactored various routes to use async/await for better readability and error handling. - Added event emission for merge and stash operations in the MergeService and StashService. - Cleaned up error messages in AgentExecutor to remove redundant prefixes and ANSI codes for better clarity.
463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
/**
|
||
* 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 type { EventEmitter } 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,
|
||
events?: EventEmitter
|
||
): Promise<StashApplyResult> {
|
||
const operation: 'apply' | 'pop' = options?.pop ? 'pop' : 'apply';
|
||
const stashRef = `stash@{${stashIndex}}`;
|
||
|
||
logger.info(`[StashService] ${operation} ${stashRef} in ${worktreePath}`);
|
||
|
||
// 1. Emit start event
|
||
events?.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
|
||
events?.emit('stash:progress', {
|
||
worktreePath,
|
||
stashIndex,
|
||
operation,
|
||
output: combinedOutput,
|
||
});
|
||
|
||
// 4. Check if the error is a conflict
|
||
if (isConflictOutput(combinedOutput)) {
|
||
const conflictFiles = await getConflictedFiles(worktreePath);
|
||
|
||
events?.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.`,
|
||
};
|
||
|
||
events?.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;
|
||
|
||
events?.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput });
|
||
|
||
if (isConflictOutput(combinedOutput)) {
|
||
const conflictFiles = await getConflictedFiles(worktreePath);
|
||
|
||
events?.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.`,
|
||
};
|
||
|
||
events?.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`,
|
||
};
|
||
|
||
events?.emit('stash:success', {
|
||
worktreePath,
|
||
stashIndex,
|
||
operation,
|
||
hasConflicts: false,
|
||
});
|
||
|
||
return result;
|
||
} catch (error) {
|
||
const errorMessage = getErrorMessage(error);
|
||
|
||
logError(error, `Stash ${operation} failed`);
|
||
|
||
events?.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`,
|
||
};
|
||
}
|