mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-24 00:13:07 +00:00
209 lines
7.3 KiB
TypeScript
209 lines
7.3 KiB
TypeScript
/**
|
|
* Shared git command execution utilities.
|
|
*
|
|
* This module provides the canonical `execGitCommand` helper and common
|
|
* git utilities used across services and routes. All consumers should
|
|
* import from here rather than defining their own copy.
|
|
*/
|
|
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { spawnProcess } from '@automaker/platform';
|
|
import { createLogger } from '@automaker/utils';
|
|
|
|
const logger = createLogger('GitLib');
|
|
|
|
// ============================================================================
|
|
// Secure Command Execution
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Execute git command with array arguments to prevent command injection.
|
|
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
|
|
*
|
|
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
|
|
* @param cwd - Working directory to execute the command in
|
|
* @param env - Optional additional environment variables to pass to the git process.
|
|
* These are merged on top of the current process environment. Pass
|
|
* `{ LC_ALL: 'C' }` to force git to emit English output regardless of the
|
|
* system locale so that text-based output parsing remains reliable.
|
|
* @param abortController - Optional AbortController to cancel the git process.
|
|
* When the controller is aborted the underlying process is sent SIGTERM and
|
|
* the returned promise rejects with an Error whose message is 'Process aborted'.
|
|
* @returns Promise resolving to stdout output
|
|
* @throws Error with stderr/stdout message if command fails. The thrown error
|
|
* also has `stdout` and `stderr` string properties for structured access.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Safe: no injection possible
|
|
* await execGitCommand(['branch', '-D', branchName], projectPath);
|
|
*
|
|
* // Force English output for reliable text parsing:
|
|
* await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' });
|
|
*
|
|
* // With a process-level timeout:
|
|
* const controller = new AbortController();
|
|
* const timerId = setTimeout(() => controller.abort(), 30_000);
|
|
* try {
|
|
* await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
|
* } finally {
|
|
* clearTimeout(timerId);
|
|
* }
|
|
*
|
|
* // Instead of unsafe:
|
|
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
|
* ```
|
|
*/
|
|
export async function execGitCommand(
|
|
args: string[],
|
|
cwd: string,
|
|
env?: Record<string, string>,
|
|
abortController?: AbortController
|
|
): Promise<string> {
|
|
const result = await spawnProcess({
|
|
command: 'git',
|
|
args,
|
|
cwd,
|
|
...(env !== undefined ? { env } : {}),
|
|
...(abortController !== undefined ? { abortController } : {}),
|
|
});
|
|
|
|
// spawnProcess returns { stdout, stderr, exitCode }
|
|
if (result.exitCode === 0) {
|
|
return result.stdout;
|
|
} else {
|
|
const errorMessage =
|
|
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
|
|
throw Object.assign(new Error(errorMessage), {
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Common Git Utilities
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get the current branch name for the given worktree.
|
|
*
|
|
* This is the canonical implementation shared across services. Services
|
|
* should import this rather than duplicating the logic locally.
|
|
*
|
|
* @param worktreePath - Path to the git worktree
|
|
* @returns The current branch name (trimmed)
|
|
*/
|
|
export async function getCurrentBranch(worktreePath: string): Promise<string> {
|
|
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
|
return branchOutput.trim();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Index Lock Recovery
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check whether an error message indicates a stale git index lock file.
|
|
*
|
|
* Git operations that write to the index (e.g. `git stash push`) will fail
|
|
* with "could not write index" or "Unable to create ... .lock" when a
|
|
* `.git/index.lock` file exists from a previously interrupted operation.
|
|
*
|
|
* @param errorMessage - The error string from a failed git command
|
|
* @returns true if the error looks like a stale index lock issue
|
|
*/
|
|
export function isIndexLockError(errorMessage: string): boolean {
|
|
const lower = errorMessage.toLowerCase();
|
|
return (
|
|
lower.includes('could not write index') ||
|
|
(lower.includes('unable to create') && lower.includes('index.lock')) ||
|
|
lower.includes('index.lock')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Attempt to remove a stale `.git/index.lock` file for the given worktree.
|
|
*
|
|
* Uses `git rev-parse --git-dir` to locate the correct `.git` directory,
|
|
* which works for both regular repositories and linked worktrees.
|
|
*
|
|
* @param worktreePath - Path to the git worktree (or main repo)
|
|
* @returns true if a lock file was found and removed, false otherwise
|
|
*/
|
|
export async function removeStaleIndexLock(worktreePath: string): Promise<boolean> {
|
|
try {
|
|
// Resolve the .git directory (handles worktrees correctly)
|
|
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
|
|
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
|
const lockFilePath = path.join(gitDir, 'index.lock');
|
|
|
|
// Check if the lock file exists
|
|
try {
|
|
await fs.access(lockFilePath);
|
|
} catch {
|
|
// Lock file does not exist — nothing to remove
|
|
return false;
|
|
}
|
|
|
|
// Remove the stale lock file
|
|
await fs.unlink(lockFilePath);
|
|
logger.info('Removed stale index.lock file', { worktreePath, lockFilePath });
|
|
return true;
|
|
} catch (err) {
|
|
logger.warn('Failed to remove stale index.lock file', {
|
|
worktreePath,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a git command with automatic retry when a stale index.lock is detected.
|
|
*
|
|
* If the command fails with an error indicating a locked index file, this
|
|
* helper will attempt to remove the stale `.git/index.lock` and retry the
|
|
* command exactly once.
|
|
*
|
|
* This is particularly useful for `git stash push` which writes to the
|
|
* index and commonly fails when a previous git operation was interrupted.
|
|
*
|
|
* @param args - Array of git command arguments
|
|
* @param cwd - Working directory to execute the command in
|
|
* @param env - Optional additional environment variables
|
|
* @returns Promise resolving to stdout output
|
|
* @throws The original error if retry also fails, or a non-lock error
|
|
*/
|
|
export async function execGitCommandWithLockRetry(
|
|
args: string[],
|
|
cwd: string,
|
|
env?: Record<string, string>
|
|
): Promise<string> {
|
|
try {
|
|
return await execGitCommand(args, cwd, env);
|
|
} catch (error: unknown) {
|
|
const err = error as { message?: string; stderr?: string };
|
|
const errorMessage = err.stderr || err.message || '';
|
|
|
|
if (!isIndexLockError(errorMessage)) {
|
|
throw error;
|
|
}
|
|
|
|
logger.info('Git command failed due to index lock, attempting cleanup and retry', {
|
|
cwd,
|
|
args: args.join(' '),
|
|
});
|
|
|
|
const removed = await removeStaleIndexLock(cwd);
|
|
if (!removed) {
|
|
// Could not remove the lock file — re-throw the original error
|
|
throw error;
|
|
}
|
|
|
|
// Retry the command once after removing the lock file
|
|
return await execGitCommand(args, cwd, env);
|
|
}
|
|
}
|