git-vault.js 10.5 KB
/**
 * 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 <pm@devloop.local>")
 * - 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,
};