import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { DEFAULT_REPOSITORIES, createReport, inspectRepository, renderMarkdown } from './report-unmerged-branches.mjs'; function fixture(t) { const path = mkdtempSync(join(tmpdir(), 'easyai-branch-report-')); t.after(() => rmSync(path, { recursive: true, force: true })); const git = (...args) => execFileSync('git', args, { cwd: path, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); git('init', '-b', 'master'); git('config', 'user.name', '分支验收'); git('config', 'user.email', 'branch-test@example.invalid'); const commit = (file, contents, subject) => { writeFileSync(join(path, file), contents); git('add', file); git('commit', '-m', subject); return git('rev-parse', 'HEAD'); }; const base = commit('base.txt', 'base\n', 'chore: 基线'); git('checkout', '-b', 'equivalent'); const original = commit('equivalent.txt', 'integrated\n', 'fix: 原始修复'); git('checkout', 'master'); git('cherry-pick', '--no-commit', original); git('commit', '-m', 'fix: 等价集成修复'); const target = git('rev-parse', 'HEAD'); assert.notEqual(original, target); git('update-ref', 'refs/remotes/origin/master', target); git('update-ref', 'refs/remotes/origin/equivalent', original); git('update-ref', 'refs/remotes/origin/merged', base); git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master'); git('checkout', '-b', 'feature'); const unmerged = commit('feature.txt', 'new\n', 'feat: 未合入能力'); git('update-ref', 'refs/remotes/origin/feature', unmerged); return { path, git, original, target, unmerged, repository: { name: 'web', label: '前端', path, targetBranch: 'master' } }; } test('默认前端发布和分支检查均以 master 为主分支', () => { assert.equal(DEFAULT_REPOSITORIES.find(x => x.name === 'web').targetBranch, 'master'); }); test('报告真实未合入提交,排除已合入历史和 cherry-pick 等价补丁,且不改工作区', t => { const f = fixture(t); writeFileSync(join(f.path, 'base.txt'), '用户未提交编辑\n'); const before = f.git('status', '--porcelain'); const result = inspectRepository(f.repository); assert.equal(result.status, 'complete'); assert.equal(result.summary.checkedBranches, 3); assert.equal(result.summary.uniqueUnmergedCommits, 1); assert.equal(result.branches.find(x => x.name === 'feature').unmergedCommits[0].sha, f.unmerged); assert.equal(result.branches.find(x => x.name === 'equivalent').status, 'equivalent-integrated'); assert.equal(result.branches.find(x => x.name === 'equivalent').equivalentCommits[0].sha, f.original); assert.equal(result.branches.find(x => x.name === 'merged').status, 'merged'); assert.equal(f.git('status', '--porcelain'), before); assert.equal(f.git('rev-parse', 'HEAD'), f.unmerged); }); test('相同提交位于多个分支时,仓库提交总数去重并保留各分支', t => { const f = fixture(t); f.git('update-ref', 'refs/remotes/origin/feature-copy', f.unmerged); const result = inspectRepository(f.repository); assert.equal(result.summary.branchesNeedingReview, 2); assert.equal(result.summary.uniqueUnmergedCommits, 1); }); test('仅合并提交尚未进入主分支也标为待核对,不冒充普通功能提交', t => { const f = fixture(t); const tree = f.git('rev-parse', f.target + '^{tree}'); const merge = f.git('commit-tree', tree, '-p', f.target, '-p', f.original, '-m', 'chore: 合并历史待核对'); f.git('update-ref', 'refs/remotes/origin/integration', merge); const result = inspectRepository(f.repository); const branch = result.branches.find(x => x.name === 'integration'); assert.equal(branch.status, 'needs-review'); assert.equal(branch.unmergedCommits.length, 0); assert.equal(branch.mergeCommits[0].sha, merge); assert.match(renderMarkdown(createReport([f.repository])), /合并提交.*合并历史待核对/); }); test('主分支缺失时明确检查失败,CLI 保存证据且返回非零', t => { const f = fixture(t); const repository = { ...f.repository, targetBranch: 'missing' }; const result = createReport([repository]); assert.equal(result.status, 'incomplete'); assert.equal(result.repositories[0].errors.length, 1); assert.doesNotMatch(renderMarkdown(result), /其他远端分支无未合入改动/); const config = join(f.path, 'repos.json'), output = join(f.path, 'report'); writeFileSync(config, JSON.stringify([repository])); const cli = spawnSync(process.execPath, [fileURLToPath(new URL('./report-unmerged-branches.mjs', import.meta.url)), '--config', config, '--output-dir', output], { encoding: 'utf8' }); assert.equal(cli.status, 1); assert.equal(JSON.parse(readFileSync(join(output, 'unmerged-branches.json'), 'utf8')).status, 'incomplete'); assert.match(readFileSync(join(output, 'unmerged-branches.md'), 'utf8'), /检查失败/); }); test('fetch 失败不能把旧 refs 当成本日检查成功', t => { const f = fixture(t); const result = inspectRepository(f.repository, { fetch: true }); assert.equal(result.status, 'incomplete'); assert.equal(result.summary.checkedBranches, 0); assert.match(result.errors[0].message, /origin/); });