import { Readable } from 'node:stream'; import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; import { collectGitContext, parseProjectName, sanitizeRemoteUrl } from '../../src/session/git-context'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; function fakeProcess(stdout: string, exitCode = 0): KaosProcess { return { stdin: { write: () => true, end: () => {} } as never, stdout: Readable.from([stdout]), stderr: Readable.from(['']), pid: 1, exitCode, wait: async () => exitCode, kill: async () => {}, dispose: async () => {}, }; } /** Scripted git output keyed by the git subcommand (`args[3]`). */ type GitScript = Record; function gitKaos(script: GitScript): Kaos { return createFakeKaos({ exec: async (...args: string[]): Promise => { const subcommand = args[3] ?? ''; const scripted = script[subcommand]; if (scripted === undefined) return fakeProcess('', 1); return fakeProcess(scripted.stdout, scripted.exitCode ?? 0); }, }); } describe('collectGitContext', () => { it('returns an empty string when the directory is not a git repository', async () => { const kaos = gitKaos({ 'rev-parse': { stdout: '', exitCode: 1 } }); expect(await collectGitContext(kaos, '/project')).toBe(''); }); it('builds a git-context block with all sections', async () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'https://github.com/acme/widgets.git' }, branch: { stdout: 'main' }, status: { stdout: ' M src/a.ts\n?? src/b.ts' }, log: { stdout: 'abc123 first commit\ndef456 second commit' }, }); const block = await collectGitContext(kaos, '/project'); expect(block.startsWith('\n')).toBe(true); expect(block.endsWith('\n')).toBe(true); expect(block).toContain('Working directory: /project'); expect(block).toContain('Remote: https://github.com/acme/widgets.git'); expect(block).toContain('Project: acme/widgets'); expect(block).toContain('Branch: main'); expect(block).toContain('Dirty files (2):'); expect(block).toContain(' ?? src/b.ts'); expect(block).toContain('Recent commits:'); expect(block).toContain(' abc123 first commit'); }); it('caps dirty files at 20 and reports the remainder', async () => { const dirty = Array.from({ length: 25 }, (_, i) => ` M src/f${String(i)}.ts`).join('\n'); const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, status: { stdout: dirty }, }); const block = await collectGitContext(kaos, '/project'); expect(block).toContain('Dirty files (25):'); expect(block).toContain(' ... and 5 more'); }); it('returns an empty string when only the working directory is known', async () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' } }); expect(await collectGitContext(kaos, '/project')).toBe(''); }); it('omits both Remote and Project for a disallowed remote host', async () => { const kaos = gitKaos({ 'rev-parse': { stdout: 'true' }, remote: { stdout: 'git@internal.corp:secret/repo.git' }, branch: { stdout: 'main' }, }); const block = await collectGitContext(kaos, '/project'); expect(block).not.toContain('Remote:'); expect(block).not.toContain('Project:'); expect(block).not.toContain('secret/repo'); expect(block).toContain('Branch: main'); }); it('treats a hanging git command as a failure (timeout)', async () => { vi.useFakeTimers(); try { const kaos = createFakeKaos({ exec: async (): Promise => { let release: (code: number) => void = () => {}; const exited = new Promise((resolve) => { release = resolve; }); return { stdin: { write: () => true, end: () => {} } as never, stdout: Readable.from(['']), stderr: Readable.from(['']), pid: 1, exitCode: null, wait: () => exited, // A real process exits once it receives SIGKILL. kill: async () => { release(137); }, dispose: async () => {}, }; }, }); const promise = collectGitContext(kaos, '/project'); await vi.advanceTimersByTimeAsync(6_000); expect(await promise).toBe(''); } finally { vi.useRealTimers(); } }); }); describe('sanitizeRemoteUrl', () => { it('strips credentials from an allowed HTTPS host', () => { expect(sanitizeRemoteUrl('https://user:pass@github.com/acme/widgets.git')).toBe( 'https://github.com/acme/widgets.git', ); }); it('passes through an allowed SSH host', () => { expect(sanitizeRemoteUrl('git@github.com:acme/widgets.git')).toBe( 'git@github.com:acme/widgets.git', ); }); it('rejects an unrecognized HTTPS host', () => { expect(sanitizeRemoteUrl('https://internal.corp/acme/widgets.git')).toBeNull(); }); it('rejects an unrecognized SSH host', () => { expect(sanitizeRemoteUrl('git@internal.corp:acme/widgets.git')).toBeNull(); }); it('passes through the SourceHut git host', () => { expect(sanitizeRemoteUrl('git@git.sr.ht:~user/repo')).toBe('git@git.sr.ht:~user/repo'); }); }); describe('parseProjectName', () => { it('extracts owner/repo from an SSH URL', () => { expect(parseProjectName('git@github.com:acme/widgets.git')).toBe('acme/widgets'); }); it('extracts owner/repo from an HTTPS URL', () => { expect(parseProjectName('https://github.com/acme/widgets.git')).toBe('acme/widgets'); }); it('extracts owner/repo from an HTTPS URL without a .git suffix', () => { expect(parseProjectName('https://gitee.com/acme/widgets')).toBe('acme/widgets'); }); it('keeps the full namespace for nested GitLab groups (HTTPS)', () => { expect(parseProjectName('https://gitlab.com/group/subgroup/repo.git')).toBe( 'group/subgroup/repo', ); }); it('keeps the full namespace for nested GitLab groups (SSH)', () => { expect(parseProjectName('git@gitlab.com:group/subgroup/repo.git')).toBe('group/subgroup/repo'); }); });