feat(sync): GitClient — async wrapper for git CLI

얇은 git CLI 래퍼. F6-L2 sync MVP 의 빌딩 블록.

- run/isRepo/hasRemote/addAll/commit/push/currentBranch
- commit() 은 "nothing to commit" 을 changed=false 로 구분 (정상 path)
- 그 외 실패는 throw, exitCode + stderr 보존
- 8 vitest cases — empty file 로 GIT_CONFIG_GLOBAL/SYSTEM 격리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-26 11:37:43 +09:00
parent 6310716fb7
commit 32c7becd47
2 changed files with 208 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
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<GitExecResult> {
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<boolean> {
const r = await this.run(['rev-parse', '--git-dir']);
return r.exitCode === 0;
}
async hasRemote(name: string = 'origin'): Promise<boolean> {
const r = await this.run(['remote', 'get-url', name]);
return r.exitCode === 0;
}
async addAll(): Promise<void> {
const r = await this.run(['add', '-A']);
if (r.exitCode !== 0) throw new Error(`git add failed: ${r.stderr}`);
}
async commit(message: string): Promise<GitCommitResult> {
// 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 push(remote: string = 'origin', branch?: string): Promise<void> {
const 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<string> {
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();
}
}