import { describe, it, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js'; // --- helpers --- function makeTmpWorkspace() { const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-')); fs.mkdirSync(path.join(ws, 'repo'), { recursive: true }); return ws; } function makeActionSource() { const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'git-source-')); for (const relPath of SYNC_PATHS) { const fullPath = path.join(sourceRoot, relPath); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, relPath); } return sourceRoot; } // Default stub: all commands succeed, status returns changes function makeSpawn(overrides = {}) { const calls = []; const spawn = (cmd, args, opts) => { const key = args[0]; calls.push({ cmd, args, opts }); if (overrides[key]) return overrides[key](args, opts); if (key === 'status') return { status: 0, stdout: 'M .gitea/ai-review/findings.json', stderr: '', error: null }; if (key === 'commit') return { status: 0, stdout: '[feature-branch abc1234] chore', stderr: '', error: null }; return { status: 0, stdout: '', stderr: '', error: null }; }; spawn.calls = calls; return spawn; } describe('commitAndPush', () => { let workspace; let sourceRoot; before(() => { workspace = makeTmpWorkspace(); }); after(() => { fs.rmSync(workspace, { recursive: true, force: true }); }); before(() => { sourceRoot = makeActionSource(); }); after(() => { fs.rmSync(sourceRoot, { recursive: true, force: true }); }); beforeEach(() => { for (const f of fs.readdirSync(workspace)) { if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f)); } }); it('does not embed token in any git command argument', async () => { const spawn = makeSpawn(); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); for (const { args } of spawn.calls) { assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`); } }); it('tags auto commits with the bot marker for workflow filtering', async () => { const spawn = makeSpawn(); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); const commitCall = spawn.calls.find(c => c.args[0] === 'commit'); assert.ok(commitCall, 'expected git commit to run'); assert.ok(commitCall.args.some(arg => arg.includes(BOT_COMMIT_MARKER)), 'expected commit message to include bot marker'); }); it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => { const spawn = makeSpawn(); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); const networkOps = ['fetch', 'push', 'clone']; const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0])); assert.ok(networkCalls.length > 0, 'expected at least one network git call'); for (const { args, opts } of networkCalls) { assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`); } }); it('cleans up askpass script after successful run', async () => { await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot); const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh')); assert.equal(leftover.length, 0, 'askpass script was not cleaned up'); }); it('cleans up askpass script even when git fails', async () => { const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null }); await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot); const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh')); assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure'); }); it('skips commit when status shows no changes', async () => { const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) }); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); const commitCalled = spawn.calls.some(c => c.args[0] === 'commit'); assert.equal(commitCalled, false, 'commit should not run when there are no changes'); }); it('adds skill and entry files together with findings', async () => { const repoDir = path.join(workspace, 'repo'); fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true }); fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n'); fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n'); fs.mkdirSync(path.join(repoDir, '.gitea/ai-review'), { recursive: true }); fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n'); fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n'); const spawn = makeSpawn(); await commitAndPush(workspace, repoDir, spawn, sourceRoot); const addCalls = spawn.calls.filter(c => c.args[0] === 'add'); const skillAddCall = addCalls.find(c => c.args.includes('.github/skills/triage-findings/SKILL.md')); const generatedAddCall = addCalls.find(c => c.args.includes('.gitea/ai-review/exclusions.json')); assert.ok(skillAddCall, 'expected git add for synced skill files'); assert.ok(generatedAddCall, 'expected git add for generated review files'); assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml')); assert.ok(skillAddCall.args.includes('.claude/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.gemini/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md')); assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md')); assert.ok(skillAddCall.args.includes('CLAUDE.md')); assert.ok(skillAddCall.args.includes('GEMINI.md')); assert.ok(!skillAddCall.args.includes('README.md')); assert.ok(generatedAddCall.args.includes('.gitea/ai-review/findings.json')); assert.ok(generatedAddCall.args.includes('.gitea/ai-review/exclusions.json')); }); it('keeps repo copies when the source sync file is missing', async () => { const missingPath = path.join(sourceRoot, '.amazonq/rules/triage-findings.md'); fs.rmSync(missingPath, { force: true }); const repoPath = path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md'); fs.writeFileSync(repoPath, 'stale'); const spawn = makeSpawn(); await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); const rmCall = spawn.calls.find(c => c.args[0] === 'rm'); assert.equal(rmCall, undefined, 'git rm should not run for missing source files'); assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale'); }); it('overwrites existing repo copies with workspace files', async () => { const repoDir = path.join(workspace, 'repo'); fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale'); await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot); assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md'); assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md'); }); it('does not throw when git command fails', async () => { const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null }); await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot)); }); it('logs push failures separately from commit failures', async () => { const repoDir = path.join(workspace, 'repo'); fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true }); fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n'); fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n'); fs.mkdirSync(path.join(repoDir, '.gitea/ai-review'), { recursive: true }); fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n'); fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n'); const spawn = makeSpawn({ push: () => ({ status: 1, stdout: '', stderr: 'remote: error: pre-receive hook declined', error: null }), }); const logs = []; const originalLog = console.log; console.log = (...args) => { logs.push(args.join(' ')); }; try { await commitAndPush(workspace, repoDir, spawn, sourceRoot); } finally { console.log = originalLog; } assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗'))); assert.ok(logs.some(line => line.includes('pre-receive hook declined'))); }); }); describe('cloneRepo', () => { let workspace; before(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'clone-test-')); }); after(() => { fs.rmSync(workspace, { recursive: true, force: true }); }); it('clones repo when repoDir does not exist', () => { const spawn = makeSpawn(); cloneRepo(workspace, spawn); const cloneCalled = spawn.calls.some(c => c.args[0] === 'clone'); assert.ok(cloneCalled, 'expected git clone to be called'); }); it('fetches and checks out when repoDir already exists', () => { const repoDir = path.join(workspace, 'repo'); fs.mkdirSync(repoDir, { recursive: true }); const spawn = makeSpawn(); cloneRepo(workspace, spawn); const cloneCalled = spawn.calls.some(c => c.args[0] === 'clone'); const fetchCalled = spawn.calls.some(c => c.args[0] === 'fetch'); assert.ok(!cloneCalled, 'clone should not run when repoDir exists'); assert.ok(fetchCalled, 'fetch should run when repoDir exists'); }); it('does not embed token in any git command argument', () => { const spawn = makeSpawn(); cloneRepo(workspace, spawn); for (const { args } of spawn.calls) { assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`); } }); it('uses GIT_ASKPASS for network operations', () => { const spawn = makeSpawn(); cloneRepo(workspace, spawn); const networkCalls = spawn.calls.filter(c => ['clone', 'fetch'].includes(c.args[0])); assert.ok(networkCalls.length > 0, 'expected at least one network git call'); for (const { args, opts } of networkCalls) { assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`); } }); it('cleans up askpass script after run', () => { const spawn = makeSpawn(); cloneRepo(workspace, spawn); const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh')); assert.equal(leftover.length, 0, 'askpass script was not cleaned up'); }); it('returns repoDir path', () => { const spawn = makeSpawn(); const result = cloneRepo(workspace, spawn); assert.equal(result, path.join(workspace, 'repo')); }); it('reads head commit message and detects bot auto commits', () => { const spawn = makeSpawn({ show: () => ({ status: 0, stdout: `chore: update ai-review findings ${BOT_COMMIT_MARKER}\n`, stderr: '', error: null }), }); assert.ok(getHeadCommitMessage(workspace, spawn).includes(BOT_COMMIT_MARKER)); assert.equal(isBotAutoCommit(workspace, spawn), true); }); });