/** * DevLoop · gitVault * * 每项目一个独立 git repo,LoopController 在每次 Agent 成功完成时调 * gitVault.commit(...) 落盘一个版本。Agent 自身不动 git。 * * - 实现用 child_process.spawn('git', ...);不依赖第三方 npm 包 * - 所有方法按 pid 串行化(per-pid mutex),避免同项目并发 git 操作打架 * - commit author 标 Agent 名("PM Agent ") * - nothing-to-commit 不抛错,返 { ok: true, skipped: true } * * 规格:docs/prd/newfunction/_spec/arch-git-versioning.md * docs/prd/newfunction/_spec/sdd-control.md · C-18 * docs/prd/newfunction/_spec/sdd-process.md · P-10 */ const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const pathResolver = require('./path-resolver'); const DEFAULT_GITIGNORE = `# DevLoop project .gitignore # 高频写的运行态,不需要版本 agent-state/ # git 本身就是 history history/ # 大日志 stream.log units/*/runs/*/stdout.txt units/*/runs/*/stderr.txt integration/runs/*/process.log integration/runs/*/http-trace.ndjson.gz # Node / 依赖 node_modules/ *.log .DS_Store .cache/ .tmp/ .coverage/ # 测试中间文件 units/*/.vitest-cache/ `; // per-pid mutex(简单 Promise chain) const mutexes = new Map(); function _withLock(pid, fn) { const prev = mutexes.get(pid) || Promise.resolve(); const next = prev.then(fn, fn); // 不管前一个成功失败,继续跑下一个 mutexes.set(pid, next.catch(() => {})); // 防止 unhandled rejection return next; } // 运行单条 git 命令;返回 { ok, code, stdout, stderr } function _run(cwd, args, opts = {}) { return new Promise((resolve) => { const child = spawn('git', args, { cwd, env: { ...process.env, ...(opts.env || {}) }, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = '', stderr = ''; child.stdout.on('data', c => { stdout += c.toString(); }); child.stderr.on('data', c => { stderr += c.toString(); }); child.on('close', code => { resolve({ ok: code === 0, code, stdout, stderr }); }); child.on('error', err => { resolve({ ok: false, code: -1, stdout, stderr: err.message }); }); }); } // ──────────────────────────────────────────────────────────────── // API // ──────────────────────────────────────────────────────────────── /** 在项目根 git init + 写 .gitignore + 首个 commit */ async function init(pid, opts = {}) { return _withLock(pid, async () => { const root = pathResolver.resolveProjectRoot(pid); // 已是 repo 则跳 if (fs.existsSync(path.join(root, '.git'))) { return { ok: true, head: await _head(root), already_exists: true }; } // 1. git init const r1 = await _run(root, ['init', '-q', '-b', opts.initialBranch || 'main']); if (!r1.ok) throw new Error(`git init failed: ${r1.stderr}`); // 2. 配 author identity(commit 需要) const userName = opts.name || 'DevLoop'; const userEmail = opts.email || 'devloop@lemonboss.local'; await _run(root, ['config', 'user.name', userName]); await _run(root, ['config', 'user.email', userEmail]); // 不签名(v1 不强求 GPG) await _run(root, ['config', 'commit.gpgsign', 'false']); // 3. 写 .gitignore const gitignorePath = path.join(root, '.gitignore'); if (!fs.existsSync(gitignorePath)) { fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE); } // 4. 首个 commit const r2 = await _run(root, ['add', '.']); if (!r2.ok) throw new Error(`git add failed: ${r2.stderr}`); const initialMsg = opts.initialMessage || `init: project ${pid}` + (opts.summary ? ` · ${opts.summary}` : ''); const r3 = await _run(root, ['commit', '-q', '-m', initialMsg, '--allow-empty']); if (!r3.ok) throw new Error(`initial commit failed: ${r3.stderr}`); return { ok: true, head: await _head(root) }; }); } /** * Commit current changes. * @param {string} pid * @param {object} spec * - kind: 'init'|'prd'|'sdd'|'code'|'triage'|'integ'|'ship'|'chore' * - unit_id?: string (code 时填,作为 message 一部分) * - run_id?: string (integ/triage 时填) * - summary: string (必填) * - files?: string[] (限定 add 的路径,默认 git add -A) * - author?: { name, email } (commit author) * - skipEmpty?: boolean (默认 true,nothing-to-commit 返 skipped) * @returns { ok, sha?, skipped? } */ async function commit(pid, spec) { return _withLock(pid, async () => { const root = pathResolver.resolveProjectRoot(pid); if (!fs.existsSync(path.join(root, '.git'))) { throw new Error(`not a git repo: ${root} (call init first)`); } if (!spec.summary) throw new Error('commit: summary required'); // 1. 加 files(显式列表则限定路径;否则 -A 全量。-A 受 .gitignore 控制) if (Array.isArray(spec.files) && spec.files.length) { const rAdd = await _run(root, ['add', '--', ...spec.files]); if (!rAdd.ok) throw new Error(`git add files failed: ${rAdd.stderr}`); } else { const rA = await _run(root, ['add', '-A']); if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`); } // 2. 检查是否有可 commit 变化 const status = await _run(root, ['status', '--porcelain']); const dirty = status.stdout.trim().length > 0; if (!dirty) { if (spec.skipEmpty === false) { throw new Error('nothing to commit and skipEmpty=false'); } return { ok: true, skipped: true }; } // 3. commit const env = {}; if (spec.author?.name) env.GIT_AUTHOR_NAME = spec.author.name; if (spec.author?.email) env.GIT_AUTHOR_EMAIL = spec.author.email; if (spec.author?.name) env.GIT_COMMITTER_NAME = spec.author.name; if (spec.author?.email) env.GIT_COMMITTER_EMAIL = spec.author.email; const r = await _run(root, ['commit', '-q', '-m', spec.summary], { env }); if (!r.ok) { // 兜底:检测 "nothing to commit" 文案 if (/nothing to commit/i.test(r.stdout + r.stderr)) { return { ok: true, skipped: true }; } throw new Error(`git commit failed: ${r.stderr || r.stdout}`); } const sha = await _head(root); return { ok: true, sha, kind: spec.kind, summary: spec.summary }; }); } async function tag(pid, tagName, opts = {}) { return _withLock(pid, async () => { const root = pathResolver.resolveProjectRoot(pid); const args = ['tag']; if (opts.message) { args.push('-a', tagName, '-m', opts.message); } else { args.push(tagName); } if (opts.sha) args.push(opts.sha); const r = await _run(root, args); if (!r.ok) throw new Error(`git tag failed: ${r.stderr}`); return { ok: true, tag: tagName }; }); } async function log(pid, opts = {}) { const root = pathResolver.resolveProjectRoot(pid); const args = ['log', '--format=%H%x09%an%x09%ae%x09%at%x09%s']; if (opts.limit) args.push(`-n${opts.limit}`); if (opts.since) args.push(`--since=${opts.since}`); if (opts.grep) args.push(`--grep=${opts.grep}`); const r = await _run(root, args); if (!r.ok) { // 空仓库 (no commits) → 返空 if (/does not have any commits/i.test(r.stderr)) return []; throw new Error(`git log failed: ${r.stderr}`); } return r.stdout.trim().split('\n').filter(Boolean).map(line => { const [sha, author_name, author_email, ts, ...rest] = line.split('\t'); return { sha, author_name, author_email, ts: parseInt(ts, 10), summary: rest.join('\t'), }; }); } async function diff(pid, refA, refB, opts = {}) { const root = pathResolver.resolveProjectRoot(pid); const args = ['diff', `${refA}..${refB}`]; if (opts.path) args.push('--', opts.path); const r = await _run(root, args); if (!r.ok) throw new Error(`git diff failed: ${r.stderr}`); return r.stdout; } async function show(pid, sha) { const root = pathResolver.resolveProjectRoot(pid); const r = await _run(root, ['show', '--format=%H%x09%an%x09%ae%x09%at%x09%s%n', sha]); if (!r.ok) throw new Error(`git show failed: ${r.stderr}`); return { sha, output: r.stdout }; } async function listTags(pid) { const root = pathResolver.resolveProjectRoot(pid); const r = await _run(root, ['tag', '--list', '--sort=-creatordate']); if (!r.ok) throw new Error(`git tag --list failed: ${r.stderr}`); return r.stdout.trim().split('\n').filter(Boolean); } async function status(pid) { const root = pathResolver.resolveProjectRoot(pid); if (!fs.existsSync(path.join(root, '.git'))) { return { dirty: false, files: [], no_repo: true }; } const r = await _run(root, ['status', '--porcelain']); if (!r.ok) throw new Error(`git status failed: ${r.stderr}`); const files = r.stdout.trim().split('\n').filter(Boolean).map(l => l.trim().split(/\s+/, 2)); return { dirty: files.length > 0, files }; } async function resetWorkingTree(pid, ref = 'HEAD') { return _withLock(pid, async () => { const root = pathResolver.resolveProjectRoot(pid); const r1 = await _run(root, ['reset', '--hard', ref]); if (!r1.ok) throw new Error(`git reset failed: ${r1.stderr}`); const r2 = await _run(root, ['clean', '-fd']); if (!r2.ok) throw new Error(`git clean failed: ${r2.stderr}`); return { ok: true }; }); } async function revert(pid, sha) { return _withLock(pid, async () => { const root = pathResolver.resolveProjectRoot(pid); const r = await _run(root, ['revert', '--no-edit', sha]); if (!r.ok) throw new Error(`git revert failed: ${r.stderr}`); return { ok: true, newSha: await _head(root) }; }); } async function archive(pid, ref = 'HEAD', format = 'zip') { const root = pathResolver.resolveProjectRoot(pid); const outPath = path.join('/tmp', `${pid}-${ref}.${format}`); // git archive --format=zip -o /tmp/x.zip HEAD const r = await _run(root, ['archive', `--format=${format}`, `-o`, outPath, ref]); if (!r.ok) throw new Error(`git archive failed: ${r.stderr}`); return { path: outPath }; } // 工具 async function _head(root) { const r = await _run(root, ['rev-parse', 'HEAD']); if (!r.ok) return null; return r.stdout.trim(); } function _resetMutexesForTesting() { mutexes.clear(); } module.exports = { init, commit, tag, log, diff, show, listTags, status, resetWorkingTree, revert, archive, DEFAULT_GITIGNORE, _resetMutexesForTesting, };