/** * branch-utils - Shared git branch helper utilities * * Provides common git operations used by both checkout-branch-service and * worktree-branch-service. Extracted to avoid duplication and ensure * consistent behaviour across branch-related services. */ import { createLogger, getErrorMessage } from '@automaker/utils'; import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js'; const logger = createLogger('BranchUtils'); // ============================================================================ // Types // ============================================================================ export interface HasAnyChangesOptions { /** * When true, lines that refer to worktree-internal paths (containing * ".worktrees/" or ending with ".worktrees") are excluded from the count. * Use this in contexts where worktree directory entries should not be * considered as real working-tree changes (e.g. worktree-branch-service). */ excludeWorktreePaths?: boolean; /** * When true (default), untracked files (lines starting with "??") are * included in the change count. When false, untracked files are ignored so * that hasAnyChanges() is consistent with stashChanges() called without * --include-untracked. */ includeUntracked?: boolean; } // ============================================================================ // Helpers // ============================================================================ /** * Returns true when a `git status --porcelain` output line refers to a * worktree-internal path that should be ignored when deciding whether there * are "real" local changes. */ function isExcludedWorktreeLine(line: string): boolean { return line.includes('.worktrees/') || line.endsWith('.worktrees'); } // ============================================================================ // Exported Utilities // ============================================================================ /** * Check if there are any changes that should be stashed. * * @param cwd - Working directory of the git repository / worktree * @param options - Optional flags controlling which lines are counted * @param options.excludeWorktreePaths - When true, lines matching worktree * internal paths are excluded so they are not mistaken for real changes * @param options.includeUntracked - When false, untracked files (lines * starting with "??") are excluded so this is consistent with a * stashChanges() call that does not pass --include-untracked. * Defaults to true. */ export async function hasAnyChanges(cwd: string, options?: HasAnyChangesOptions): Promise { try { const includeUntracked = options?.includeUntracked ?? true; const stdout = await execGitCommand(['status', '--porcelain'], cwd); const lines = stdout .trim() .split('\n') .filter((line) => { if (!line.trim()) return false; if (options?.excludeWorktreePaths && isExcludedWorktreeLine(line)) return false; if (!includeUntracked && line.startsWith('??')) return false; return true; }); return lines.length > 0; } catch (err) { logger.error('hasAnyChanges: execGitCommand failed — returning false', { cwd, error: getErrorMessage(err), }); return false; } } /** * Stash all local changes (including untracked files if requested). * Returns true if a stash was created, false if there was nothing to stash. * Throws on unexpected errors so callers abort rather than proceeding silently. * * @param cwd - Working directory of the git repository / worktree * @param message - Stash message * @param includeUntracked - When true, passes `--include-untracked` to git stash */ export async function stashChanges( cwd: string, message: string, includeUntracked: boolean = true ): Promise { try { const args = ['stash', 'push']; if (includeUntracked) { args.push('--include-untracked'); } args.push('-m', message); const stdout = await execGitCommandWithLockRetry(args, cwd); // git exits 0 but prints a benign message when there is nothing to stash const stdoutLower = stdout.toLowerCase(); if ( stdoutLower.includes('no local changes to save') || stdoutLower.includes('nothing to stash') ) { logger.debug('stashChanges: nothing to stash', { cwd, message, stdout }); return false; } return true; } catch (error) { const errorMsg = getErrorMessage(error); // Unexpected error – log full details and re-throw so the caller aborts // rather than proceeding with an un-stashed working tree logger.error('stashChanges: unexpected error during stash', { cwd, message, error: errorMsg, }); throw new Error(`Failed to stash changes in ${cwd}: ${errorMsg}`); } } /** * Pop the most recent stash entry. * Returns an object indicating success and whether there were conflicts. * * @param cwd - Working directory of the git repository / worktree */ export async function popStash( cwd: string ): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> { try { await execGitCommandWithLockRetry(['stash', 'pop'], cwd); // If execGitCommandWithLockRetry succeeds (zero exit code), there are no conflicts 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 }; } } /** * Check if a local branch already exists. * * @param cwd - Working directory of the git repository / worktree * @param branchName - The branch name to look up (without refs/heads/ prefix) */ export async function localBranchExists(cwd: string, branchName: string): Promise { try { await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], cwd); return true; } catch { return false; } }