Files
easyai/scripts/report-unmerged-branches.mjs
T

164 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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; }
}