'use strict'; // 사이트별 소스를 git 으로 보관하고 원격(Gitea)에 푸시한다. // - 로컬 git 디렉터리: data/git/ (work-tree 는 /var/www/sites/, .git 은 사이트 폴더에 두지 않음) // - 원격 브랜치: sites//main 최신 상태 // sites//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|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} 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 };