From 3cf079f527f1add28b9b83c592b6364af062cf11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E6=B3=A2?= Date: Mon, 12 Oct 2026 20:25:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E5=A2=9E=E5=8A=A0=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E6=9C=AA=E5=90=88=E5=85=A5=E5=88=86=E6=94=AF=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=B9=B6=E4=BB=A5=20master=20=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/report-unmerged-branches.mjs | 163 ++++++++++++++++++++++ scripts/report-unmerged-branches.test.mjs | 99 +++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 scripts/report-unmerged-branches.mjs create mode 100644 scripts/report-unmerged-branches.test.mjs diff --git a/scripts/report-unmerged-branches.mjs b/scripts/report-unmerged-branches.mjs new file mode 100644 index 0000000..241d08f --- /dev/null +++ b/scripts/report-unmerged-branches.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const DEFAULT_REPOSITORIES = [ + { name: 'backend', label: '后端', path: join(homedir(), 'projects/easyai-backend'), targetBranch: 'main' }, + { name: 'web', label: '前端', path: join(homedir(), 'projects/easyai-main-web'), targetBranch: 'master' }, + { name: 'doc', label: '文档', path: join(homedir(), 'projects/easyai-doc'), targetBranch: 'master' }, + { name: 'deploy', label: '部署', path: join(homedir(), 'projects/easyai'), targetBranch: 'main' }, +]; + +function git(path, ...args) { + return execFileSync('git', args, { + cwd: path, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 120_000, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }, + }).trim(); +} + +function errorMessage(error) { + return String(error.stderr || error.message).trim().replace(/https?:\/\/[^\s/@]+@/g, 'https://[redacted]@'); +} + +function commits(path, range, filter) { + const output = git(path, 'log', filter, '--format=%H%x00%cI%x00%an%x00%s', range); + return output ? output.split('\n').map(line => { + const [sha, committedAt, author, subject] = line.split('\0'); + return { sha, shortSha: sha.slice(0, 8), committedAt, author, subject }; + }) : []; +} + +export function inspectRepository(repository, { fetch = false } = {}) { + const remote = repository.remote || 'origin'; + const targetRef = `refs/remotes/${remote}/${repository.targetBranch}`; + const result = { ...repository, remote, targetRef, branches: [], errors: [], status: 'complete' }; + try { + if (fetch) git(repository.path, 'fetch', '--prune', remote, `+refs/heads/*:refs/remotes/${remote}/*`); + result.targetSha = git(repository.path, 'rev-parse', '--verify', `${targetRef}^{commit}`); + const output = git(repository.path, 'for-each-ref', '--format=%(refname)%00%(objectname)%00%(symref)', `refs/remotes/${remote}/`); + const cache = new Map(); + const knownEquivalent = new Set(); + for (const line of output.split('\n').filter(Boolean)) { + const [ref, sha, symbolicRef] = line.split('\0'); + if (symbolicRef || ref === targetRef) continue; + const branch = { name: ref.slice(`refs/remotes/${remote}/`.length), ref, sha }; + try { + let comparison = cache.get(sha); + if (!comparison) { + const range = `${targetRef}..${ref}`; + const ahead = Number(git(repository.path, 'rev-list', '--count', range)); + comparison = { ahead, unmergedCommits: [], equivalentCommits: [], mergeCommits: [] }; + if (ahead > 0) { + const cherry = git(repository.path, 'cherry', targetRef, ref); + const equivalence = new Map(cherry.split('\n').filter(Boolean).map(line => [line.slice(2), line[0]])); + comparison.mergeCommits = commits(repository.path, range, '--merges'); + for (const commit of commits(repository.path, range, '--no-merges')) { + if (equivalence.get(commit.sha) === '-') knownEquivalent.add(commit.sha); + // If the branch merged the target, Git's merge base can hide older + // upstream patches. Compare the individual incoming commit too. + if (comparison.mergeCommits.length && !knownEquivalent.has(commit.sha)) { + const isolated = git(repository.path, 'cherry', targetRef, commit.sha); + for (const line of isolated.split('\n')) if (line.startsWith('- ')) knownEquivalent.add(line.slice(2)); + } + // A changed SHA alone does not mean the change is absent from the target branch. + const list = knownEquivalent.has(commit.sha) ? comparison.equivalentCommits : comparison.unmergedCommits; + list.push(commit); + } + } + comparison.status = comparison.unmergedCommits.length || comparison.mergeCommits.length + ? 'needs-review' : comparison.equivalentCommits.length ? 'equivalent-integrated' : 'merged'; + cache.set(sha, comparison); + } + result.branches.push({ ...branch, ...comparison }); + } catch (error) { + result.errors.push({ branch: branch.name, message: errorMessage(error) }); + } + } + } catch (error) { + result.errors.push({ message: errorMessage(error) }); + } + if (result.errors.length) result.status = 'incomplete'; + result.branches.sort((a, b) => b.unmergedCommits.length - a.unmergedCommits.length || a.name.localeCompare(b.name)); + result.summary = { + checkedBranches: result.branches.length, + branchesNeedingReview: result.branches.filter(x => x.status === 'needs-review').length, + uniqueUnmergedCommits: new Set(result.branches.flatMap(x => x.unmergedCommits.map(c => c.sha))).size, + uniqueMergeCommits: new Set(result.branches.flatMap(x => x.mergeCommits.map(c => c.sha))).size, + equivalentOnlyBranches: result.branches.filter(x => x.status === 'equivalent-integrated').length, + }; + return result; +} + +export function createReport(repositories = DEFAULT_REPOSITORIES, options = {}) { + const results = repositories.map(repository => inspectRepository(repository, options)); + return { + schemaVersion: 1, checkedAt: new Date().toISOString(), timezone: 'Asia/Shanghai', + fetchedByScript: Boolean(options.fetch), status: results.every(x => x.status === 'complete') ? 'complete' : 'incomplete', + repositories: results, + }; +} + +function markdown(value) { return String(value).replace(/[\\`*_[\]<>|]/g, '\\$&').replace(/[\r\n]/g, ' '); } + +export function renderMarkdown(report, limit = 10) { + const time = new Date(report.checkedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); + const lines = ['# 每日分支合入检查', '', `检查时间:${time}(Asia/Shanghai)。`, '', + '检查四库 origin 远端分支。未合入不代表未上线;补丁等价的提交单独标记,合并提交单独列为待核对。squash 或重写后无法匹配的提交仍需按实际代码核对。此报告不自动合并或发布支线内容。', '', + '| 仓库 | 发布主分支 | 已检查分支 | 待核对分支 | 未合入普通提交(去重) | 待核对合并提交(去重) | 状态 |', + '| --- | --- | ---: | ---: | ---: | ---: | --- |']; + for (const repo of report.repositories) { + const s = repo.summary; + lines.push(`| ${markdown(repo.label || repo.name)} | ${markdown(repo.remote + '/' + repo.targetBranch)} | ${s.checkedBranches} | ${s.branchesNeedingReview} | ${s.uniqueUnmergedCommits} | ${s.uniqueMergeCommits} | ${repo.status === 'complete' ? '完成' : '检查不完整'} |`); + } + for (const repo of report.repositories) { + lines.push('', `## ${markdown(repo.label || repo.name)}`, ''); + if (repo.errors.length) for (const error of repo.errors) lines.push(`- 检查失败${error.branch ? '(' + markdown(error.branch) + ')' : ''}:${markdown(error.message)}`); + const pending = repo.branches.filter(x => x.status === 'needs-review'); + if (!pending.length && !repo.errors.length) lines.push('其他远端分支无未合入改动。'); + for (const branch of pending) { + lines.push(`- **${markdown(branch.name)}**:${branch.unmergedCommits.length} 个未合入普通提交;${branch.equivalentCommits.length} 个补丁等价提交已集成;${branch.mergeCommits.length} 个合并提交待核对。`); + for (const commit of branch.unmergedCommits.slice(0, limit)) lines.push(` - \`${commit.shortSha}\` ${markdown(commit.subject)}(${markdown(commit.author)},${commit.committedAt})`); + for (const commit of branch.mergeCommits.slice(0, limit)) lines.push(` - 合并提交 \`${commit.shortSha}\` ${markdown(commit.subject)}`); + if (branch.unmergedCommits.length > limit || branch.mergeCommits.length > limit) lines.push(' - 其余提交见同目录 unmerged-branches.json。'); + } + const equivalent = repo.branches.filter(x => x.status === 'equivalent-integrated'); + if (equivalent.length) lines.push('', '仅 SHA/历史不同、补丁已等价集成的分支:' + equivalent.map(x => markdown(x.name)).join('、') + '。'); + } + return lines.join('\n') + '\n'; +} + +function main() { + const args = process.argv.slice(2); + let outputDir, config, fetch = false; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--fetch') fetch = true; + else if (args[i] === '--output-dir' || args[i] === '--config') { + const option = args[i], value = args[++i]; + if (!value || value.startsWith('--')) throw new Error(`${option} 缺少参数`); + if (option === '--output-dir') outputDir = resolve(value); else config = value; + } else if (args[i] === '--help') { + console.log('node report-unmerged-branches.mjs [--fetch] [--output-dir 目录] [--config 仓库配置.json]\n默认只读已 fetch 的远端 refs;--fetch 同步四库全部 origin 分支并清除失效远端 refs。配置为包含 name、path、targetBranch 的数组。'); + return; + } else throw new Error(`未知参数:${args[i]}`); + } + const repositories = config ? JSON.parse(readFileSync(config, 'utf8')) : DEFAULT_REPOSITORIES; + if (!Array.isArray(repositories) || !repositories.length || repositories.some(x => !x.name || !x.path || !x.targetBranch)) throw new Error('仓库配置必须是非空数组,包含 name、path、targetBranch'); + const report = createReport(repositories, { fetch }); + const text = renderMarkdown(report); + if (outputDir) { + mkdirSync(outputDir, { recursive: true, mode: 0o700 }); + writeFileSync(join(outputDir, 'unmerged-branches.json'), JSON.stringify(report, null, 2) + '\n', { mode: 0o600 }); + writeFileSync(join(outputDir, 'unmerged-branches.md'), text, { mode: 0o600 }); + } + console.log(text); + if (report.status !== 'complete') process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { main(); } catch (error) { console.error(errorMessage(error)); process.exitCode = 1; } +} diff --git a/scripts/report-unmerged-branches.test.mjs b/scripts/report-unmerged-branches.test.mjs new file mode 100644 index 0000000..5d97bb6 --- /dev/null +++ b/scripts/report-unmerged-branches.test.mjs @@ -0,0 +1,99 @@ +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/); +});