174 lines
6.0 KiB
JavaScript
174 lines
6.0 KiB
JavaScript
#!/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 };
|