/** * test/http/git-ops.test.ts (w4-commit-push) — the git WRITE engine. * * Covers src/http/git-ops.ts: * - classifyGitError (pure: stderr → safe {status,error}, SEC-M10) * - validateRepoFiles (realpath containment: rejects escapes/absolute/flags) * - stageFiles (git add / restore --staged, real temp repos) * - commit (staged commit, empty/identity/length guards) * - push (upstream vs no-upstream argv, bare-remote real push) * * Pure functions are unit-tested; the git verbs run against real throwaway git * repos in os.tmpdir() (no network — a local bare repo is the push remote), * mirroring test/http/worktrees-create.test.ts. */ import { describe, it, expect, beforeAll } from 'vitest' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { classifyGitError, validateRepoFiles, stageFiles, commit, push, fetch as gitFetch, } from '../../src/http/git-ops.js' const execFileP = promisify(execFile) const TIMEOUT_MS = 15000 const OPTS = { timeoutMs: TIMEOUT_MS, maxFiles: 300 } let gitAvailable = false beforeAll(async () => { try { await execFileP('git', ['--version']) gitAvailable = true } catch { gitAvailable = false } }) /** Init a temp git repo with one commit; returns its REALPATH (macOS /var fix). */ async function makeRepo(): Promise { const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-'))) const git = async (args: string[]): Promise => { await execFileP('git', args, { cwd: dir }) } await git(['init', '-q', '-b', 'main']) await git(['config', 'user.email', 't@t.local']) await git(['config', 'user.name', 'tester']) await git(['config', 'commit.gpgsign', 'false']) await fs.writeFile(path.join(dir, 'tracked.txt'), 'v1\n', 'utf8') await git(['add', '.']) await git(['commit', '-q', '-m', 'init']) return dir } /** A bare repo to serve as a push `origin`. Returns its REALPATH. */ async function makeBareRemote(): Promise { const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-bare-'))) await execFileP('git', ['init', '-q', '--bare', '-b', 'main', dir]) return dir } // ── classifyGitError (pure, SEC-M10) ──────────────────────────────────────────── describe('classifyGitError', () => { const cases: Array<[string, number]> = [ ['nothing to commit, working tree clean', 409], ['no changes added to commit (use "git add")', 409], ['Please tell me who you are', 400], [' ! [rejected] main -> main (non-fast-forward)', 409], ['Updates were rejected because the tip of your current branch is behind\nfetch first', 409], ['fatal: Authentication failed for \'https://github.com/x/y.git/\'', 401], ["fatal: could not read Username for 'https://github.com': terminal prompts disabled", 401], ['git@github.com: Permission denied (publickey).', 401], ["fatal: Unable to create '/repo/.git/index.lock': File exists.", 409], ['some totally unexpected failure', 500], ] it('maps each stderr signature to the expected status', () => { for (const [stderr, status] of cases) { expect(classifyGitError(stderr).status).toBe(status) } }) it('never leaks raw git stderr (no "fatal:" in any safe message)', () => { for (const [stderr] of cases) { expect(classifyGitError(stderr).error.toLowerCase()).not.toContain('fatal:') } }) it('is case-insensitive and tolerant of a non-string input', () => { expect(classifyGitError('NOTHING TO COMMIT').status).toBe(409) // @ts-expect-error — defensive: non-string must not throw expect(classifyGitError(undefined).status).toBe(500) }) }) // ── validateRepoFiles (realpath containment) ──────────────────────────────────── describe('validateRepoFiles', () => { it('rejects an empty list', async () => { const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) expect(await validateRepoFiles(repo, [], 300)).toBeNull() }) it('rejects more than maxFiles entries', async () => { const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) expect(await validateRepoFiles(repo, ['a', 'b', 'c'], 2)).toBeNull() }) it('rejects non-string, absolute, and leading-dash entries', async () => { const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) expect(await validateRepoFiles(repo, [123], 300)).toBeNull() expect(await validateRepoFiles(repo, [''], 300)).toBeNull() expect(await validateRepoFiles(repo, ['/etc/passwd'], 300)).toBeNull() expect(await validateRepoFiles(repo, ['-rf'], 300)).toBeNull() }) it('rejects a ../ traversal escape', async () => { const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) expect(await validateRepoFiles(repo, ['../escape'], 300)).toBeNull() }) it('rejects the repo root itself (".")', async () => { const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) expect(await validateRepoFiles(repo, ['.'], 300)).toBeNull() }) it('accepts in-repo relative paths (existing AND not-yet-on-disk / deleted)', async () => { const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) await fs.writeFile(path.join(repo, 'exists.txt'), 'x', 'utf8') const ok = await validateRepoFiles(repo, ['exists.txt', 'sub/deleted.txt'], 300) expect(ok).toEqual(['exists.txt', 'sub/deleted.txt']) }) it('rejects a pre-planted symlink that escapes the repo after realpath (M2)', async () => { const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-'))) const outside = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-out-'))) await fs.writeFile(path.join(outside, 'secret.txt'), 'top', 'utf8') await fs.symlink(path.join(outside, 'secret.txt'), path.join(repo, 'link.txt')) expect(await validateRepoFiles(repo, ['link.txt'], 300)).toBeNull() }) }) // ── stageFiles (real temp repos) ──────────────────────────────────────────────── describe('stageFiles', { timeout: 30_000 }, () => { it('stages the given files (git add) and unstages them (restore --staged)', async () => { if (!gitAvailable) return const repo = await makeRepo() await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') // modify tracked await fs.writeFile(path.join(repo, 'new.txt'), 'new\n', 'utf8') // add untracked const staged = await stageFiles(repo, ['tracked.txt', 'new.txt'], true, OPTS) expect(staged).toMatchObject({ ok: true, staged: true, count: 2 }) const cached = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout expect(cached).toContain('tracked.txt') expect(cached).toContain('new.txt') const unstaged = await stageFiles(repo, ['tracked.txt'], false, OPTS) expect(unstaged).toMatchObject({ ok: true, staged: false, count: 1 }) const cached2 = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout expect(cached2).not.toContain('tracked.txt') }) it('returns 404 for a non-git directory', async () => { const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-'))) const res = await stageFiles(plain, ['x.txt'], true, OPTS) expect(res).toMatchObject({ ok: false, status: 404 }) }) it('returns 400 for an escaping file path (never runs git)', async () => { if (!gitAvailable) return const repo = await makeRepo() const res = await stageFiles(repo, ['../../etc/passwd'], true, OPTS) expect(res).toMatchObject({ ok: false, status: 400 }) }) }) // ── commit (real temp repos) ──────────────────────────────────────────────────── describe('commit', { timeout: 30_000 }, () => { it('commits staged changes and returns a short SHA', async () => { if (!gitAvailable) return const repo = await makeRepo() await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') await execFileP('git', ['add', 'tracked.txt'], { cwd: repo }) const res = await commit(repo, 'second commit', { timeoutMs: TIMEOUT_MS, maxLen: 5000 }) expect(res.ok).toBe(true) expect(typeof res.commit).toBe('string') const head = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() expect(head.startsWith(res.commit as string)).toBe(true) }) it('returns 409 when nothing is staged', async () => { if (!gitAvailable) return const repo = await makeRepo() const res = await commit(repo, 'noop', { timeoutMs: TIMEOUT_MS, maxLen: 5000 }) expect(res).toMatchObject({ ok: false, status: 409 }) expect(res.error).not.toContain('fatal:') }) it('rejects an empty message with 400 (never runs git)', async () => { if (!gitAvailable) return const repo = await makeRepo() expect(await commit(repo, '', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({ ok: false, status: 400, }) expect(await commit(repo, ' \n ', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({ ok: false, status: 400, }) }) it('rejects an over-long message with 400', async () => { if (!gitAvailable) return const repo = await makeRepo() const res = await commit(repo, 'x'.repeat(51), { timeoutMs: TIMEOUT_MS, maxLen: 50 }) expect(res).toMatchObject({ ok: false, status: 400 }) }) it('returns 400 when the author identity is unset', async () => { if (!gitAvailable) return // Init WITHOUT any local identity, then hide the global/system config so no // identity resolves — git must fail with "Please tell me who you are" → 400. const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-noid-'))) await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo }) // useConfigOnly=true stops git auto-detecting an identity from the OS user, so // with no configured identity it hard-fails "Please tell me who you are". await execFileP('git', ['config', '--local', 'user.useConfigOnly', 'true'], { cwd: repo }) await fs.writeFile(path.join(repo, 'f.txt'), 'x\n', 'utf8') await execFileP('git', ['add', 'f.txt'], { cwd: repo }) // stage on unborn HEAD const prevG = process.env['GIT_CONFIG_GLOBAL'] const prevS = process.env['GIT_CONFIG_SYSTEM'] process.env['GIT_CONFIG_GLOBAL'] = '/dev/null' process.env['GIT_CONFIG_SYSTEM'] = '/dev/null' try { const res = await commit(repo, 'msg', { timeoutMs: TIMEOUT_MS, maxLen: 5000 }) expect(res).toMatchObject({ ok: false, status: 400 }) expect(res.error).not.toContain('fatal:') } finally { if (prevG === undefined) delete process.env['GIT_CONFIG_GLOBAL'] else process.env['GIT_CONFIG_GLOBAL'] = prevG if (prevS === undefined) delete process.env['GIT_CONFIG_SYSTEM'] else process.env['GIT_CONFIG_SYSTEM'] = prevS } }) }) // ── push (real bare-repo remote, no network) ──────────────────────────────────── describe('push', { timeout: 30_000 }, () => { it('first push sets upstream (-u) and the bare remote receives the ref', async () => { if (!gitAvailable) return const repo = await makeRepo() const bare = await makeBareRemote() await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo }) const res = await push(repo, { timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' }) const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim() const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() expect(remoteHead).toBe(localHead) }) it('a second push (upstream now set) succeeds via a plain push', async () => { if (!gitAvailable) return const repo = await makeRepo() const bare = await makeBareRemote() await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo }) expect((await push(repo, { timeoutMs: TIMEOUT_MS })).ok).toBe(true) await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') await execFileP('git', ['add', 'tracked.txt'], { cwd: repo }) await execFileP('git', ['commit', '-q', '-m', 'second'], { cwd: repo }) const res = await push(repo, { timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' }) const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim() const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() expect(remoteHead).toBe(localHead) }) it('returns 400 for a detached HEAD', async () => { if (!gitAvailable) return const repo = await makeRepo() const sha = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() await execFileP('git', ['checkout', '-q', sha], { cwd: repo }) // detach const res = await push(repo, { timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: false, status: 400 }) }) it('returns 400 when the repo has zero remotes', async () => { if (!gitAvailable) return const repo = await makeRepo() const res = await push(repo, { timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: false, status: 400 }) }) it('returns 409 with two remotes and no upstream (ambiguous)', async () => { if (!gitAvailable) return const repo = await makeRepo() const bare1 = await makeBareRemote() const bare2 = await makeBareRemote() await execFileP('git', ['remote', 'add', 'origin', bare1], { cwd: repo }) await execFileP('git', ['remote', 'add', 'backup', bare2], { cwd: repo }) const res = await push(repo, { timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: false, status: 409 }) }) it('returns 404 for a non-git directory', async () => { const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-nrepo-'))) expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 }) }) }) // ── w6/G2 fetch — read-only refresh of remote-tracking refs ──────────────────── // Fetch exists so the panel can stop lying about `behind`. It touches no working // tree, no index and no branch: the only thing it may change is refs/remotes. // Same reason as the G1 block: clone + push + fetch is well past a 5 s default. describe('fetch (w6 G2)', { timeout: 30_000 }, () => { it('refuses a non-git directory without spawning git', async () => { if (!gitAvailable) return const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-'))) const res = await gitFetch(plain, { timeoutMs: TIMEOUT_MS }) expect(res.ok).toBe(false) expect(res.ok === false && res.status).toBe(404) }) it('reports a plain message — never raw git stderr — when no remote exists', async () => { if (!gitAvailable) return const repo = await makeRepo() const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS }) expect(res.ok).toBe(false) expect(res.ok === false && res.status).toBe(400) expect(res.ok === false && res.error).toBe('No remote configured.') }) it('fetches from the sole remote and leaves HEAD and the working tree untouched', async () => { if (!gitAvailable) return const bare = await makeBareRemote() const repo = await makeRepo() const git = async (args: string[]): Promise => { await execFileP('git', args, { cwd: repo }) } await git(['remote', 'add', 'origin', bare]) await git(['push', '-q', '-u', 'origin', 'main']) const headBefore = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS }) expect(res.ok).toBe(true) const headAfter = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim() expect(headAfter).toBe(headBefore) const status = (await execFileP('git', ['status', '--porcelain'], { cwd: repo })).stdout expect(status.trim()).toBe('') }) it('moves the remote-tracking ref so `behind` becomes true after fetching', async () => { if (!gitAvailable) return const bare = await makeBareRemote() const repo = await makeRepo() await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo }) await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo }) // A second clone pushes one commit; `repo` cannot know until it fetches. const other = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-other-'))) const clone = path.join(other, 'c') await execFileP('git', ['clone', '-q', bare, clone]) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: clone }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd: clone }) await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: clone }) await fs.writeFile(path.join(clone, 'remote.txt'), 'r\n', 'utf8') await execFileP('git', ['add', '.'], { cwd: clone }) await execFileP('git', ['commit', '-q', '-m', 'from elsewhere'], { cwd: clone }) await execFileP('git', ['push', '-q'], { cwd: clone }) const countBefore = ( await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo }) ).stdout.trim() expect(countBefore).toBe('0') // the stale lie the panel would have shown const res = await gitFetch(repo, { timeoutMs: TIMEOUT_MS }) expect(res.ok).toBe(true) const countAfter = ( await execFileP('git', ['rev-list', '--count', 'HEAD..@{u}'], { cwd: repo }) ).stdout.trim() expect(countAfter).toBe('1') }) it('ignores any remote or refspec the caller tries to smuggle in', async () => { if (!gitAvailable) return const bare = await makeBareRemote() const repo = await makeRepo() await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo }) await execFileP('git', ['push', '-q', '-u', 'origin', 'main'], { cwd: repo }) // The options type carries only timeoutMs; a caller passing extra keys must // not be able to steer the command at a remote of their choosing. const smuggled = { timeoutMs: TIMEOUT_MS, remote: 'file:///tmp/evil', refspec: '+refs/heads/*:refs/heads/*', } as unknown as { timeoutMs: number } const res = await gitFetch(repo, smuggled) expect(res.ok).toBe(true) expect(res.ok === true && res.remote).toBe('origin') }) })