Files
automaker/apps/server/src/services/branch-utils.ts

171 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<boolean> {
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<boolean> {
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<boolean> {
try {
await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], cwd);
return true;
} catch {
return false;
}
}