bookworm-smart-assistant/scripts/auto-git-sync.js

174 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 };