Files
inkling/tests/unit/GitClient.test.ts
altair823 32c7becd47 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>
2026-04-26 11:37:43 +09:00

130 lines
4.5 KiB
TypeScript

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);
});
});