src/workspace/git.ts
Rama main · réplica de lectura del repositorio original.
import { spawn } from 'node:child_process';
import { bounded, type Trace } from '../harness/trace.ts';
import { terminateProcessTree } from '../platform/process.ts';
export interface CommandResult { exitCode: number; stdout: string; stderr: string; timedOut: boolean }
export function command(cwd: string, executable: string, args: string[], env?: NodeJS.ProcessEnv, timeoutMs = 300_000, signal?: AbortSignal): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { cwd, env: env ?? process.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
let termination: Promise<void> | undefined;
const abort = () => { if (child.pid) termination ??= terminateProcessTree(child.pid); };
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
let stdout = '', stderr = '', timedOut = false;
const timer = setTimeout(() => { timedOut = true; abort(); }, timeoutMs);
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
child.on('error', error => { signal?.removeEventListener('abort', abort); clearTimeout(timer); reject(error); });
child.on('close', code => { signal?.removeEventListener('abort', abort); clearTimeout(timer); if (signal?.aborted) { reject(signal.reason); return; } resolve({ exitCode: code ?? -1, stdout, stderr, timedOut }); });
});
}
export async function git(cwd: string, args: string[], trace?: Trace, env?: NodeJS.ProcessEnv, allowFailure = false) {
const result = await command(cwd, 'git', args, env);
if (trace) {
// Bounded: paginated tool reads (git_diff) re-run the same large command per page; the
// trace keeps facts plus an excerpt, never the full repeated payload.
const stdout = bounded(result.stdout), stderr = bounded(result.stderr);
trace.emit('git', { args, exitCode: result.exitCode, timedOut: result.timedOut,
truncated: stdout.truncated || stderr.truncated,
stdout_chars: stdout.chars, stdout: stdout.excerpt, stdout_sha256: stdout.truncated ? stdout.sha256 : undefined,
stderr_chars: stderr.chars, stderr: stderr.excerpt });
}
if (result.exitCode !== 0 && !allowFailure) throw new Error(`Git command failed: ${args[0]} (exit ${result.exitCode})`);
return result;
}
export function gitAuth(token: string, remoteUrl = 'https://github.com', username = 'x-access-token', scopeUrl?: string): NodeJS.ProcessEnv {
const origin = new URL(remoteUrl);
if (!['http:', 'https:'].includes(origin.protocol) || origin.username || origin.password || origin.search || origin.hash) throw new Error('Git remote URL is unsafe for authenticated transport');
let scope = `${origin.protocol}//${origin.host}`;
if (scopeUrl) {
const candidate = new URL(scopeUrl);
if (!['http:', 'https:'].includes(candidate.protocol) || candidate.origin !== origin.origin || candidate.username || candidate.password || candidate.search || candidate.hash) {
throw new Error('Git credential scope crossed the remote origin');
}
const path = candidate.pathname.replace(/\/+$/, '');
if (path && origin.pathname !== path && !origin.pathname.startsWith(`${path}/`)) throw new Error('Git credential scope does not contain the remote URL');
scope += path;
}
return { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: `http.${scope}/.extraheader`,
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`${username}:${token}`).toString('base64')}` };
}