Files

319 lines
28 KiB
JavaScript

'use strict';
// 사이트별 소스를 git 으로 보관하고 원격(Gitea)에 푸시한다.
// - 로컬 git 디렉터리: data/git/<siteId> (work-tree 는 /var/www/sites/<siteId>, .git 은 사이트 폴더에 두지 않음)
// - 원격 브랜치: sites/<siteId>/main 최신 상태
// sites/<siteId>/changes/NNNN-kind 변경(생성/AI 수정/수동 편집)마다 하나씩
// - 원격 저장소: v1.4.14 부터 서비스 서버 안의 전용 Gitea(deploy/install-gitea.sh 가 만드는 webmaker/sites, ssh 별칭 webmaker-gitea → 127.0.0.1:2230).
// 1.4.5 까지는 회사 소스 저장소(source.ibit.kr OZ-Group/WebHosting_AI_agent)에 섞어 두었고, 그 브랜치는 scripts/migrate-gitstore.sh 로 옮긴다.
// 다른 서버에서 복사해 온 저장소는 alignRepo 가 매 사용 때 origin 을 맞춘다.
// - v1.5.7 (요구 6): 접속 정보(푸시 주소·웹 주소·브랜치 접두어)와 오류 해석·상태 확인은 연동 모듈(lib/connectors/gitea.js)이 맡는다.
// 1.4.14~1.4.18 의 환경 변수 SITES_GIT_REMOTE/SITES_GIT_WEB/SITES_GIT_PREFIX 는 설정 파일(source.remote/web/prefix)로 옮겨졌고,
// 여기서는 호출할 때마다 모듈에서 읽으므로 설정 페이지에서 바꾼 값이 재시작 없이 다음 커밋부터 적용된다.
// git 명령 자체는 제품과 무관한 표준 동작이라 이 파일에 그대로 둔다 — 다른 git 서비스로 바꿀 때는 모듈만 바꾼다.
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const connectors = require('./connectors');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const GIT_ROOT = path.join(DATA_ROOT, 'git');
fs.mkdirSync(GIT_ROOT, { recursive: true });
/** 지금 설정된 소스 보관 연동 정보. 연동 안 함(provider none)이면 remote 가 비어 원격 작업을 건너뛴다 */
function conn() {
const c = connectors.source(); if (!c) return { remote: '', web: '', prefix: 'sites', module: null };
const s = c.settings(); return { remote: s.remote || '', web: s.web || '', prefix: s.prefix || 'sites', module: c };
}
const NO_REMOTE = '소스 보관 연동 미설정 (설정 파일 source.remote 가 비어 있음 — 초기 설정 페이지에서 넣거나 deploy/install-gitea.sh 실행)';
function git(siteId, siteDir, args, opts = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, GIT_DIR: path.join(GIT_ROOT, siteId), GIT_WORK_TREE: siteDir, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new' };
execFile('git', args, { env, cwd: siteDir, timeout: opts.timeout || 60000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) return reject(new Error(gitHint((stderr || err.message).trim().split('\n').map((l) => l.trim()).filter(Boolean).slice(-3).join(' '))));
resolve(stdout.trim());
});
});
}
function mainBranch(siteId) { return `${conn().prefix}/${siteId}/main`; }
function changeBranch(siteId, no, kind) { return `${conn().prefix}/${siteId}/changes/${String(no).padStart(4, '0')}-${kind}`; }
function branchUrl(branch) { const { module } = conn(); return module ? module.branchUrl(branch) : null; }
function commitUrl(sha) { const { module } = conn(); return module ? module.commitUrl(sha) : null; }
/** 사이트 git 저장소 초기화 (이미 있으면 무시) */
async function initSite(siteId, siteDir) {
const gitDir = path.join(GIT_ROOT, siteId);
if (fs.existsSync(path.join(gitDir, 'HEAD'))) return alignRepo(siteId, siteDir);
await git(siteId, siteDir, ['init', '-q', '-b', 'main']);
fs.mkdirSync(path.join(gitDir, 'info'), { recursive: true });
fs.writeFileSync(path.join(gitDir, 'info', 'exclude'), '.claude/\n.refs/\n*.tmp\n.unpublished\n');
const { remote } = conn(); if (remote) await git(siteId, siteDir, ['remote', 'add', 'origin', remote]);
}
/** 다른 서버에서 복사해 온 저장소는 origin 주소·work-tree 가 그 서버 기준이므로 이 서버 설정에 맞춘다 (매 사용 시, 비용 미미) */
async function alignRepo(siteId, siteDir) {
try {
const { remote } = conn();
const cur = await git(siteId, siteDir, ['config', '--get', 'remote.origin.url']).catch(() => '');
if (remote && cur !== remote) { if (cur) await git(siteId, siteDir, ['remote', 'set-url', 'origin', remote]); else await git(siteId, siteDir, ['remote', 'add', 'origin', remote]); }
const wt = await git(siteId, siteDir, ['config', '--get', 'core.worktree']).catch(() => '');
if (wt && wt !== siteDir) await git(siteId, siteDir, ['config', 'core.worktree', siteDir]);
// 예전 저장소의 제외 목록에 발행 취소 표식(.unpublished)이 없으면 추가 (v1.1.4)
const ex = path.join(GIT_ROOT, siteId, 'info', 'exclude'); try { const cur2 = fs.existsSync(ex) ? fs.readFileSync(ex, 'utf8') : ''; if (!/^\.unpublished$/m.test(cur2)) { fs.mkdirSync(path.dirname(ex), { recursive: true }); fs.writeFileSync(ex, cur2.replace(/\s*$/, '\n') + '.unpublished\n'); } } catch (_) {}
} catch (_) { /* 정렬 실패는 이후 작업의 오류로 드러난다 */ }
}
// 사이트별 직렬 실행 큐 (동시 커밋/푸시 방지)
const queues = new Map();
/** git/ssh 오류를 운영자가 조치할 수 있는 문구로 (연동 모듈이 제품에 맞게 해석, 모듈이 없으면 원문) */
function gitHint(msg) { const { module } = conn(); return module && module.gitHint ? module.gitHint(msg) : msg; }
/** 원격 저장소 연결 상태와 이 서버가 쓰는 공개키 (관리자 콘솔용) — 연동 모듈의 status() */
function remoteStatus() { return connectors.status('source'); }
function enqueue(siteId, fn) {
const prev = queues.get(siteId) || Promise.resolve();
const next = prev.then(fn, fn);
queues.set(siteId, next.catch(() => {}));
return next;
}
/**
* 변경 사항을 커밋하고 변경 브랜치 + main 브랜치로 푸시한다.
* @returns {Promise<{no, kind, branch, commit, message, pushed, error, at}|null>} 변경이 없으면 null
*/
function recordChange({ siteId, siteDir, no, kind, message, author }) {
return enqueue(siteId, async () => {
await initSite(siteId, siteDir);
await git(siteId, siteDir, ['add', '-A']);
const status = await git(siteId, siteDir, ['status', '--porcelain']);
if (!status) return null;
const authorArgs = ['-c', `user.name=${author.name || 'webhosting-agent'}`, '-c', `user.email=${author.email || 'webhosting-agent@local'}`];
await git(siteId, siteDir, [...authorArgs, 'commit', '-q', '-m', message]);
const commit = await git(siteId, siteDir, ['rev-parse', 'HEAD']);
const branch = changeBranch(siteId, no, kind);
await git(siteId, siteDir, ['branch', '-f', branch, 'HEAD']);
// 이 버전에서 바뀐 파일 목록 (A=추가, M=수정, D=삭제, R=이름변경) — 사용자에게 "어느 페이지를 어떻게 고쳤는지" 보여 주는 데 쓴다
let files = []; let stat = '';
try { files = (await git(siteId, siteDir, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', 'HEAD'])).split('\n').filter(Boolean).slice(0, 100).map((l) => { const [st, ...rest] = l.split('\t'); return { status: st[0], path: rest[rest.length - 1] }; }); } catch (_) {}
try { stat = (await git(siteId, siteDir, ['show', '--shortstat', '--format=', 'HEAD'])).trim(); } catch (_) {}
const rec = { no, kind, branch, commit, message, pushed: false, error: null, at: new Date().toISOString(), url: branchUrl(branch), commitUrl: commitUrl(commit), files, stat };
const { remote } = conn();
if (remote) {
try {
await git(siteId, siteDir, ['push', '-q', 'origin', `+HEAD:refs/heads/${mainBranch(siteId)}`, `HEAD:refs/heads/${branch}`], { timeout: 120000 });
rec.pushed = true;
} catch (e) { rec.error = e.message; }
} else rec.error = NO_REMOTE;
return rec;
});
}
/** 배포 완료 후: 이력을 버리고 현재 트리 하나의 커밋만 남긴다(로컬·원격 변경 브랜치 삭제, main 강제 갱신) */
function squashToCurrent(siteId, siteDir, message) {
return enqueue(siteId, async () => {
await initSite(siteId, siteDir);
const { remote, prefix } = conn();
// 로컬 변경 브랜치 목록(원격 삭제용)
let localBranches = [];
try { localBranches = (await git(siteId, siteDir, ['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}/${siteId}/changes/`])).split('\n').filter(Boolean); } catch (_) {}
// 고아 브랜치에 현재 상태 커밋 → main 교체
await git(siteId, siteDir, ['checkout', '-q', '--orphan', '_squash']);
await git(siteId, siteDir, ['add', '-A']);
await git(siteId, siteDir, ['-c', 'user.name=webhosting-agent', '-c', 'user.email=webhosting-agent@local', 'commit', '-q', '--allow-empty', '-m', message]);
const commit = await git(siteId, siteDir, ['rev-parse', 'HEAD']);
try { await git(siteId, siteDir, ['branch', '-D', 'main']); } catch (_) {}
await git(siteId, siteDir, ['branch', '-m', 'main']);
for (const b of localBranches) { try { await git(siteId, siteDir, ['branch', '-D', b]); } catch (_) {} }
try { await git(siteId, siteDir, ['reflog', 'expire', '--expire=now', '--all']); await git(siteId, siteDir, ['gc', '-q', '--prune=now']); } catch (_) {}
const rec = { no: 1, kind: 'deploy', branch: mainBranch(siteId), commit, message, pushed: false, error: null, at: new Date().toISOString(), url: branchUrl(mainBranch(siteId)), commitUrl: commitUrl(commit) };
if (remote) {
try {
// 원격 변경 브랜치 삭제 + main 강제 갱신
let remoteBranches = [];
try { remoteBranches = (await git(siteId, siteDir, ['ls-remote', '--heads', 'origin', `refs/heads/${prefix}/${siteId}/changes/*`], { timeout: 60000 })).split('\n').map((l) => l.split('\t')[1]).filter(Boolean); } catch (_) {}
await git(siteId, siteDir, ['push', '-q', '--force', 'origin', `HEAD:refs/heads/${mainBranch(siteId)}`], { timeout: 120000 });
for (let i = 0; i < remoteBranches.length; i += 20) { await git(siteId, siteDir, ['push', '-q', 'origin', ...remoteBranches.slice(i, i + 20).map((r) => `:${r}`)], { timeout: 120000 }); }
rec.pushed = true;
} catch (e) { rec.error = e.message; }
} else rec.error = NO_REMOTE;
return rec;
});
}
/**
* 원격 반영 조정(푸시 실패분 재시도의 후속, 1.8.0 검토 수정 2차). pushed 가 아닌 기록(직접 편집 저장의 'pending' — 기록은 푸시 **전** 에 저장되므로 원격 반영을 주장하지 않는다 — 과 예전 실패분)을
* 1) `ls-remote` 로 **원격 ref 에서 확인** 해 변경 브랜치 = 기록의 커밋이고 main 이 그 커밋을 포함하면 푸시 없이 pushed:true·remote:'verified' 로 표시하고,
* 2) 아니면 로컬 변경 브랜치가 기록의 커밋을 가리키는지 확인한 뒤 그 브랜치들 + main(+HEAD — main 은 늘 지금 HEAD, v1.3.17 B07) 을 **한 번의 원자적 푸시** 로 올리고 다시 1) 로 확인한다.
* 확인되지 않은 기록은 pushed:false 로 남고 error 에 사유가 적힌다(거짓 성공 없음). 원격이 없으면 그대로 돌려준다. 던지지 않는다. retryPush 라는 옛 이름으로도 부른다.
*/
function reconcilePushes(siteId, siteDir, recs) {
return enqueue(siteId, async () => {
const { remote, prefix } = conn(); if (!remote) return recs;
await initSite(siteId, siteDir);
const main = `refs/heads/${mainBranch(siteId)}`; const sh = (args, opts) => git(siteId, siteDir, args, opts);
const pending = (recs || []).filter((r) => r && !r.pushed && r.commit);
if (!pending.length) return recs;
const readRemote = () => readRemoteRefs(siteId, siteDir, prefix);
const isAncestor = async (c, sha) => { if (!sha) return false; if (c === sha) return true; try { await sh(['merge-base', '--is-ancestor', c, sha]); return true; } catch (_) { return false; } };
const verified = async (r, refs) => ((r.kind === 'deploy' || !r.branch) ? true : refs[`refs/heads/${r.branch}`] === r.commit) && await isAncestor(r.commit, refs[main]);
const mark = (r) => { r.pushed = true; r.error = null; r.remote = 'verified'; r.verifiedAt = new Date().toISOString(); };
let refs; try { refs = await readRemote(); } catch (e) { for (const r of pending) r.error = `원격 확인 실패: ${e.message}`; return recs; }
const toPush = []; for (const r of pending) { if (await verified(r, refs)) mark(r); else toPush.push(r); }
if (!toPush.length) return recs;
const specs = []; const pushing = [];
for (const r of toPush) {
if (r.kind === 'deploy' || !r.branch) { pushing.push(r); continue; } // 배포 정리 기록은 main 만
const local = (await sh(['rev-parse', '--verify', '-q', `refs/heads/${r.branch}`]).catch(() => '')) || null;
if (local !== r.commit) { r.error = `로컬 변경 브랜치 ${r.branch} 가 기록의 커밋을 가리키지 않아 올릴 수 없습니다 (${local ? local.slice(0, 7) : '없음'})`; continue; }
specs.push(`${r.commit}:refs/heads/${r.branch}`); pushing.push(r);
}
if (!pushing.length) return recs;
try { await sh(['push', '--atomic', '-q', 'origin', ...specs, `+HEAD:${main}`], { timeout: 120000 }); } catch (e) { for (const r of pushing) r.error = e.message; return recs; }
let refs2; try { refs2 = await readRemote(); } catch (e) { for (const r of pushing) r.error = `푸시 뒤 원격 확인 실패: ${e.message}`; return recs; }
for (const r of pushing) { if (await verified(r, refs2)) mark(r); else r.error = '푸시 뒤 원격 ref 가 기록과 다릅니다'; }
return recs;
});
}
const retryPush = reconcilePushes;
/** 작업 트리를 지정 커밋의 내용으로 되돌린다(추적 파일 기준). 이후 recordChange 로 새 변경(restore)을 기록해야 한다. */
function restoreTo(siteId, siteDir, commit) {
return enqueue(siteId, async () => {
await initSite(siteId, siteDir);
await git(siteId, siteDir, ['read-tree', '-u', '--reset', commit]);
return true;
});
}
// ---------- 직접 편집 저장 트랜잭션(1.8.0 검토 수정 2차·3차) ----------
/** status --porcelain 출력 → 경로 집합(되돌리기 검증: 저장 전에 없던 변경이 남지 않았는지) */
function statusPaths(porcelain) { return new Set(String(porcelain || '').split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^.* -> /, ''))); }
/** 원격의 이 사이트 ref → { refname: sha } (ls-remote). 실패하면 던진다 */
async function readRemoteRefs(siteId, siteDir, prefix) {
const out = await git(siteId, siteDir, ['ls-remote', '--heads', 'origin', `refs/heads/${prefix}/${siteId}/*`], { timeout: 60000 });
return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => { const [sha, ref] = l.split('\t'); return [ref, sha]; }));
}
/**
* 되돌리기 기준 스냅샷: 저장소가 있었는지·HEAD(첫 커밋 전이면 null)·인덱스가 HEAD 와 같았는지·변경 브랜치의 이전 값(없으면 null)·작업 트리 상태(경로 집합). add 하기 **전** 에 찍는다.
* @returns {Promise<{repoExisted: boolean, head: string|null, indexClean: boolean, branch: string|null, branchWas: string|null, paths: Set<string>|null}>}
*/
async function snapshotState(siteId, siteDir, branch, repoExisted) {
const head = (await git(siteId, siteDir, ['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null;
let indexClean = false; try { await git(siteId, siteDir, ['diff', '--cached', '--quiet']); indexClean = true; } catch (_) {}
const branchWas = branch ? ((await git(siteId, siteDir, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).catch(() => '')) || null) : null;
const status = await git(siteId, siteDir, ['status', '--porcelain']).catch(() => null);
return { repoExisted: repoExisted !== false, head, indexClean, branch: branch || null, branchWas, paths: status === null ? null : statusPaths(status) };
}
/**
* 로컬 Git 상태를 스냅샷으로 되돌리고 **검증한 결과** 를 돌려준다(큐 밖, recordChangeStrict 안에서 쓴다).
* 0) 이 트랜잭션이 저장소를 새로 만들었으면(repoExisted=false) 그 git 폴더를 통째로 지운다 — init·인덱스·브랜치 효과가 모두 사라진다. 스냅샷을 찍기 전에 실패했으면(snap 없음) 그 밖의 Git 상태는 건드리지 않는다.
* 1) HEAD·인덱스: `reset --mixed <이전 HEAD>` — 커밋이 만들어졌으면 되감고, add 로 올라간 인덱스도 비운다. 작업 트리의 다른 파일은 건드리지 않는다(--hard 는 저장과 무관한
* 커밋되지 않은 파일을 지울 수 있다). 첫 커밋 전이었으면 만들어진 브랜치 참조를 지우고 인덱스를 비운다(read-tree --empty).
* 2) 변경 브랜치: 새로 만든 것은 지우고, 원래 있던 것은 이전 값으로.
* 3) hooks.onRollback(): 호출자가 페이지 원본·사이트 기록을 되돌리고 문제 목록(빈 목록 = 성공)을 돌려준다. 어느 단계에서 실패했든 **항상** 부른다(페이지는 트랜잭션 전에 이미 바뀌었다).
* 4) 검증: 저장소 유무 = 스냅샷, HEAD = 스냅샷, 인덱스 = HEAD(저장 전에 깨끗했다면), 변경 브랜치 = 이전 값, 작업 트리 변경 경로 ⊆ 저장 전 경로. 판정은 **실제 상태** 로만 한다 — 되돌리기 명령이 실패해도
* 상태가 저장 전과 같으면 ok 이고 명령 오류는 notes 에 남긴다. 스냅샷이 없어 검증할 수 없으면(저장소가 있었는데 스냅샷 전에 실패) ok 가 아니다 — 되돌렸다고 주장하지 않는다.
* @returns {Promise<{ok: boolean, problems: string[], notes: string[]}>}
*/
async function rollbackLocal(siteId, siteDir, snap, hooks) {
const problems = []; const notes = []; const sh = (args) => git(siteId, siteDir, args); const gitDir = path.join(GIT_ROOT, siteId);
const repoExisted = !!(snap && snap.repoExisted);
if (!repoExisted) { try { fs.rmSync(gitDir, { recursive: true, force: true }); } catch (e) { notes.push(`새로 만든 git 폴더 삭제 실패: ${e.message}`); } }
else if (snap && !snap.unknown) {
try {
const cur = (await sh(['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null;
if (snap.head) await sh(['reset', '-q', '--mixed', snap.head]);
else { if (cur) { const b = (await sh(['symbolic-ref', '--short', '-q', 'HEAD']).catch(() => '')) || 'main'; await sh(['update-ref', '-d', `refs/heads/${b}`]); } await sh(['read-tree', '--empty']); }
} catch (e) { notes.push(`HEAD·인덱스 되돌리기 명령 실패: ${e.message}`); }
if (snap.branch) { try { if (snap.branchWas) await sh(['update-ref', `refs/heads/${snap.branch}`, snap.branchWas]); else if ((await sh(['rev-parse', '--verify', '-q', `refs/heads/${snap.branch}`]).catch(() => ''))) await sh(['update-ref', '-d', `refs/heads/${snap.branch}`]); } catch (e) { notes.push(`변경 브랜치 되돌리기 명령 실패: ${e.message}`); } }
}
if (hooks && typeof hooks.onRollback === 'function') { try { for (const p of ((await hooks.onRollback()) || [])) problems.push(String(p)); } catch (e) { problems.push(`페이지·기록 되돌리기 실패: ${e.message}`); } }
if (!repoExisted) { if (fs.existsSync(gitDir)) problems.push('이 트랜잭션이 만든 git 폴더가 남아 있습니다'); return { ok: problems.length === 0, problems, notes }; }
if (!snap || snap.unknown) { problems.push('저장 전 Git 상태를 찍기 전에 실패해 되돌리기를 검증할 수 없습니다'); return { ok: false, problems, notes }; }
try {
const cur = (await sh(['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null;
if (cur !== snap.head) problems.push(`HEAD 가 저장 전과 다릅니다 (${cur || '없음'} ≠ ${snap.head || '없음'})`);
let indexClean = false; try { await sh(['diff', '--cached', '--quiet']); indexClean = true; } catch (_) { /* 차이 있음 또는 실패 */ }
if (!indexClean) problems.push('인덱스에 커밋되지 않은 항목이 남아 있습니다'); else if (!snap.indexClean) notes.push('저장 전에 인덱스에 있던 항목은 비워졌습니다(커밋되지 않은 채 작업 트리에 남음)');
if (snap.branch) { const b = (await sh(['rev-parse', '--verify', '-q', `refs/heads/${snap.branch}`]).catch(() => '')) || null; if (b !== snap.branchWas) problems.push(`변경 브랜치 ${snap.branch} 가 저장 전과 다릅니다`); }
if (snap.paths) { for (const p of statusPaths(await sh(['status', '--porcelain']))) if (!snap.paths.has(p)) problems.push(`작업 트리에 저장 전에 없던 변경이 남아 있습니다: ${p}`); }
else problems.push('저장 전 작업 트리 상태를 알 수 없어 검증하지 못했습니다');
} catch (e) { problems.push(`되돌리기 검증 실패: ${e.message}`); }
return { ok: problems.length === 0, problems, notes };
}
/** rollbackLocal 을 사이트 큐 안에서(외부 호출용). 결과는 검증된 {ok, problems, notes} */
function rollbackTo(siteId, siteDir, snap, hooks) { return enqueue(siteId, () => rollbackLocal(siteId, siteDir, snap, hooks)); }
/**
* 직접 편집 저장 트랜잭션. 한 큐 항목 안에서 순서대로(모두 try 안 — 어느 단계에서 실패해도 onRollback 이 불린다. 페이지는 호출 전에 이미 바뀌었기 때문):
* [prepare] 저장소 유무 확인 → initSite → 원격 설정 → 스냅샷(add 전) → [commit] add·commit·변경 브랜치(로컬) → [metadata] onCommitted(rec)(호출자가 사이트 기록을 **원격 푸시 전** 에 저장; 던지면 실패)
* → [push] 원격이 있으면 푸시 전 원격 ref 를 읽고(실패하면 푸시를 시도하지 않은 것이므로 되돌림), main + 변경 브랜치를 **한 번의 원자적 푸시**(`push --atomic`)로 올린다.
* 푸시가 마지막 단계다 — 성공한 푸시 뒤에는 실패할 수 있는 상태 변경을 하지 않는다. 푸시 **전** 에 저장되는 기록은 원격 반영을 주장하지 않는다(`pushed:false, remote:'pending'`).
* 푸시 명령이 오류를 돌려주면(클라이언트 오류는 모호하다 — 원격이 받았을 수도 있다) 원격 ref 를 다시 읽어 판정한다:
* · main·변경 브랜치가 모두 의도한 커밋 → 성공으로 본다(rec.remoteOutcome='accepted', 되돌리지 않음; 조정이 verified 로 바꾼다)
* · 둘 다 푸시 전 값 그대로 → 원격이 바뀌지 않았음이 증명됨 → 되돌리고 로컬·원격을 다시 검증한다(throw, stage 'push')
* · 혼합이거나 원격을 읽을 수 없음 → 되돌리지 **않고** 로컬 커밋·페이지·기록을 pending 으로 남긴 채 rec.remoteOutcome='ambiguous'(+error) 로 돌려준다 — 호출자는 rolledBack 을 주장하지 않고, 조정(기동·저장 뒤·다시 푸시)이 안전하게 해결한다.
* 던지는 오류에 { stage, rollback: {ok, problems, notes} } 를 붙인다. 원격이 없으면 로컬 커밋으로 끝난다(pushed:false, error:NO_REMOTE). 변경이 없으면 rec=null 로 onCommitted 만 부른다. 강제 푸시를 되돌리기에 쓰지 않는다.
* @returns {Promise<object|null>} rec(변경 없음이면 null)
*/
function recordChangeStrict({ siteId, siteDir, no, kind, message, author, onCommitted, onRollback }) {
return enqueue(siteId, async () => {
const repoExisted = fs.existsSync(path.join(GIT_ROOT, siteId, 'HEAD'));
let stage = 'prepare'; let snap = null; let rec = null; let remote = ''; let prefix = 'sites'; let branch = null; let remoteBefore = null;
const sh = (args, opts) => git(siteId, siteDir, args, opts);
try {
await initSite(siteId, siteDir);
({ remote, prefix } = conn());
branch = changeBranch(siteId, no, kind);
snap = await snapshotState(siteId, siteDir, branch, repoExisted);
stage = 'commit';
await sh(['add', '-A']);
const status = await sh(['status', '--porcelain']);
if (status) {
const authorArgs = ['-c', `user.name=${(author && author.name) || 'webhosting-agent'}`, '-c', `user.email=${(author && author.email) || 'webhosting-agent@local'}`];
await sh([...authorArgs, 'commit', '-q', '-m', message]);
const commit = await sh(['rev-parse', 'HEAD']);
await sh(['branch', '-f', branch, 'HEAD']);
let files = []; let stat = '';
try { files = (await sh(['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', 'HEAD'])).split('\n').filter(Boolean).slice(0, 100).map((l) => { const [st, ...rest] = l.split('\t'); return { status: st[0], path: rest[rest.length - 1] }; }); } catch (_) {}
try { stat = (await sh(['show', '--shortstat', '--format=', 'HEAD'])).trim(); } catch (_) {}
rec = { no, kind, branch, commit, message, pushed: false, error: remote ? null : NO_REMOTE, at: new Date().toISOString(), url: branchUrl(branch), commitUrl: commitUrl(commit), files, stat };
if (remote) rec.remote = 'pending'; // 원격 반영은 아직 확인되지 않음 — reconcilePushes 가 ref 로 확인한 뒤에만 pushed:true
}
stage = 'metadata';
if (typeof onCommitted === 'function') await onCommitted(rec);
if (rec && remote) {
stage = 'push';
const mainRef = `refs/heads/${mainBranch(siteId)}`; const branchRef = `refs/heads/${branch}`;
remoteBefore = await readRemoteRefs(siteId, siteDir, prefix); // 실패하면 푸시를 시도하지 않는다 → 원격 불변 → 아래에서 되돌림
try { await sh(['push', '--atomic', '-q', 'origin', `+HEAD:${mainRef}`, `HEAD:${branchRef}`], { timeout: 120000 }); }
catch (e) {
let after = null; try { after = await readRemoteRefs(siteId, siteDir, prefix); } catch (_) { after = null; }
if (after && after[mainRef] === rec.commit && after[branchRef] === rec.commit) { rec.remoteOutcome = 'accepted'; return rec; } // 원격은 받았다 — 클라이언트 쪽 오류였을 뿐
if (after && after[mainRef] === remoteBefore[mainRef] && after[branchRef] === remoteBefore[branchRef]) { e.remoteUnchanged = true; throw e; } // 원격 불변 증명 → 되돌림
rec.remoteOutcome = 'ambiguous';
rec.error = after ? `원격 반영이 혼합 상태라 확인이 필요합니다 (main ${after[mainRef] ? after[mainRef].slice(0, 7) : '없음'} / 변경 브랜치 ${after[branchRef] ? after[branchRef].slice(0, 7) : '없음'}; 푸시 오류: ${e.message})` : `원격 반영을 확인할 수 없습니다 (푸시 오류: ${e.message})`;
return rec;
}
}
return rec;
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.stage = stage;
err.rollback = await rollbackLocal(siteId, siteDir, snap || { repoExisted, unknown: true, head: null, branch, branchWas: null, paths: null }, { onRollback });
if (err.remoteUnchanged && remoteBefore) { // 원격이 바뀌지 않았음을 되돌리기 뒤에 다시 확인한다(확인하지 못하면 되돌렸다고 주장하지 않는다)
try { const again = await readRemoteRefs(siteId, siteDir, prefix); const mainRef = `refs/heads/${mainBranch(siteId)}`; const branchRef = `refs/heads/${branch}`; if (again[mainRef] !== remoteBefore[mainRef] || again[branchRef] !== remoteBefore[branchRef]) err.rollback.problems.push('되돌리기 뒤 원격 ref 가 푸시 전과 다릅니다'); }
catch (e2) { err.rollback.problems.push(`되돌리기 뒤 원격 확인 실패: ${e2.message}`); }
err.rollback.ok = err.rollback.problems.length === 0;
}
throw err;
}
});
}
function info(siteId) {
const { remote, module } = conn();
return { remote: remote ? remote.replace(/^.*[:/]([^/]+\/[^/]+?)(\.git)?$/, '$1') : null, provider: module ? module.id : 'none', mainBranch: mainBranch(siteId), mainUrl: branchUrl(mainBranch(siteId)), enabled: !!remote };
}
module.exports = { recordChange, recordChangeStrict, snapshotState, rollbackTo, reconcilePushes, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint, NO_REMOTE };