#!/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(); const mergeBases = new Map(); const isolatedEquivalence = new Map(); const mergeBase = sha => { if (!mergeBases.has(sha)) { try { mergeBases.set(sha, git(repository.path, 'merge-base', result.targetSha, sha)); } catch (error) { if (error.status === 1 && !String(error.stderr || '').trim()) mergeBases.set(sha, null); else throw error; } } return mergeBases.get(sha); }; 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) { // Pin the inventory SHAs so another fetch cannot change this comparison midway. const range = `${result.targetSha}..${sha}`; const ahead = Number(git(repository.path, 'rev-list', '--count', range)); comparison = { ahead, unmergedCommits: [], equivalentCommits: [], mergeCommits: [] }; if (ahead > 0) { const cherry = git(repository.path, 'cherry', result.targetSha, sha); 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) && mergeBase(sha) !== mergeBase(commit.sha)) { if (!isolatedEquivalence.has(commit.sha)) { const isolated = git(repository.path, 'cherry', result.targetSha, commit.sha); isolatedEquivalence.set(commit.sha, isolated.split('\n').includes('- ' + commit.sha)); } if (isolatedEquivalence.get(commit.sha)) knownEquivalent.add(commit.sha); } // 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; } }