diff --git a/src/main/services/GitClient.ts b/src/main/services/GitClient.ts new file mode 100644 index 0000000..49da9fb --- /dev/null +++ b/src/main/services/GitClient.ts @@ -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 { + 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 push(remote: string = 'origin', branch?: string): Promise { + 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 { + 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(); + } +} diff --git a/tests/unit/GitClient.test.ts b/tests/unit/GitClient.test.ts new file mode 100644 index 0000000..132883f --- /dev/null +++ b/tests/unit/GitClient.test.ts @@ -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); + }); +});