# 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() 흐름

```ts
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` 인터페이스 확장:

```ts
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)

**revision edited_by**: 'sync' enum 추가 안 함 — `updateRawText` 의 default 'user' 그대로 활용 (sync = user-edited 변경 전파 = 의미상 user). YAGNI: m008 회피.

### 3-3. GitClient 확장

```ts
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

```ts
// 신규
'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 비율)
