/** * test/http/git-log.test.ts (W3 quick-wins d) — recent-commit log parsing + getGitLog. * * Two layers: * 1. parseGitLog fed canned NUL-record/US-field output — the deterministic core * (empty → []; malformed record skipped; subject with tabs/newlines intact; * subject truncated at the cap; truncated flag when records === max). * 2. getGitLog against a REAL throwaway git repo in os.tmpdir (newest-first, * n-clamping + truncated flag). Mirrors test/http/diff.test.ts. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import os from 'node:os' import path from 'node:path' import fs from 'node:fs/promises' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { parseGitLog, getGitLog, clampLogCount, GIT_LOG_MAX, GIT_LOG_DEFAULT, } from '../../src/http/git-log.js' const execFileAsync = promisify(execFile) const US = '\x1f' const NUL = '\x00' /** Build one NUL-terminated record: hash US ct US subject. */ function rec(hash: string, ct: number, subject: string): string { return `${hash}${US}${ct}${US}${subject}${NUL}` } // ── clampLogCount ───────────────────────────────────────────────────────────── describe('clampLogCount', () => { it('defaults to GIT_LOG_DEFAULT for missing / non-numeric', () => { expect(clampLogCount(undefined)).toBe(GIT_LOG_DEFAULT) expect(clampLogCount('abc')).toBe(GIT_LOG_DEFAULT) expect(clampLogCount(NaN)).toBe(GIT_LOG_DEFAULT) }) it('clamps to [1, GIT_LOG_MAX]', () => { expect(clampLogCount(0)).toBe(1) expect(clampLogCount(-5)).toBe(1) expect(clampLogCount(999)).toBe(GIT_LOG_MAX) expect(clampLogCount(GIT_LOG_MAX)).toBe(GIT_LOG_MAX) }) it('parses a numeric string and floors it', () => { expect(clampLogCount('3')).toBe(3) expect(clampLogCount(3.9)).toBe(3) }) }) // ── parseGitLog (pure) ──────────────────────────────────────────────────────── describe('parseGitLog', () => { it('returns [] for empty stdout', () => { expect(parseGitLog('', 20)).toEqual({ commits: [], truncated: false }) }) it('parses one record into hash / at(ms) / subject', () => { const out = parseGitLog(rec('abc1234', 1700000000, 'first commit'), 20) expect(out.commits).toHaveLength(1) expect(out.commits[0]).toEqual({ hash: 'abc1234', at: 1700000000 * 1000, subject: 'first commit', }) expect(out.truncated).toBe(false) }) it('preserves subjects containing tabs and newlines (US/NUL delimiters)', () => { const nasty = 'fix:\ttabbed\nand newlined' const out = parseGitLog(rec('h1', 1700000000, nasty), 20) expect(out.commits[0]?.subject).toBe(nasty) }) it('skips a malformed record missing a field', () => { const good = rec('h1', 1700000000, 'ok') const bad = `h2${US}onlytwo${NUL}` // only 2 fields const out = parseGitLog(good + bad, 20) expect(out.commits.map((c) => c.hash)).toEqual(['h1']) }) it('skips a record with a non-numeric timestamp', () => { const out = parseGitLog(rec('h1', NaN as unknown as number, 'x') + rec('h2', 1700000001, 'y'), 20) expect(out.commits.map((c) => c.hash)).toEqual(['h2']) }) it('truncates an over-long subject at the cap', () => { const long = 'x'.repeat(1000) const out = parseGitLog(rec('h1', 1700000000, long), 20) expect(out.commits[0]?.subject.length).toBe(500) }) it('sets truncated when records === max (there may be more)', () => { const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b') + rec('h3', 3, 'c') const out = parseGitLog(stdout, 3) expect(out.commits).toHaveLength(3) expect(out.truncated).toBe(true) }) it('does NOT set truncated when fewer records than max', () => { const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b') const out = parseGitLog(stdout, 3) expect(out.truncated).toBe(false) }) }) // ── getGitLog (integration against a real git repo) ────────────────────────── async function git(cwd: string, ...args: string[]): Promise { await execFileAsync('git', args, { cwd }) } describe('getGitLog (real git repo)', () => { let repo: string beforeAll(async () => { repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitlog-')) await git(repo, 'init', '-q', '-b', 'main') await git(repo, 'config', 'user.email', 'test@example.com') await git(repo, 'config', 'user.name', 'Test') await git(repo, 'config', 'commit.gpgsign', 'false') for (const [file, msg] of [ ['a.txt', 'first'], ['b.txt', 'second'], ['c.txt', 'third'], ]) { await fs.writeFile(path.join(repo, file), `${file}\n`) await git(repo, 'add', '.') await git(repo, 'commit', '-q', '-m', msg) } }) afterAll(async () => { await fs.rm(repo, { recursive: true, force: true }) }) it('returns 3 commits newest-first', async () => { const out = await getGitLog(repo, { timeoutMs: 5000 }) expect(out.commits).toHaveLength(3) expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second', 'first']) expect(out.truncated).toBe(false) // hashes are short + non-empty; at is a plausible ms timestamp for (const c of out.commits) { expect(c.hash.length).toBeGreaterThan(0) expect(c.at).toBeGreaterThan(0) } }) it('clamps n and flags truncated when asking for fewer than exist', async () => { const out = await getGitLog(repo, { n: 2, timeoutMs: 5000 }) expect(out.commits).toHaveLength(2) expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second']) expect(out.truncated).toBe(true) }) it('returns an empty result for a non-git directory (best-effort, no throw)', async () => { const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-')) const out = await getGitLog(plain, { timeoutMs: 5000 }) expect(out).toEqual({ commits: [], truncated: false }) await fs.rm(plain, { recursive: true, force: true }) }) }) // ── w6/G4 unpushed marking ───────────────────────────────────────────────────── // The marks come from `rev-list`, never from row position — see markUnpushed. describe('getGitLog — unpushed marking (w6 G4)', { timeout: 30_000 }, () => { async function git(cwd: string, args: string[]): Promise { const { stdout } = await execFileAsync('git', args, { cwd }) return stdout } async function initRepo(cwd: string): Promise { await fs.mkdir(cwd, { recursive: true }) await git(cwd, ['init', '-q', '-b', 'main']) await git(cwd, ['config', 'user.email', 't@t.local']) await git(cwd, ['config', 'user.name', 'tester']) await git(cwd, ['config', 'commit.gpgsign', 'false']) } async function commit(cwd: string, file: string, msg: string): Promise { await fs.writeFile(path.join(cwd, file), `${file}\n`, 'utf8') await git(cwd, ['add', '.']) await git(cwd, ['commit', '-q', '-m', msg]) } it('marks nothing and names no upstream when the branch tracks nothing', async () => { const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-noup-'))) await initRepo(repo) await commit(repo, 'a.txt', 'one') const res = await getGitLog(repo, { timeoutMs: 20_000 }) expect(res.commits.length).toBe(1) expect(res.upstream).toBeUndefined() expect(res.commits.every((c) => c.unpushed !== true)).toBe(true) }) it('marks only the commits absent from the upstream and names it', async () => { const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-up-'))) const bare = path.join(root, 'bare.git') await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare]) const repo = path.join(root, 'work') await initRepo(repo) await commit(repo, 'pushed.txt', 'pushed one') await git(repo, ['remote', 'add', 'origin', bare]) await git(repo, ['push', '-q', '-u', 'origin', 'main']) await commit(repo, 'local1.txt', 'local one') await commit(repo, 'local2.txt', 'local two') const res = await getGitLog(repo, { timeoutMs: 20_000 }) expect(res.upstream).toBe('origin/main') const bySubject = new Map(res.commits.map((c) => [c.subject, c.unpushed === true])) expect(bySubject.get('local two')).toBe(true) expect(bySubject.get('local one')).toBe(true) expect(bySubject.get('pushed one')).toBe(false) }) it('marks an unpushed commit that sorts BELOW a pushed one (merged older branch)', async () => { const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-merge-'))) const bare = path.join(root, 'bare.git') await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare]) const repo = path.join(root, 'work') await initRepo(repo) await commit(repo, 'base.txt', 'base') await git(repo, ['remote', 'add', 'origin', bare]) await git(repo, ['push', '-q', '-u', 'origin', 'main']) // A side branch whose commit is BACKDATED so date-ordered `git log` puts it // below the newer pushed tip — the exact shape "first N rows" gets wrong. await git(repo, ['checkout', '-q', '-b', 'side']) const old = '2001-01-01T00:00:00' await fs.writeFile(path.join(repo, 'old.txt'), 'old\n', 'utf8') await git(repo, ['add', '.']) await execFileAsync( 'git', ['commit', '-q', '-m', 'backdated side work', '--date', old], { cwd: repo, env: { ...process.env, GIT_COMMITTER_DATE: old } }, ) await git(repo, ['checkout', '-q', 'main']) await git(repo, ['merge', '-q', '--no-ff', '-m', 'merge side', 'side']) const res = await getGitLog(repo, { timeoutMs: 20_000 }) const side = res.commits.find((c) => c.subject === 'backdated side work') expect(side).toBeDefined() expect(side!.unpushed).toBe(true) // And it really is out of order, so the test is testing what it claims. const idxSide = res.commits.findIndex((c) => c.subject === 'backdated side work') const idxBase = res.commits.findIndex((c) => c.subject === 'base') expect(idxSide).toBeGreaterThan(idxBase) expect(res.commits[idxBase]!.unpushed).not.toBe(true) }) })