Files
inkling/docs/superpowers/specs/2026-05-09-v030-cut-e-design.md

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.md Cut 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) 메서드 (백업 복원 흐름) — parsedToInputrepo.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)

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:

  1. Mac + Windows 양 기기 sync 1주 — 충돌 빈도 측정
  2. 자동 주기 sync 의 timing — battery / network 영향
  3. revision merge 정확도 (사용자 confirm 비율)