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:
79
src/main/services/GitClient.ts
Normal file
79
src/main/services/GitClient.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
129
tests/unit/GitClient.test.ts
Normal file
129
tests/unit/GitClient.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync, closeSync, openSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { GitClient } from '@main/services/GitClient.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Empty file used as GIT_CONFIG_GLOBAL/SYSTEM to isolate from the user's git config.
|
||||
let emptyConfigPath: string;
|
||||
let emptyConfigDir: string;
|
||||
|
||||
function gitEnv(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: emptyConfigPath,
|
||||
GIT_CONFIG_SYSTEM: emptyConfigPath,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@inkling.local',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@inkling.local'
|
||||
};
|
||||
}
|
||||
|
||||
async function initRepo(dir: string) {
|
||||
const env = gitEnv();
|
||||
await execFileAsync('git', ['init', '-b', 'main', dir], { env });
|
||||
await execFileAsync('git', ['-C', dir, 'config', 'user.email', 'test@inkling.local'], { env });
|
||||
await execFileAsync('git', ['-C', dir, 'config', 'user.name', 'Test'], { env });
|
||||
// Initial commit so HEAD exists.
|
||||
writeFileSync(join(dir, 'init.txt'), 'init');
|
||||
await execFileAsync('git', ['-C', dir, 'add', '-A'], { env });
|
||||
await execFileAsync('git', ['-C', dir, 'commit', '-m', 'initial'], { env });
|
||||
}
|
||||
|
||||
describe('GitClient', () => {
|
||||
let dir: string;
|
||||
let prevEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create an empty file for GIT_CONFIG_GLOBAL/SYSTEM to neutralize the user's global git config.
|
||||
emptyConfigDir = mkdtempSync(join(tmpdir(), 'gc-cfg-'));
|
||||
emptyConfigPath = join(emptyConfigDir, 'empty.gitconfig');
|
||||
closeSync(openSync(emptyConfigPath, 'w')); // touch
|
||||
// Stash and replace process.env so child gits inherit isolated config (GitClient uses default env).
|
||||
prevEnv = { ...process.env };
|
||||
Object.assign(process.env, {
|
||||
GIT_CONFIG_GLOBAL: emptyConfigPath,
|
||||
GIT_CONFIG_SYSTEM: emptyConfigPath,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@inkling.local',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@inkling.local'
|
||||
});
|
||||
dir = mkdtempSync(join(tmpdir(), 'gc-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(emptyConfigDir, { recursive: true, force: true });
|
||||
// Restore env exactly.
|
||||
for (const k of Object.keys(process.env)) delete process.env[k];
|
||||
Object.assign(process.env, prevEnv);
|
||||
});
|
||||
|
||||
it('isRepo returns false when dir is not a repo', async () => {
|
||||
const g = new GitClient(dir);
|
||||
expect(await g.isRepo()).toBe(false);
|
||||
});
|
||||
|
||||
it('isRepo returns true after git init', async () => {
|
||||
await initRepo(dir);
|
||||
const g = new GitClient(dir);
|
||||
expect(await g.isRepo()).toBe(true);
|
||||
});
|
||||
|
||||
it('hasRemote returns false when no remote', async () => {
|
||||
await initRepo(dir);
|
||||
const g = new GitClient(dir);
|
||||
expect(await g.hasRemote()).toBe(false);
|
||||
});
|
||||
|
||||
it('hasRemote returns true after remote add', async () => {
|
||||
await initRepo(dir);
|
||||
const remoteDir = mkdtempSync(join(tmpdir(), 'gc-remote-'));
|
||||
try {
|
||||
await execFileAsync('git', ['init', '--bare', remoteDir], { env: gitEnv() });
|
||||
await execFileAsync('git', ['-C', dir, 'remote', 'add', 'origin', remoteDir], { env: gitEnv() });
|
||||
const g = new GitClient(dir);
|
||||
expect(await g.hasRemote()).toBe(true);
|
||||
} finally {
|
||||
rmSync(remoteDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('commit returns changed=false when nothing to commit', async () => {
|
||||
await initRepo(dir);
|
||||
const g = new GitClient(dir);
|
||||
const r = await g.commit('test');
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.sha).toBeNull();
|
||||
});
|
||||
|
||||
it('commit returns changed=true with sha when something to commit', async () => {
|
||||
await initRepo(dir);
|
||||
writeFileSync(join(dir, 'new.txt'), 'hi');
|
||||
const g = new GitClient(dir);
|
||||
await g.addAll();
|
||||
const r = await g.commit('test commit');
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.sha).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it('currentBranch returns the branch name', async () => {
|
||||
await initRepo(dir);
|
||||
const g = new GitClient(dir);
|
||||
const branch = await g.currentBranch();
|
||||
expect(branch).toBe('main');
|
||||
});
|
||||
|
||||
it('run returns non-zero exit code without throwing for invalid args', async () => {
|
||||
await initRepo(dir);
|
||||
const g = new GitClient(dir);
|
||||
const r = await g.run(['this-is-not-a-real-git-subcommand']);
|
||||
expect(r.exitCode).not.toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user