'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; }); } /** 푸시 실패분 재시도 */ function retryPush(siteId, siteDir, recs) { return enqueue(siteId, async () => { if (!conn().remote) return recs; await initSite(siteId, siteDir); // v1.3.17 (B07): 예전에는 실패했던 옛 기록마다 그 커밋을 main 에 강제로 올려, 새 버전이 이미 올라간 뒤에 main 이 옛 내용으로 // 되감길 수 있었다. 이제 옛 기록은 자기 변경 브랜치에만 올리고, main 은 늘 지금 HEAD(가장 새 버전)로 맞춘다. for (const r of recs) { if (r.pushed) continue; try { if (r.kind !== 'deploy' && r.branch) await git(siteId, siteDir, ['push', '-q', 'origin', `${r.commit}:refs/heads/${r.branch}`], { timeout: 120000 }); r.pushed = true; r.error = null; } catch (e) { r.error = e.message; } } try { await git(siteId, siteDir, ['push', '-q', 'origin', `+HEAD:refs/heads/${mainBranch(siteId)}`], { timeout: 120000 }); } catch (e) { for (const r of recs) if (r.pushed && !r.error) { r.pushed = false; r.error = e.message; } } return recs; }); } /** 작업 트리를 지정 커밋의 내용으로 되돌린다(추적 파일 기준). 이후 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; }); } 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, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint };