UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

704 lines (608 loc) 21.7 kB
const execa = require('execa'); const path = require('path'); const fs = require('fs').promises; const chalk = require('chalk'); /** * @typedef {Object} WorktreeInfo * @property {string} storyId - The story identifier * @property {string} path - Absolute path to the worktree * @property {string} branch - Branch name (auto-claude/{storyId}) * @property {Date} createdAt - When the worktree was created * @property {number} uncommittedChanges - Number of uncommitted changes * @property {'active'|'stale'} status - Worktree status (stale if > 30 days) */ /** * @typedef {Object} WorktreeManagerOptions * @property {number} [maxWorktrees=10] - Maximum number of concurrent worktrees * @property {string} [worktreeDir='.aios/worktrees'] - Directory for worktrees * @property {string} [branchPrefix='auto-claude/'] - Prefix for worktree branches * @property {number} [staleDays=30] - Days after which a worktree is considered stale * @property {string} [mergeLogDir='.aios/logs/merges'] - Directory for merge audit logs */ /** * @typedef {Object} MergeOptions * @property {boolean} [staged=false] - Merge without committing (--no-commit) * @property {boolean} [squash=false] - Squash commits before merge (--squash) * @property {boolean} [cleanup=false] - Remove worktree after successful merge * @property {string} [message] - Custom merge commit message */ /** * @typedef {Object} MergeResult * @property {boolean} success - Whether the merge was successful * @property {string} storyId - Story identifier that was merged * @property {string} sourceBranch - Branch that was merged from * @property {string} targetBranch - Branch that was merged into * @property {string[]} conflicts - List of conflicting files (if any) * @property {string} [commitHash] - Resulting commit hash (if committed) * @property {string} [error] - Error message if merge failed * @property {Date} timestamp - When the merge was attempted * @property {string} [logPath] - Path to merge audit log */ /** * WorktreeManager - Manages Git worktrees for isolated story development * * Provides functionality to create, list, and remove Git worktrees, * enabling parallel development of multiple stories in isolation. * * @example * const manager = new WorktreeManager('/path/to/repo'); * await manager.create('STORY-42'); * const worktrees = await manager.list(); * await manager.remove('STORY-42'); */ class WorktreeManager { /** * @param {string} [projectRoot] - Root path of the git repository * @param {WorktreeManagerOptions} [options] - Configuration options */ constructor(projectRoot, options = {}) { this.projectRoot = projectRoot || process.cwd(); this.maxWorktrees = options.maxWorktrees || 10; this.worktreeDir = options.worktreeDir || '.aios/worktrees'; this.branchPrefix = options.branchPrefix || 'auto-claude/'; this.staleDays = options.staleDays || 30; this.mergeLogDir = options.mergeLogDir || '.aios/logs/merges'; this.gitPath = 'git'; } /** * Execute git command using execa * @private * @param {string[]} args - Git command arguments * @param {Object} [options] - Execution options * @returns {Promise<string>} Command stdout output */ async execGit(args, options = {}) { try { const { stdout, stderr } = await execa(this.gitPath, args, { cwd: this.projectRoot, ...options, }); if (stderr && !options.ignoreStderr) { console.warn(chalk.yellow(`Git warning: ${stderr}`)); } return stdout.trim(); } catch (error) { throw new Error(`Git command failed: ${error.message}`); } } /** * Get the worktree path for a story * @private * @param {string} storyId - Story identifier * @returns {string} Absolute path to worktree */ getWorktreePath(storyId) { return path.join(this.projectRoot, this.worktreeDir, storyId); } /** * Get the branch name for a story * @private * @param {string} storyId - Story identifier * @returns {string} Branch name */ getBranchName(storyId) { return `${this.branchPrefix}${storyId}`; } /** * Create a new worktree for a story * * @param {string} storyId - Story identifier (e.g., 'STORY-42') * @returns {Promise<WorktreeInfo>} Created worktree information * @throws {Error} If max worktrees limit reached or worktree already exists */ async create(storyId) { // Check if worktree already exists if (await this.exists(storyId)) { throw new Error(`Worktree for story '${storyId}' already exists`); } // Check max worktrees limit const currentWorktrees = await this.list(); if (currentWorktrees.length >= this.maxWorktrees) { throw new Error( `Maximum worktrees limit (${this.maxWorktrees}) reached. ` + `Remove stale worktrees before creating new ones.` ); } const worktreePath = this.getWorktreePath(storyId); const branchName = this.getBranchName(storyId); // Ensure parent directory exists const parentDir = path.dirname(worktreePath); await fs.mkdir(parentDir, { recursive: true }); // Create worktree with new branch await this.execGit(['worktree', 'add', worktreePath, '-b', branchName]); console.log(chalk.green(`✓ Created worktree for ${storyId} at ${worktreePath}`)); return this.get(storyId); } /** * Remove a worktree and its associated branch * * @param {string} storyId - Story identifier * @param {Object} [options] - Removal options * @param {boolean} [options.force=false] - Force removal even with uncommitted changes * @returns {Promise<boolean>} True if successfully removed * @throws {Error} If worktree doesn't exist */ async remove(storyId, options = {}) { if (!(await this.exists(storyId))) { throw new Error(`Worktree for story '${storyId}' does not exist`); } const worktreePath = this.getWorktreePath(storyId); const branchName = this.getBranchName(storyId); // Remove worktree const worktreeArgs = ['worktree', 'remove', worktreePath]; if (options.force) { worktreeArgs.push('--force'); } await this.execGit(worktreeArgs); // Delete branch try { const branchArgs = ['branch', '-d', branchName]; if (options.force) { branchArgs[1] = '-D'; // Force delete } await this.execGit(branchArgs); } catch (error) { console.warn( chalk.yellow(`Warning: Could not delete branch ${branchName}: ${error.message}`) ); } console.log(chalk.green(`✓ Removed worktree for ${storyId}`)); return true; } /** * List all worktrees with their status * * @returns {Promise<WorktreeInfo[]>} Array of worktree information */ async list() { const output = await this.execGit(['worktree', 'list', '--porcelain']); if (!output) { return []; } const worktrees = []; const blocks = output.split('\n\n').filter(Boolean); for (const block of blocks) { const lines = block.split('\n'); const worktreePath = lines[0]?.replace('worktree ', '') || ''; const branchLine = lines.find((l) => l.startsWith('branch ')); const branch = branchLine?.replace('branch refs/heads/', '') || ''; // Skip main worktree and non-auto-claude worktrees if (!branch.startsWith(this.branchPrefix)) { continue; } const storyId = branch.replace(this.branchPrefix, ''); const expectedPath = this.getWorktreePath(storyId); // Only include worktrees in our managed directory if (!worktreePath.includes(this.worktreeDir)) { continue; } const info = await this.getWorktreeInfo(storyId, worktreePath, branch); if (info) { worktrees.push(info); } } return worktrees; } /** * Get detailed information about a worktree * @private * @param {string} storyId - Story identifier * @param {string} worktreePath - Path to worktree * @param {string} branch - Branch name * @returns {Promise<WorktreeInfo|null>} Worktree information */ async getWorktreeInfo(storyId, worktreePath, branch) { try { // Get creation time from directory const stats = await fs.stat(worktreePath); const createdAt = stats.birthtime || stats.mtime; // Get uncommitted changes count let uncommittedChanges = 0; try { const statusOutput = await execa(this.gitPath, ['status', '--porcelain'], { cwd: worktreePath, }); uncommittedChanges = statusOutput.stdout ? statusOutput.stdout.split('\n').filter(Boolean).length : 0; } catch { // Worktree might be in invalid state } // Determine status const daysSinceCreation = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24); const status = daysSinceCreation > this.staleDays ? 'stale' : 'active'; return { storyId, path: worktreePath, branch, createdAt, uncommittedChanges, status, }; } catch (error) { return null; } } /** * Get information about a specific worktree * * @param {string} storyId - Story identifier * @returns {Promise<WorktreeInfo|null>} Worktree information or null if not found */ async get(storyId) { const worktreePath = this.getWorktreePath(storyId); const branchName = this.getBranchName(storyId); try { await fs.access(worktreePath); return this.getWorktreeInfo(storyId, worktreePath, branchName); } catch { return null; } } /** * Check if a worktree exists for a story * * @param {string} storyId - Story identifier * @returns {Promise<boolean>} True if worktree exists */ async exists(storyId) { const worktreePath = this.getWorktreePath(storyId); try { await fs.access(worktreePath); return true; } catch { return false; } } /** * Get count of active worktrees * * @returns {Promise<{total: number, active: number, stale: number}>} Worktree counts */ async getCount() { const worktrees = await this.list(); return { total: worktrees.length, active: worktrees.filter((w) => w.status === 'active').length, stale: worktrees.filter((w) => w.status === 'stale').length, }; } /** * Remove all stale worktrees * * @returns {Promise<string[]>} List of removed story IDs */ async cleanupStale() { const worktrees = await this.list(); const stale = worktrees.filter((w) => w.status === 'stale'); const removed = []; for (const worktree of stale) { try { await this.remove(worktree.storyId, { force: true }); removed.push(worktree.storyId); } catch (error) { console.warn( chalk.yellow( `Warning: Could not remove stale worktree ${worktree.storyId}: ${error.message}` ) ); } } if (removed.length > 0) { console.log(chalk.green(`✓ Cleaned up ${removed.length} stale worktrees`)); } return removed; } /** * Detect potential merge conflicts before merging * * Performs a dry-run merge to identify conflicting files without * permanently modifying the working tree. * * @param {string} storyId - Story identifier * @returns {Promise<string[]>} List of files that would conflict * @throws {Error} If worktree doesn't exist */ async detectConflicts(storyId) { if (!(await this.exists(storyId))) { throw new Error(`Worktree for story '${storyId}' does not exist`); } const branchName = this.getBranchName(storyId); // Save current HEAD to restore if needed const currentHead = await this.execGit(['rev-parse', 'HEAD']); try { // Attempt a dry-run merge with --no-commit await execa(this.gitPath, ['merge', '--no-commit', '--no-ff', branchName], { cwd: this.projectRoot, }); // No conflicts - abort the successful merge await execa(this.gitPath, ['merge', '--abort'], { cwd: this.projectRoot, }).catch(() => { // If abort fails, reset to original HEAD return execa(this.gitPath, ['reset', '--hard', currentHead], { cwd: this.projectRoot, }); }); return []; } catch (mergeError) { // Merge failed - likely conflicts let conflicts = []; try { // Get conflict list using diff const { stdout: conflictOutput } = await execa( this.gitPath, ['diff', '--name-only', '--diff-filter=U'], { cwd: this.projectRoot } ); conflicts = conflictOutput ? conflictOutput.split('\n').filter((f) => f.trim()) : []; } catch { // If diff fails, try ls-files with unmerged try { const { stdout: lsOutput } = await execa(this.gitPath, ['ls-files', '--unmerged'], { cwd: this.projectRoot, }); // Extract unique filenames from ls-files output const fileSet = new Set(); lsOutput.split('\n').forEach((line) => { const parts = line.split('\t'); if (parts[1]) { fileSet.add(parts[1]); } }); conflicts = Array.from(fileSet); } catch { // Unable to get conflicts } } // Abort the failed merge and reset try { await execa(this.gitPath, ['merge', '--abort'], { cwd: this.projectRoot, }); } catch { // If abort fails, reset to original HEAD await execa(this.gitPath, ['reset', '--hard', currentHead], { cwd: this.projectRoot, }); } return conflicts; } } /** * Merge worktree branch back to base branch * * @param {string} storyId - Story identifier * @param {MergeOptions} [options] - Merge options * @returns {Promise<MergeResult>} Result of the merge operation * @throws {Error} If worktree doesn't exist */ async mergeToBase(storyId, options = {}) { const timestamp = new Date(); const branchName = this.getBranchName(storyId); // Validate worktree exists if (!(await this.exists(storyId))) { throw new Error(`Worktree for story '${storyId}' does not exist`); } // Get current branch (base branch to merge into) const baseBranch = await this.execGit(['rev-parse', '--abbrev-ref', 'HEAD']); // Initialize result object const result = { success: false, storyId, sourceBranch: branchName, targetBranch: baseBranch, conflicts: [], timestamp, }; try { // Check for conflicts first const conflicts = await this.detectConflicts(storyId); if (conflicts.length > 0) { result.conflicts = conflicts; result.error = `Merge would result in ${conflicts.length} conflict(s)`; console.log(chalk.red(`✗ Cannot merge ${storyId}: ${conflicts.length} conflicts detected`)); conflicts.forEach((file) => console.log(chalk.yellow(` - ${file}`))); await this.writeMergeLog(result); return result; } // Build merge arguments const mergeArgs = ['merge']; if (options.squash) { mergeArgs.push('--squash'); } if (options.staged || options.squash) { mergeArgs.push('--no-commit'); } // Force merge commit (no fast-forward) when staged without squash // (squash already implies non-fast-forward behavior) if (options.staged && !options.squash) { mergeArgs.push('--no-ff'); } // Custom message or default const mergeMessage = options.message || `Merge ${branchName} (Story: ${storyId})`; if (!options.staged && !options.squash) { mergeArgs.push('-m', mergeMessage); } mergeArgs.push(branchName); // Perform the merge await execa(this.gitPath, mergeArgs, { cwd: this.projectRoot, }); // If squash merge, we need to commit manually unless staged option if (options.squash && !options.staged) { await execa(this.gitPath, ['commit', '-m', mergeMessage], { cwd: this.projectRoot, }); } // Get the resulting commit hash (if committed) if (!options.staged) { try { const commitHash = await this.execGit(['rev-parse', 'HEAD']); result.commitHash = commitHash; } catch { // Commit hash not available } } result.success = true; // Write audit log const logPath = await this.writeMergeLog(result); result.logPath = logPath; // Status message if (options.staged) { console.log(chalk.green(`✓ Merged ${storyId} (staged, not committed)`)); console.log(chalk.gray(' Use "git commit" to complete the merge')); } else if (options.squash) { console.log( chalk.green(`✓ Merged ${storyId} (squashed) → ${result.commitHash?.substring(0, 7)}`) ); } else { console.log(chalk.green(`✓ Merged ${storyId} → ${result.commitHash?.substring(0, 7)}`)); } // Cleanup worktree if requested if (options.cleanup && result.success) { await this.remove(storyId, { force: true }); console.log(chalk.gray(` Cleaned up worktree for ${storyId}`)); } return result; } catch (error) { // Merge failed result.error = error.message; // Try to get conflict list try { const conflictOutput = await this.execGit(['diff', '--name-only', '--diff-filter=U']); result.conflicts = conflictOutput ? conflictOutput.split('\n').filter((f) => f.trim()) : []; } catch { // No conflicts available } // Abort the failed merge try { await this.execGit(['merge', '--abort'], { ignoreStderr: true }); } catch { // Abort might fail if nothing to abort } console.log(chalk.red(`✗ Merge failed for ${storyId}: ${error.message}`)); if (result.conflicts.length > 0) { console.log(chalk.yellow(' Conflicting files:')); result.conflicts.forEach((file) => console.log(chalk.yellow(` - ${file}`))); } await this.writeMergeLog(result); return result; } } /** * Write merge audit log * @private * @param {MergeResult} result - Merge result to log * @returns {Promise<string>} Path to the log file */ async writeMergeLog(result) { const logDir = path.join(this.projectRoot, this.mergeLogDir); await fs.mkdir(logDir, { recursive: true }); const timestamp = result.timestamp.toISOString().replace(/[:.]/g, '-'); const logFileName = `merge-${result.storyId}-${timestamp}.json`; const logPath = path.join(logDir, logFileName); const logEntry = { ...result, timestamp: result.timestamp.toISOString(), projectRoot: this.projectRoot, }; await fs.writeFile(logPath, JSON.stringify(logEntry, null, 2), 'utf8'); return logPath; } /** * Get merge history for a story * * @param {string} [storyId] - Story identifier (optional, returns all if not provided) * @returns {Promise<MergeResult[]>} Array of merge results */ async getMergeHistory(storyId = null) { const logDir = path.join(this.projectRoot, this.mergeLogDir); try { await fs.access(logDir); } catch { return []; // No logs yet } const files = await fs.readdir(logDir); const pattern = storyId ? `merge-${storyId}-` : 'merge-'; const logFiles = files.filter((f) => f.startsWith(pattern) && f.endsWith('.json')); const history = []; for (const file of logFiles) { try { const content = await fs.readFile(path.join(logDir, file), 'utf8'); const entry = JSON.parse(content); entry.timestamp = new Date(entry.timestamp); history.push(entry); } catch { // Skip invalid log files } } // Sort by timestamp descending return history.sort((a, b) => b.timestamp - a.timestamp); } /** * Format worktree list for display * * @param {WorktreeInfo[]} worktrees - List of worktrees * @returns {string} Formatted output string */ formatList(worktrees) { if (worktrees.length === 0) { return chalk.gray('No active worktrees'); } const lines = [ chalk.bold(`📁 Active Worktrees (${worktrees.length}/${this.maxWorktrees})`), chalk.gray('━'.repeat(50)), ]; for (const wt of worktrees) { const statusIcon = wt.status === 'active' ? (wt.uncommittedChanges > 0 ? '🟢' : '🟡') : '⚫'; const changesText = wt.uncommittedChanges > 0 ? `${wt.uncommittedChanges} uncommitted` : 'clean'; const age = this.formatAge(wt.createdAt); const staleTag = wt.status === 'stale' ? chalk.red(' (stale)') : ''; lines.push( `${statusIcon} ${chalk.cyan(wt.storyId.padEnd(12))} │ ${wt.branch.padEnd(25)} │ ${changesText.padEnd(15)} │ ${age}${staleTag}` ); } return lines.join('\n'); } /** * Format age for display * @private * @param {Date} date - Date to format * @returns {string} Formatted age string */ formatAge(date) { const diff = Date.now() - date.getTime(); const hours = Math.floor(diff / (1000 * 60 * 60)); const days = Math.floor(hours / 24); if (days > 0) { return `${days}d ago`; } if (hours > 0) { return `${hours}h ago`; } return 'just now'; } } module.exports = WorktreeManager;