[verified] feat: add Webmaker visual editor v0.2.0
This commit is contained in:
@@ -140,22 +140,41 @@ function squashToCurrent(siteId, siteDir, message) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 푸시 실패분 재시도 */
|
||||
function retryPush(siteId, siteDir, recs) {
|
||||
/**
|
||||
* 원격 반영 조정(푸시 실패분 재시도의 후속, 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 () => {
|
||||
if (!conn().remote) return recs;
|
||||
const { remote, prefix } = conn(); if (!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; }
|
||||
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);
|
||||
}
|
||||
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; } }
|
||||
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) {
|
||||
@@ -166,9 +185,134 @@ function restoreTo(siteId, siteDir, commit) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 직접 편집 저장 트랜잭션(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, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint };
|
||||
module.exports = { recordChange, recordChangeStrict, snapshotState, rollbackTo, reconcilePushes, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint, NO_REMOTE };
|
||||
|
||||
Reference in New Issue
Block a user