forked from wangbo/easyai
177 lines
11 KiB
JavaScript
177 lines
11 KiB
JavaScript
#!/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; }
|
||
}
|