/** * git 操作:状态、发布(提交 + 推两端)、同步。 * * 所有 git 调用串行化——两个并发的 commit/push 撞在一起会互相踩工作区, * 单用户场景下也值得防一手(用户手快连点两次「发布」就会遇到)。 */ import { execFile } from 'node:child_process'; function run(cwd, args, { allowFailure = false } = {}) { return new Promise((resolve, reject) => { execFile('git', args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => { const out = (stdout || '') + (stderr || ''); if (err && !allowFailure) { const e = new Error(out.trim() || err.message); e.stdout = stdout; e.stderr = stderr; e.code = err.code; reject(e); return; } resolve({ ok: !err, code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, out, stdout, stderr }); }); }); } export function makeGit(cfg) { const { repoRoot, branch, pushRemotes, authorName, authorEmail, paths } = cfg; // 串行队列 let tail = Promise.resolve(); function serial(task) { const next = tail.then(task, task); tail = next.catch(() => {}); return next; } const identity = [ '-c', 'user.name=' + authorName, '-c', 'user.email=' + authorEmail, // 容器里通常没有 GPG,且我们不需要签名 '-c', 'commit.gpgsign=false', ]; /** * scope 语义(编辑角色 = 只提交自己的文章): * undefined → 全量 cfg.paths(content / static),管理员用 * string[] → 只用这些 pathspec(编辑自己的文章目录) * [] → 编辑名下还没有文章目录:什么都不做(不能退化成全量!) * * ★ 空数组必须显式拦掉。`git add -- ` 后面不跟 pathspec 就等于 `git add -A`, * 会把仓库里所有人未提交的改动一起提交进去 —— 那正是要避免的事。 */ function resolvePaths(scope) { if (Array.isArray(scope)) return scope; return paths; } async function status(scope) { const usePaths = resolvePaths(scope); const scoped = Array.isArray(scope); const head = await run(repoRoot, ['log', '-1', '--pretty=%h%x09%ad%x09%s', '--date=format:%Y-%m-%d %H:%M']); const br = await run(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']); const base = { branch: br.out.trim(), scoped, lastCommit: head.out.trim(), }; if (scoped && !usePaths.length) { return { ...base, dirty: false, changed: 0, files: [] }; } // core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义, // 否则发布弹层里满屏都是看不懂的编码 const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...usePaths]); const files = st.out.split('\n').map((l) => l.trim()).filter(Boolean); return { ...base, dirty: files.length > 0, changed: files.length, files: files.slice(0, 50), }; } async function publish(message, scope) { return serial(async () => { const log = []; const usePaths = resolvePaths(scope); const scoped = Array.isArray(scope); if (scoped && !usePaths.length) { return { ok: true, pushed: [], scoped: true, warning: null, log: '(你名下还没有文章目录,没有需要发布的内容)' }; } const add = await run(repoRoot, ['add', '--', ...usePaths]); log.push(add.out.trim()); // 先提交(没有暂存内容就跳过) const diff = await run(repoRoot, ['diff', '--cached', '--quiet'], { allowFailure: true }); if (diff.code === 1) { const msg = message || ('编辑: ' + new Date().toISOString().slice(0, 16).replace('T', ' ')); const c = await run(repoRoot, [...identity, 'commit', '-m', msg]); log.push(c.out.trim()); } else if (diff.code !== 0) { throw new Error('检查暂存区失败: ' + diff.out.trim()); } else { log.push('(没有需要提交的改动)'); } // 先拉再推,减少被拒概率 const pull = await run(repoRoot, ['pull', '--rebase', '--autostash', 'origin', branch], { allowFailure: true }); log.push(pull.out.trim()); if (!pull.ok && /conflict|CONFLICT|nothing to rebase|Automatic merge failed/i.test(pull.out)) { await run(repoRoot, ['rebase', '--abort'], { allowFailure: true }); return { ok: false, conflict: true, log: log.join('\n'), error: '远程有冲突,已放弃合并。请先「同步」后手动处理。' }; } const pushed = []; const failed = []; for (const remote of pushRemotes) { const p = await run(repoRoot, ['push', remote, 'HEAD:' + branch], { allowFailure: true }); log.push(p.out.trim()); if (p.ok) pushed.push(remote); else failed.push(remote + ': ' + p.out.trim().split('\n').slice(-2).join(' ')); } if (!pushed.length) { return { ok: false, log: log.join('\n'), error: '推送失败:' + failed.join(' | ') }; } return { ok: true, pushed, scoped, // 备份远端推失败不算发布失败(主仓成了就算成),但如实说明 warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null, log: log.join('\n'), }; }); } async function sync() { return serial(async () => { const pull = await run(repoRoot, ['pull', '--rebase', '--autostash', 'origin', branch], { allowFailure: true }); if (pull.ok) return { ok: true, log: pull.out.trim() }; if (/conflict|CONFLICT|Automatic merge failed/i.test(pull.out)) { await run(repoRoot, ['rebase', '--abort'], { allowFailure: true }); return { ok: false, conflict: true, error: '存在冲突,已放弃合并' }; } return { ok: false, error: pull.out.trim() }; }); } return { status, publish, sync }; }