import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); export interface GitExecResult { stdout: string; stderr: string; exitCode: number; } export interface GitCommitResult { changed: boolean; // false if "nothing to commit" sha: string | null; } export class GitClient { constructor(private cwd: string, private timeoutMs: number = 60_000) {} async run(args: string[]): Promise { try { const { stdout, stderr } = await execFileAsync( 'git', ['-C', this.cwd, ...args], { timeout: this.timeoutMs, maxBuffer: 10 * 1024 * 1024 } ); return { stdout, stderr, exitCode: 0 }; } catch (e) { const err = e as { stdout?: string; stderr?: string; code?: number }; return { stdout: err.stdout ?? '', stderr: err.stderr ?? String(e), exitCode: typeof err.code === 'number' ? err.code : -1 }; } } async isRepo(): Promise { const r = await this.run(['rev-parse', '--git-dir']); return r.exitCode === 0; } async hasRemote(name: string = 'origin'): Promise { const r = await this.run(['remote', 'get-url', name]); return r.exitCode === 0; } async addAll(): Promise { const r = await this.run(['add', '-A']); if (r.exitCode !== 0) throw new Error(`git add failed: ${r.stderr}`); } async commit(message: string): Promise { // Detect "nothing to commit" via stderr/stdout rather than treating as a thrown error. const r = await this.run(['commit', '-m', message]); if (r.exitCode === 0) { const sha = await this.run(['rev-parse', 'HEAD']); return { changed: true, sha: sha.stdout.trim() }; } const blob = (r.stdout + '\n' + r.stderr).toLowerCase(); const nothingToCommit = /nothing to commit|nothing added to commit|no changes added to commit/i.test(blob); if (nothingToCommit) { return { changed: false, sha: null }; } throw new Error(`git commit failed: ${r.stderr || r.stdout}`); } async hasUpstream(): Promise { const r = await this.run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); return r.exitCode === 0; } async push(remote: string = 'origin', branch?: string): Promise { // If no upstream is configured, set one with -u so subsequent `git push` works. const upstream = await this.hasUpstream(); let args: string[]; if (!upstream) { const targetBranch = branch ?? (await this.currentBranch()); args = ['push', '-u', remote, targetBranch]; } else { args = branch ? ['push', remote, branch] : ['push', remote]; } const r = await this.run(args); if (r.exitCode !== 0) throw new Error(`git push failed: ${r.stderr}`); } async currentBranch(): Promise { const r = await this.run(['rev-parse', '--abbrev-ref', 'HEAD']); if (r.exitCode !== 0) throw new Error(`git rev-parse failed: ${r.stderr}`); return r.stdout.trim(); } async fetch(remote: string = 'origin'): Promise { return this.run(['fetch', remote]); } async rebaseOnto(ref: string): Promise { return this.run(['rebase', ref]); } async rebaseAbort(): Promise { return this.run(['rebase', '--abort']); } async hasUncommittedChanges(): Promise { const r = await this.run(['status', '--porcelain']); return r.stdout.trim().length > 0; } /** ref (branch, tag, remote branch) 존재 여부 확인. `git rev-parse --verify`. */ async refExists(ref: string): Promise { const r = await this.run(['rev-parse', '--verify', ref]); return r.exitCode === 0; } /** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */ async listConflicts(): Promise { const r = await this.run(['diff', '--name-only', '--diff-filter=U']); return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0); } }