import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { getStatus, resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js'; const tempDirs = []; const createTempDir = () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-')); tempDirs.push(dir); return dir; }; const runGit = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); const canRunGit = () => { try { execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } }; afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); describe('resolveBaseRefForLog', () => { it('returns the local ref unchanged when it exists, even if origin also exists', async () => { // Both local 'main' and 'refs/remotes/origin/main' are present. // The local ref takes precedence — callers that ask for 'main' get 'main'. const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main'; expect(await resolveBaseRefForLog('main', checkRef)).toBe('main'); }); it('falls back to origin/ when local ref cannot be resolved but origin can', async () => { // Local 'main' is absent (e.g. user never checked it out), but origin/main exists. const checkRef = async (ref) => ref === 'refs/remotes/origin/main'; expect(await resolveBaseRefForLog('main', checkRef)).toBe('origin/main'); }); it('returns the original ref when neither local nor origin ref can be resolved', async () => { // Neither ref exists; return as-is so git surfaces a meaningful error. const checkRef = async () => false; expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch'); }); it('returns undefined when from is undefined', async () => { const checkRef = async () => true; expect(await resolveBaseRefForLog(undefined, checkRef)).toBeUndefined(); }); it('returns undefined when from is an empty string', async () => { const checkRef = async () => true; expect(await resolveBaseRefForLog('', checkRef)).toBeUndefined(); }); it('returns undefined when from is a whitespace-only string', async () => { const checkRef = async () => true; expect(await resolveBaseRefForLog(' ', checkRef)).toBeUndefined(); }); }); describe('git index path validation', () => { it('rejects stage paths outside the repository before invoking git', async () => { await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt'); }); it('rejects unstage paths outside the repository before invoking git', async () => { await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt'); }); }); describe('getStatus', () => { it('handles repositories without upstream tracking', async () => { if (!canRunGit()) { return; } const repo = createTempDir(); runGit(repo, ['init', '-b', 'main']); runGit(repo, ['config', 'user.email', 'test@example.com']); runGit(repo, ['config', 'user.name', 'Test User']); fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); runGit(repo, ['add', 'README.md']); runGit(repo, ['commit', '-m', 'Initial commit']); await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main', }); }); });