mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-22 23:53:08 +00:00
feat: Add GPT-5 model variants and improve Codex execution logic. Addressed code review comments
This commit is contained in:
462
apps/server/src/services/stash-service.ts
Normal file
462
apps/server/src/services/stash-service.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user