#!/usr/bin/env node /** * auto-git-sync.js — 自动 Git 同步模块 * * pullLatest() → 会话启动时调用,拉取最新代码 * pushChanges() → 会话结束时调用,提交并推送变更 * * 设计原则: * - fail-open: 任何错误只写日志,不抛出,不阻断主流程 * - 幂等: 无变更时静默跳过 * - 安全: 使用 spawnSync 数组参数,无 shell 注入风险 */ 'use strict'; const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); let ROOT; try { ROOT = require('./paths.config.js').PATHS.root; } catch (_) { ROOT = path.join(require('os').homedir(), '.claude'); } const CONF_PATH = path.join(ROOT, 'config', 'auto-sync-repos.json'); const LOG_PATH = path.join(ROOT, 'debug', 'auto-git-sync.log'); const STATE_PATH = path.join(ROOT, 'session-state', 'git-sync-state.json'); // ─── 工具函数 ─────────────────────────────────────────────── function log(msg) { try { fs.appendFileSync(LOG_PATH, `${new Date().toISOString()} ${msg}\n`, 'utf8'); } catch (_) {} } /** * 安全执行 git 命令,使用数组参数(无 shell 拼接) * @param {string[]} args 命令参数数组,如 ['git', 'pull', 'origin', 'main'] * @param {string} cwd 工作目录 */ function git(args, cwd) { const result = spawnSync('git', args, { cwd, timeout: 20000, encoding: 'utf8', env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' }), }); if (result.status !== 0) { throw new Error((result.stderr || result.stdout || 'git error').trim().slice(0, 300)); } return (result.stdout || '').trim(); } function loadConf() { if (!fs.existsSync(CONF_PATH)) return null; try { return JSON.parse(fs.readFileSync(CONF_PATH, 'utf8')); } catch (e) { log(`[CONF ERROR] ${e.message}`); return null; } } function loadState() { try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch (_) { return {}; } } function saveState(state) { try { fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true }); fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8'); } catch (_) {} } // ─── Pull: 会话启动时拉取最新代码 ─────────────────────────── function pullLatest() { const conf = loadConf(); if (!conf) return []; const state = loadState(); const minGap = (conf.minPullIntervalMinutes || 30) * 60 * 1000; const now = Date.now(); const msgs = []; for (const repo of (conf.repos || [])) { if (!repo.pullOnStart) continue; const gitDir = path.join(repo.path, '.git'); if (!fs.existsSync(gitDir)) continue; const stateKey = 'pull:' + repo.path; if (now - (state[stateKey] || 0) < minGap) continue; // 冷却中 const remote = repo.remote || 'origin'; const branch = repo.branch || 'main'; try { // 有未提交改动先 stash const dirty = git(['status', '--porcelain'], repo.path); if (dirty) git(['stash', 'push', '-m', 'auto-sync-stash'], repo.path); const before = git(['rev-parse', 'HEAD'], repo.path); git(['pull', remote, branch, '--ff-only'], repo.path); const after = git(['rev-parse', 'HEAD'], repo.path); if (dirty) { try { git(['stash', 'pop'], repo.path); } catch (_) {} } state[stateKey] = now; if (before !== after) { const diffStat = git(['diff', '--stat', before + '..' + after], repo.path) .split('\n').pop().trim(); msgs.push('[git-pull] ' + path.basename(repo.path) + ': ' + diffStat); log('[PULL OK] ' + repo.path + ' ' + before.slice(0, 7) + '->' + after.slice(0, 7)); } else { log('[PULL SKIP] ' + repo.path + ' already up to date'); } } catch (e) { log('[PULL ERROR] ' + repo.path + ': ' + e.message); msgs.push('[git-pull] WARN ' + path.basename(repo.path) + ': ' + e.message.slice(0, 60)); } } saveState(state); return msgs; // 注入 systemMessage(有更新才非空) } // ─── Push: 会话结束时提交并推送 ───────────────────────────── function pushChanges() { const conf = loadConf(); if (!conf) return; if (conf.dryRun) { log('[PUSH DRY-RUN] skipping'); return; } for (const repo of (conf.repos || [])) { if (!repo.pushOnStop) continue; const gitDir = path.join(repo.path, '.git'); if (!fs.existsSync(gitDir)) continue; const remote = repo.remote || 'origin'; const branch = repo.branch || 'main'; const prefix = repo.commitPrefix || 'chore:'; const maxFiles = repo.maxFilesPerCommit || 80; try { // stage 配置的路径(安全:每个 pattern 独立调用) for (const p of (repo.stagePatterns || ['.'])) { const fullP = path.join(repo.path, p); if (fs.existsSync(fullP)) { try { git(['add', p], repo.path); } catch (_) {} } } const staged = git(['diff', '--cached', '--name-only'], repo.path); if (!staged) { log('[PUSH SKIP] ' + repo.path + ' no staged changes'); continue; } const fileList = staged.split('\n').filter(Boolean); if (fileList.length > maxFiles) { log('[PUSH SKIP] ' + repo.path + ' too many files: ' + fileList.length); continue; } const ts = new Date().toISOString().slice(0, 16).replace('T', ' '); const preview = fileList.slice(0, 3).join(', ') + (fileList.length > 3 ? ' +' + (fileList.length - 3) : ''); const msg = prefix + ' auto-sync ' + ts + ' — ' + preview; git(['commit', '-m', msg], repo.path); git(['push', remote, 'HEAD:' + branch], repo.path); log('[PUSH OK] ' + repo.path + ' -> ' + remote + '/' + branch + ' | ' + fileList.length + ' files'); } catch (e) { log('[PUSH ERROR] ' + repo.path + ': ' + e.message); // fail-open: 不阻断 stop-dispatcher } } } module.exports = { pullLatest, pushChanges };