9.5 KiB
v0.3.0 — Cut E Design (다기기 git-based 양방향 sync)
작성일: 2026-05-09 선행 문서:
docs/superpowers/specs/2026-04-25-dogfood-feedback.md(F21)docs/superpowers/strategy/v028plus-roadmap.mdCut E
Cut 라벨: v0.3.0 — semver MINOR (새 인프라 — 양방향 sync + Configure UI). Major 영역 진입.
1. Cut 정체성
기존 push-only SyncService → 양방향 (pull + import + conflict resolution + Configure UI). 다기기 (Mac 업무 + Windows 개인) dogfood 가능.
2. 범위
| 항목 | 결정 |
|---|---|
| F21 옵션 A | git fetch && rebase 후 markdown → SQLite re-import. 자동 rebase default (충돌 시 fail + 사용자 prompt) |
| F21 옵션 B | 설정 페이지 안 "동기화 저장소" sub-section — URL 입력 + 인증 안내 + 마지막 sync 결과 |
| F21 옵션 C | conflict UI — 자동 rebase 실패 시 양쪽 비교 + 사용자 선택 |
| pull 시점 | 양쪽 — manual ("지금 동기화") + 자동 주기 (사용자 설정 가능 interval, default 30분) |
| revision 결합 (Cut C) | note_revisions 가 sync 대상 — 양 기기 rev 가 다른 chain 에 있으면 timestamp linear merge (옛 rev 가 sync source 로 inserted) |
3. SyncService 양방향화
3-1. 갱신된 sync() 흐름
async sync(opts: { interval?: boolean } = {}): Promise<SyncStatus> {
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
const git = new GitClient(this.syncDir);
// 1. local export — 현재 SQLite 상태를 syncDir 에 markdown 으로 출력
await this.exportSvc.export(this.syncDir, { includeMedia: true });
await git.addAll();
const localChanged = await git.hasUncommittedChanges();
// 2. local commit (변경 있으면)
let localSha: string | null = null;
if (localChanged) {
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
localSha = c.sha;
}
// 3. fetch
const fetchR = await git.fetch();
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
// 4. rebase onto origin/main
const rebaseR = await git.rebaseOnto('origin/main');
if (rebaseR.exitCode !== 0) {
// conflict — abort + conflict 목록 반환 (UI 가 활성)
await git.rebaseAbort();
return { ok: false, reason: 'conflict', conflicts: await this.listConflictsFromMarkdown() };
}
// 5. re-import (rebase 후 markdown 변경 → SQLite upsertFromSync)
const imported = await this.importSvc.applySyncFromDir(this.syncDir);
// 6. push
try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; }
return { ok: true, changed: localChanged || imported.changedCount > 0, localSha, importedCount: imported.changedCount, pushed: true };
}
6 단계 흐름 — local export 가 fetch 보다 먼저 (Cut E 정정): spec 초안은 fetch 우선이었으나, local export → commit 후 fetch + rebase 가 git workflow 표준 (rebase 가 local commit 위에 origin commit 적용). local export 안 한 상태로 fetch + rebase → 혼란 발생.
SyncStatus 인터페이스 확장:
export interface SyncStatus {
ok: boolean;
reason?: string;
changed?: boolean;
localSha?: string | null;
pushed?: boolean;
importedCount?: number;
conflicts?: Array<{ noteId: string; localText: string; remoteText: string }>; // reason='conflict' 시
}
3-2. ImportService 활용 (실제 코드 정정)
기존 ImportService 는 run(sourceDir) 메서드 (백업 복원 흐름) — parsedToInput → repo.importNote() 호출. spec 작성 시 가정한 importAll(dir) 시그니처는 실재 코드와 다름.
repo.importNote() 의 기존 conflict 정책 (export tree 복원용):
- id 없음 → INSERT (
status: 'inserted') - id 있음 + raw_text 동일 → no-op (
status: 'skipped') - id 있음 + raw_text 다름 → fork-on-id-collision (fresh uuidv7) (
status: 'forked')
Cut E sync 정책 — fork 미적합, in-place update + revision 보존:
sync 에서 양 기기 raw_text 가 다를 때 fork 하면 노트 갯수 무한 증가 → 부적합. 신설 메서드 repo.upsertFromSync(input):
- id 없음 → INSERT (m006 trigger 가 capture revision 자동 생성)
- id 있음 + raw_text 동일 → metadata 갱신 path
- source.updatedAt > local.updatedAt 인 경우만 ai_title/ai_summary/tags/status/dueDate 갱신
- tags 변경 시
rebuildFtsTagsForNote호출 (Cut D single write path) - 동등/older 면 skip
- id 있음 + raw_text 다름 → 옵션 분기:
- source.updatedAt > local.updatedAt →
updateRawText(id, sourceRawText, sourceUpdatedAt)(Cut C single write path) → 새 user revision INSERT, latest = source - local.updatedAt > source.updatedAt → skip (다음 push 가 source 갱신할 것)
- 동일 timestamp + 다른 raw_text → SyncService 가 conflict 마킹 (rebase 단계 git markdown conflict 가 먼저 잡힘 — 본 분기는 rare)
- source.updatedAt > local.updatedAt →
revision edited_by: 'sync' enum 추가 안 함 — updateRawText 의 default 'user' 그대로 활용 (sync = user-edited 변경 전파 = 의미상 user). YAGNI: m008 회피.
3-3. GitClient 확장
class GitClient {
// 기존: run, isRepo, hasRemote, addAll, commit, push
// 신규
async fetch(): Promise<GitExecResult>;
async rebaseOnto(ref: string): Promise<GitExecResult>;
async rebaseAbort(): Promise<GitExecResult>;
async hasUncommittedChanges(): Promise<boolean>;
async listConflicts(): Promise<string[]>; // git diff --name-only --diff-filter=U
}
4. Configure UI (옵션 B)
설정 페이지 → 신규 sub-section "동기화 저장소":
[동기화 저장소]
저장소 URL: [git@gitea.example.com:user/inkling-notes.git]
[ 저장 ] [ 연결 테스트 ]
마지막 sync: 2026-05-09 14:32 (성공, 3건 가져옴, 2건 보냄)
다음 자동 sync: 2026-05-09 15:02
[ 자동 sync 사용 ]
interval: [30] 분
[ 지금 동기화 ] [ 충돌 해결... ]
저장소 URL 변경 → main 의 settings:configure-sync IPC 호출 → SyncService 가 <profileDir>/sync/ 에 git init + remote add origin (없으면). 인증 (SSH key / token) 은 사용자 OS 설정 (~/.ssh/ 또는 git credential helper) — Inkling 자체 인증 X, 안내 메시지만.
5. Conflict UI (옵션 C)
자동 rebase 실패 시 SyncService 가 { ok: false, reason: 'conflict', conflicts: [...] } 반환. 설정 페이지 의 "충돌 해결..." 버튼 활성화.
클릭 → modal:
충돌 N건
[note-id-1.md]
< 내 기기 > | < 다른 기기 >
본문 A | 본문 B
|
[ 내 것 사용 ] [ 원격 사용 ] [ 양쪽 보존 (옛 revision 으로) ]
선택:
- 내 것 사용: local 채택 (origin 변경 폐기)
- 원격 사용: origin 채택 (local 변경 → note_revisions 에 보존)
- 양쪽 보존: local + origin 모두 note_revisions 에 INSERT, latest = 사용자 선택 (또는 timestamp 더 최신)
확정 → SyncService.resolveConflict(noteId, choice) → git rebase --continue → push.
6. 자동 주기 sync
main process 가 settings.sync_interval_min (default 30) 마다 SyncService.sync({ interval: true }) 호출. interval=true 시 conflict 발생해도 silent (notification 만, 사용자가 다음 manual 또는 conflict UI 진입 시 처리).
settings: sync_auto_enabled: boolean (default true 단, configured 일 때만), sync_interval_min: number (default 30, min 5).
7. IPC
// 신규
'settings:configure-sync': (url: string) => Promise<{ ok: true } | { ok: false; reason: string }>
'settings:test-sync-connection': () => Promise<{ ok: true } | { ok: false; reason: string }> // git ls-remote
'sync:list-conflicts': () => Promise<Array<{ noteId: string; localText: string; remoteText: string }>>
'sync:resolve-conflict': (noteId: string, choice: 'local' | 'remote' | 'both') => Promise<{ ok: true }>
'sync:get-status': () => Promise<{ lastAt: string | null; lastResult: SyncStatus | null; nextAt: string | null }>
8. 테스트 전략
| 영역 | 단위 |
|---|---|
GitClient.fetch / rebaseOnto / rebaseAbort |
mock execFile + 결과 검증 |
SyncService.sync 양방향 |
mock GitClient + ImportService → 6 단계 흐름 |
| 자동 rebase 성공 | conflict 없는 시나리오 |
| 자동 rebase 실패 → abort | conflict 시 rebaseAbort + reason 반환 |
| ImportService.importAll | markdown → notes UPSERT + revision INSERT |
| revision merge | 양 chain → timestamp 순 linear |
| Configure UI | URL 입력 → IPC → git init/remote add |
| Conflict UI | 3 choice 별 sync 동작 |
| 자동 주기 sync | timer + interval=true mode |
목표: 단위 608 → 약 635 (+27), typecheck 0.
9. Risk
| Risk | 대응 |
|---|---|
| 인증 설정 실패 (사용자 SSH key 부재) | Configure UI 의 "연결 테스트" 버튼 — git ls-remote 결과 사용자에게 표시 |
| revision linear merge 정확도 | timestamp 단조 증가 가정 (양 기기 시계 동기화). NTP 부재 시 충돌 risk → 사용자 prompt |
| 자동 주기 sync 의 silent 충돌 누적 | interval mode 충돌 시 notification + 충돌 UI 자동 popup option |
| Cut C revision history 와 sync 결합 시 chain 분기 | 본 cut 의 정책: timestamp linear, branch 분기 미지원 (사용자 manual 결정으로 처리) |
10. v0.3.0 후
Cut F (v0.3.1) — F24 멀티모달 vision.
dogfood verify:
- Mac + Windows 양 기기 sync 1주 — 충돌 빈도 측정
- 자동 주기 sync 의 timing — battery / network 영향
- revision merge 정확도 (사용자 confirm 비율)