1159 lines
46 KiB
Markdown
1159 lines
46 KiB
Markdown
# v0.3.0 Cut E — 양방향 git sync + Configure UI + Conflict resolution Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** F21 — 다기기 (Mac 업무 + Windows 개인) git-based 양방향 sync. push-only `SyncService` → fetch + rebase + re-import + push 6단계 흐름 + Configure UI + Conflict resolution + 자동 주기 sync.
|
|
|
|
**Architecture:** `SyncService.sync()` 가 (1) export → (2) commit → (3) fetch → (4) rebase → (5) re-import (`upsertFromSync` single write path) → (6) push. rebase 충돌 시 `rebaseAbort` + conflicts 반환 → 설정 페이지 Conflict modal. settings 에 `sync_repo_url` / `sync_auto_enabled` / `sync_interval_min` 추가. main process 의 timer 가 interval=true 모드 sync 주기 호출.
|
|
|
|
**Tech Stack:** Node child_process git CLI (better-sqlite3 미사용 path), Electron IPC, React 19 + zustand 5, vitest 4 + RTL.
|
|
|
|
**선행 문서:**
|
|
|
|
- `docs/superpowers/specs/2026-05-09-v030-cut-e-design.md` — source spec (단위 608 기준 + ImportService.run 활용 + 'sync' enum 미도입 정정)
|
|
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F21
|
|
- `docs/superpowers/strategy/v028plus-roadmap.md` — Cut E 위치 (semver MINOR — Major 영역 진입)
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Create:**
|
|
|
|
- `src/renderer/inbox/components/settings/SyncSection.tsx` — Configure UI (URL 입력 + 자동 sync 토글 + interval + 지금 동기화 + 충돌 해결 버튼)
|
|
- `src/renderer/inbox/components/ConflictModal.tsx` — local/remote/both 선택 UI
|
|
- `src/main/services/SyncTimer.ts` — main process 의 자동 주기 sync timer (settings.sync_interval_min)
|
|
- `tests/unit/GitClient.fetch.test.ts` — fetch/rebase mock execFile 테스트
|
|
- `tests/unit/SyncService.bidirectional.test.ts` — 양방향 6단계 흐름 + conflict 시나리오
|
|
- `tests/unit/SyncService.resolveConflict.test.ts` — 3 choice 동작
|
|
- `tests/unit/NoteRepository.upsertFromSync.test.ts` — 3 분기 시나리오
|
|
- `tests/unit/ImportService.applySyncFromDir.test.ts` — sourceDir 흐름
|
|
- `tests/unit/SyncSection.test.tsx` — UI 단위
|
|
- `tests/unit/ConflictModal.test.tsx` — modal 단위
|
|
- `tests/unit/SyncTimer.test.ts` — timer + interval mode
|
|
|
|
**Modify:**
|
|
|
|
- `src/main/services/GitClient.ts` — `fetch` / `rebaseOnto` / `rebaseAbort` / `hasUncommittedChanges` / `listConflicts` 5 메서드 추가
|
|
- `src/main/services/SyncService.ts` — `sync()` 6단계 양방향 흐름 + `resolveConflict()` 신규 + `listConflicts()` 신규 + SyncStatus 확장
|
|
- `src/main/services/ImportService.ts` — `applySyncFromDir(dir)` 신규 (parsedToInput → repo.upsertFromSync 호출)
|
|
- `src/main/repository/NoteRepository.ts` — `upsertFromSync(input)` 신규 (3 분기 — INSERT / metadata-only / updateRawText)
|
|
- `src/main/services/SettingsService.ts` — sync_repo_url / sync_auto_enabled / sync_interval_min 필드 (zod schema 확장 + getter/setter)
|
|
- `src/main/ipc/settingsApi.ts` — 5 IPC handler (`settings:configure-sync` / `settings:test-sync-connection` / `sync:list-conflicts` / `sync:resolve-conflict` / `sync:get-status`)
|
|
- `src/preload/index.ts` — 5 bridge 함수
|
|
- `src/shared/types.ts` — `SyncStatus` 확장 + `ConflictRow` interface + InboxApi 5 메서드
|
|
- `src/main/index.ts` — SyncTimer 인스턴스 생성 + start (settings 변경 시 reconfigure)
|
|
- `src/renderer/inbox/components/SettingsPage.tsx` — SyncSection mount
|
|
- `package.json` — version `0.2.11` → `0.3.0`
|
|
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F21 promoted (A+B+C 옵션) + Cut E 라벨
|
|
|
|
---
|
|
|
|
## 단위 목표
|
|
|
|
608 (v0.2.11) → 약 635 (+27), typecheck 0.
|
|
|
|
---
|
|
|
|
## Task 1: GitClient 확장 — fetch / rebaseOnto / rebaseAbort / hasUncommittedChanges / listConflicts
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/main/services/GitClient.ts`
|
|
- Create: `tests/unit/GitClient.fetch.test.ts`
|
|
|
|
기존 `GitClient.run(args)` 로 git CLI 실행. 5 메서드 추가 — 모두 thin wrapper. `listConflicts` 는 `git diff --name-only --diff-filter=U` 결과 파싱.
|
|
|
|
- [ ] **Step 1: failing test 작성** — `tests/unit/GitClient.fetch.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { GitClient } from '../../src/main/services/GitClient.js';
|
|
|
|
describe('GitClient — fetch / rebase / conflict 메서드', () => {
|
|
let client: GitClient;
|
|
let runSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
client = new GitClient('/tmp/sync');
|
|
runSpy = vi.spyOn(client, 'run');
|
|
});
|
|
|
|
it('fetch — git fetch origin 호출', async () => {
|
|
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
const r = await client.fetch();
|
|
expect(runSpy).toHaveBeenCalledWith(['fetch', 'origin']);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
it('rebaseOnto — git rebase origin/main', async () => {
|
|
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
const r = await client.rebaseOnto('origin/main');
|
|
expect(runSpy).toHaveBeenCalledWith(['rebase', 'origin/main']);
|
|
expect(r.exitCode).toBe(0);
|
|
});
|
|
|
|
it('rebaseAbort — git rebase --abort', async () => {
|
|
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
await client.rebaseAbort();
|
|
expect(runSpy).toHaveBeenCalledWith(['rebase', '--abort']);
|
|
});
|
|
|
|
it('hasUncommittedChanges — git status --porcelain 의 출력 있으면 true', async () => {
|
|
runSpy.mockResolvedValueOnce({ stdout: ' M notes/abc.md\n', stderr: '', exitCode: 0 });
|
|
expect(await client.hasUncommittedChanges()).toBe(true);
|
|
|
|
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
expect(await client.hasUncommittedChanges()).toBe(false);
|
|
});
|
|
|
|
it('listConflicts — git diff --name-only --diff-filter=U 결과 파싱', async () => {
|
|
runSpy.mockResolvedValueOnce({
|
|
stdout: 'notes/aaa.md\nnotes/bbb.md\n',
|
|
stderr: '',
|
|
exitCode: 0
|
|
});
|
|
const r = await client.listConflicts();
|
|
expect(runSpy).toHaveBeenCalledWith(['diff', '--name-only', '--diff-filter=U']);
|
|
expect(r).toEqual(['notes/aaa.md', 'notes/bbb.md']);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: test FAIL** — 5 메서드 미정의.
|
|
|
|
- [ ] **Step 3: GitClient 메서드 추가** — `src/main/services/GitClient.ts` 의 클래스 안 (push 메서드 다음):
|
|
|
|
```ts
|
|
async fetch(remote: string = 'origin'): Promise<GitExecResult> {
|
|
return this.run(['fetch', remote]);
|
|
}
|
|
|
|
async rebaseOnto(ref: string): Promise<GitExecResult> {
|
|
return this.run(['rebase', ref]);
|
|
}
|
|
|
|
async rebaseAbort(): Promise<GitExecResult> {
|
|
return this.run(['rebase', '--abort']);
|
|
}
|
|
|
|
async hasUncommittedChanges(): Promise<boolean> {
|
|
const r = await this.run(['status', '--porcelain']);
|
|
return r.stdout.trim().length > 0;
|
|
}
|
|
|
|
/** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */
|
|
async listConflicts(): Promise<string[]> {
|
|
const r = await this.run(['diff', '--name-only', '--diff-filter=U']);
|
|
return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: tests + typecheck PASS**
|
|
|
|
```bash
|
|
npm run typecheck
|
|
npx vitest run tests/unit/GitClient.fetch.test.ts
|
|
```
|
|
|
|
- [ ] **Step 5: commit**
|
|
|
|
```bash
|
|
git add src/main/services/GitClient.ts tests/unit/GitClient.fetch.test.ts
|
|
git commit -m "feat(v030): GitClient — fetch/rebaseOnto/rebaseAbort/hasUncommittedChanges/listConflicts"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: NoteRepository.upsertFromSync — sync 전용 3 분기 upsert
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/main/repository/NoteRepository.ts`
|
|
- Create: `tests/unit/NoteRepository.upsertFromSync.test.ts`
|
|
|
|
기존 `importNote` 의 fork-on-conflict 정책은 sync 부적합 (양 기기 raw_text 다를 때마다 노트 갯수 증가). 신설 `upsertFromSync(input)`:
|
|
|
|
- id 없음 → INSERT (capture revision 자동 by m006 trigger + create path)
|
|
- id 있음 + raw_text 동일 → metadata 갱신 path (source.updatedAt > local.updatedAt 일 때만 ai_title/ai_summary/tags/status/dueDate 갱신, tags 변경 시 `rebuildFtsTagsForNote`)
|
|
- id 있음 + raw_text 다름 → source.updatedAt > local.updatedAt 면 `updateRawText` (Cut C path) → 새 user revision INSERT
|
|
|
|
`UpsertFromSyncInput` interface 신규 (importNote 의 ImportNoteInput 와 비슷하지만 status/moveReason 포함):
|
|
|
|
```ts
|
|
export interface UpsertFromSyncInput {
|
|
id: string;
|
|
rawText: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
aiTitle: string | null;
|
|
aiSummary: string | null;
|
|
titleEditedByUser: boolean;
|
|
summaryEditedByUser: boolean;
|
|
aiProvider: string | null;
|
|
aiGeneratedAt: string | null;
|
|
userIntent: string | null;
|
|
intentPromptedAt: string | null;
|
|
tags: { name: string; source: 'ai' | 'user' }[];
|
|
status: NoteStatus;
|
|
statusChangedAt: string | null;
|
|
moveReason: string | null;
|
|
dueDate: string | null;
|
|
dueDateEditedByUser: boolean;
|
|
}
|
|
|
|
export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped';
|
|
```
|
|
|
|
- [ ] **Step 1: failing tests** — `tests/unit/NoteRepository.upsertFromSync.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
|
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
|
|
|
const baseInput = {
|
|
id: '00000000-0000-0000-0000-000000000001',
|
|
rawText: 'sync 본문',
|
|
createdAt: '2026-05-09T00:00:00Z',
|
|
updatedAt: '2026-05-10T00:00:00Z',
|
|
aiTitle: 'sync 제목',
|
|
aiSummary: 'sync 요약',
|
|
titleEditedByUser: false,
|
|
summaryEditedByUser: false,
|
|
aiProvider: 'p',
|
|
aiGeneratedAt: '2026-05-10T00:00:00Z',
|
|
userIntent: null,
|
|
intentPromptedAt: null,
|
|
tags: [{ name: '동기', source: 'user' as const }],
|
|
status: 'active' as const,
|
|
statusChangedAt: null,
|
|
moveReason: null,
|
|
dueDate: null,
|
|
dueDateEditedByUser: false
|
|
};
|
|
|
|
describe('NoteRepository.upsertFromSync', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
db.pragma('foreign_keys = ON');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
});
|
|
|
|
afterEach(() => { db.close(); });
|
|
|
|
it('id 없음 → INSERT (status=inserted) + capture revision + tags FTS sync', () => {
|
|
const r = repo.upsertFromSync(baseInput);
|
|
expect(r.status).toBe('inserted');
|
|
expect(r.id).toBe(baseInput.id);
|
|
const note = repo.findById(baseInput.id);
|
|
expect(note?.rawText).toBe('sync 본문');
|
|
expect(note?.aiTitle).toBe('sync 제목');
|
|
const revs = repo.listRevisions(baseInput.id);
|
|
expect(revs).toHaveLength(1);
|
|
expect(revs[0]!.editedBy).toBe('capture');
|
|
const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(baseInput.id) as { tags: string };
|
|
expect(fts.tags).toBe('동기');
|
|
});
|
|
|
|
it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => {
|
|
const created = repo.create({ rawText: 'sync 본문' });
|
|
repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' });
|
|
// 충분히 옛 updatedAt
|
|
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
|
|
const r = repo.upsertFromSync({ ...baseInput, id: created.id });
|
|
expect(r.status).toBe('updated');
|
|
const note = repo.findById(created.id);
|
|
expect(note?.aiTitle).toBe('sync 제목');
|
|
expect(note?.tags.map((t) => t.name)).toEqual(['동기']);
|
|
// FTS tags sync
|
|
const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(created.id) as { tags: string };
|
|
expect(fts.tags).toBe('동기');
|
|
});
|
|
|
|
it('id 있음 + raw_text 동일 + source 더 옛 → skip (status=skipped)', () => {
|
|
const created = repo.create({ rawText: 'sync 본문' });
|
|
repo.updateAiResult(created.id, { title: '신선한 제목', summary: 'fresh', tags: ['x'], provider: 'p' });
|
|
// local 이 더 최신
|
|
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-12T00:00:00Z', created.id);
|
|
const r = repo.upsertFromSync({ ...baseInput, id: created.id, updatedAt: '2026-05-10T00:00:00Z' });
|
|
expect(r.status).toBe('skipped');
|
|
const note = repo.findById(created.id);
|
|
expect(note?.aiTitle).toBe('신선한 제목');
|
|
});
|
|
|
|
it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => {
|
|
const created = repo.create({ rawText: 'old text' });
|
|
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
|
|
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' });
|
|
expect(r.status).toBe('updated');
|
|
const note = repo.findById(created.id);
|
|
expect(note?.rawText).toBe('new sync text');
|
|
const revs = repo.listRevisions(created.id);
|
|
expect(revs).toHaveLength(2); // capture (old) + user (new)
|
|
expect(revs[0]!.editedBy).toBe('user');
|
|
expect(revs[0]!.rawText).toBe('new sync text');
|
|
});
|
|
|
|
it('id 있음 + raw_text 다름 + source 더 옛 → skip', () => {
|
|
const created = repo.create({ rawText: 'local fresh' });
|
|
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
|
|
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'old sync text', updatedAt: '2026-05-10T00:00:00Z' });
|
|
expect(r.status).toBe('skipped');
|
|
const note = repo.findById(created.id);
|
|
expect(note?.rawText).toBe('local fresh');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: test FAIL** — `repo.upsertFromSync` 미정의.
|
|
|
|
- [ ] **Step 3: 구현** — `src/main/repository/NoteRepository.ts` 의 import 옆 (`ImportNoteInput` 다음) 에 `UpsertFromSyncInput` / `UpsertFromSyncStatus` interface 추가. `importNote` 메서드 다음에 추가:
|
|
|
|
```ts
|
|
/**
|
|
* v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은
|
|
* sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가).
|
|
*
|
|
* 3 분기:
|
|
* - id 없음 → create() path (capture revision INSERT)
|
|
* - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신
|
|
* - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision),
|
|
* local 이 더 최신이면 skip
|
|
*
|
|
* tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용.
|
|
* status / dueDate / moveReason 는 metadata 갱신 path 에서 sync.
|
|
*/
|
|
upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } {
|
|
const existing = this.db
|
|
.prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`)
|
|
.get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined;
|
|
|
|
if (!existing) {
|
|
// INSERT path — create() 가 capture revision + pending_jobs 처리.
|
|
const tx = this.db.transaction(() => {
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO notes
|
|
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
|
|
title_edited_by_user, summary_edited_by_user,
|
|
user_intent, intent_prompted_at,
|
|
created_at, updated_at,
|
|
due_date, due_date_edited_by_user,
|
|
status, status_changed_at, move_reason)
|
|
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
input.id,
|
|
input.rawText,
|
|
input.aiTitle,
|
|
input.aiSummary,
|
|
input.aiProvider,
|
|
input.aiGeneratedAt,
|
|
input.titleEditedByUser ? 1 : 0,
|
|
input.summaryEditedByUser ? 1 : 0,
|
|
input.userIntent,
|
|
input.intentPromptedAt,
|
|
input.createdAt,
|
|
input.updatedAt,
|
|
input.dueDate,
|
|
input.dueDateEditedByUser ? 1 : 0,
|
|
input.status,
|
|
input.statusChangedAt,
|
|
input.moveReason
|
|
);
|
|
this.db
|
|
.prepare(
|
|
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
|
VALUES (?, ?, ?, 'capture')`
|
|
)
|
|
.run(input.id, input.rawText, input.createdAt);
|
|
if (input.tags.length > 0) {
|
|
const getOrInsertTag = this.db.prepare(
|
|
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
|
);
|
|
const linkAi = this.db.prepare(
|
|
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
|
|
);
|
|
const linkUser = this.db.prepare(
|
|
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
|
|
);
|
|
for (const t of input.tags) {
|
|
const row = getOrInsertTag.get(t.name) as { id: number };
|
|
if (t.source === 'ai') linkAi.run(input.id, row.id);
|
|
else linkUser.run(input.id, row.id);
|
|
}
|
|
this.rebuildFtsTagsForNote(input.id);
|
|
}
|
|
});
|
|
tx();
|
|
return { id: input.id, status: 'inserted' };
|
|
}
|
|
|
|
// 기존 노트 — source.updatedAt > local.updatedAt 일 때만 갱신
|
|
if (input.updatedAt <= existing.updated_at) {
|
|
return { id: input.id, status: 'skipped' };
|
|
}
|
|
|
|
if (existing.raw_text !== input.rawText) {
|
|
// raw_text 변경 path — Cut C updateRawText 재사용 (new user revision INSERT)
|
|
this.updateRawText(input.id, input.rawText, new Date(input.updatedAt));
|
|
}
|
|
|
|
// metadata 갱신 (raw_text 동일이거나 위에서 이미 갱신된 경우)
|
|
const tx = this.db.transaction(() => {
|
|
this.db
|
|
.prepare(
|
|
`UPDATE notes
|
|
SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
|
|
ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
|
|
ai_provider = ?,
|
|
ai_generated_at = ?,
|
|
due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
|
|
status = ?,
|
|
status_changed_at = ?,
|
|
move_reason = ?,
|
|
updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(
|
|
input.aiTitle,
|
|
input.aiSummary,
|
|
input.aiProvider,
|
|
input.aiGeneratedAt,
|
|
input.dueDate,
|
|
input.status,
|
|
input.statusChangedAt,
|
|
input.moveReason,
|
|
input.updatedAt,
|
|
input.id
|
|
);
|
|
// tags — DELETE 후 INSERT (importNote 패턴) + FTS sync
|
|
this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id);
|
|
if (input.tags.length > 0) {
|
|
const getOrInsertTag = this.db.prepare(
|
|
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
|
);
|
|
const linkAi = this.db.prepare(
|
|
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
|
|
);
|
|
const linkUser = this.db.prepare(
|
|
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
|
|
);
|
|
for (const t of input.tags) {
|
|
const row = getOrInsertTag.get(t.name) as { id: number };
|
|
if (t.source === 'ai') linkAi.run(input.id, row.id);
|
|
else linkUser.run(input.id, row.id);
|
|
}
|
|
}
|
|
this.rebuildFtsTagsForNote(input.id);
|
|
});
|
|
tx();
|
|
return { id: input.id, status: 'updated' };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: test PASS** — 5/5.
|
|
|
|
- [ ] **Step 5: commit**
|
|
|
|
```bash
|
|
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.upsertFromSync.test.ts
|
|
git commit -m "feat(v030): NoteRepository.upsertFromSync — sync 전용 3 분기 upsert + single write path"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: ImportService.applySyncFromDir — sourceDir 의 .md → upsertFromSync
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/main/services/ImportService.ts`
|
|
- Create: `tests/unit/ImportService.applySyncFromDir.test.ts`
|
|
|
|
기존 `run(sourceDir)` 가 `parsedToInput → repo.importNote()`. sync 용 신설 메서드는 `parsedToInput → repo.upsertFromSync()` 호출. parsedToInput 시그니처는 status / dueDate / moveReason 포함하도록 확장 (parseExportNote 가 이미 frontmatter 에서 추출).
|
|
|
|
> NOTE: `parseExportNote` 의 ParsedNote 가 status/dueDate/moveReason 필드를 노출 안 할 수 있음. 본 task 시작 전 `src/main/services/importFormat.ts` 와 `src/main/services/exportFormat.ts` 를 확인. status frontmatter 미포함이면 ParsedNote 갱신 + ExportService 의 frontmatter writer 갱신 모두 필요. Spec 2026-05-09 작성 시점 기준, 이 부분은 확인 후 plan 보강.
|
|
|
|
- [ ] **Step 1: 의존 시그니처 확인** — `parseExportNote` 가 반환하는 ParsedNote 가 status/statusChangedAt/moveReason/dueDate 모두 포함하는지 확인. 미포함 시:
|
|
- `src/main/services/importFormat.ts` — frontmatter 파서에 status / statusChangedAt / moveReason / dueDate / dueDateEditedByUser 필드 추가
|
|
- `src/main/services/exportFormat.ts` — frontmatter writer 에 동일 필드 추가
|
|
- 기존 importNote 흐름은 ParsedNote 의 신규 필드 사용 안 해도 무관 (existing tests preserve)
|
|
|
|
- [ ] **Step 2: failing test** — `tests/unit/ImportService.applySyncFromDir.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
|
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
|
import { ImportService } from '../../src/main/services/ImportService.js';
|
|
import { MediaStore } from '../../src/main/services/MediaStore.js';
|
|
|
|
describe('ImportService.applySyncFromDir', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let svc: ImportService;
|
|
let workDir: string;
|
|
|
|
beforeEach(async () => {
|
|
db = new Database(':memory:');
|
|
db.pragma('foreign_keys = ON');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
workDir = await mkdtemp(join(tmpdir(), 'inkling-sync-'));
|
|
const mediaStore = new MediaStore(workDir);
|
|
svc = new ImportService(repo, mediaStore);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
db.close();
|
|
await rm(workDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('inserts new notes and reports changedCount', async () => {
|
|
const notesDir = join(workDir, 'notes');
|
|
await mkdir(notesDir, { recursive: true });
|
|
await writeFile(
|
|
join(notesDir, 'a.md'),
|
|
`---\nid: 00000000-0000-0000-0000-000000000001\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\nai_title: title\nai_summary: summary\nstatus: active\n---\n\nbody\n`
|
|
);
|
|
const r = await svc.applySyncFromDir(workDir);
|
|
expect(r.changedCount).toBe(1);
|
|
const note = repo.findById('00000000-0000-0000-0000-000000000001');
|
|
expect(note?.rawText).toBe('body');
|
|
});
|
|
|
|
it('skips unchanged notes (no changedCount increment)', async () => {
|
|
const created = repo.create({ rawText: 'body' });
|
|
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
|
|
const notesDir = join(workDir, 'notes');
|
|
await mkdir(notesDir, { recursive: true });
|
|
await writeFile(
|
|
join(notesDir, 'a.md'),
|
|
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\nai_title: t\nai_summary: s\nstatus: active\n---\n\nbody\n`
|
|
);
|
|
const r = await svc.applySyncFromDir(workDir);
|
|
expect(r.changedCount).toBe(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
(Two tests — minimal coverage to validate the path. More-detailed scenarios in `upsertFromSync` test.)
|
|
|
|
- [ ] **Step 3: implementation** — `src/main/services/ImportService.ts` 안에 신규 메서드 + helper:
|
|
|
|
```ts
|
|
async applySyncFromDir(dir: string): Promise<{ changedCount: number }> {
|
|
const files = await this.scanNotes(dir);
|
|
let changedCount = 0;
|
|
for (const f of files) {
|
|
const content = await readFile(f, 'utf8');
|
|
const parsed = parseExportNote(content);
|
|
const r = this.repo.upsertFromSync({
|
|
id: parsed.id,
|
|
rawText: parsed.rawText,
|
|
createdAt: parsed.createdAt,
|
|
updatedAt: parsed.updatedAt,
|
|
aiTitle: parsed.aiTitle,
|
|
aiSummary: parsed.aiSummary,
|
|
titleEditedByUser: parsed.titleEditedByUser,
|
|
summaryEditedByUser: parsed.summaryEditedByUser,
|
|
aiProvider: parsed.aiProvider,
|
|
aiGeneratedAt: parsed.aiGeneratedAt,
|
|
userIntent: parsed.userIntent,
|
|
intentPromptedAt: parsed.intentPromptedAt,
|
|
tags: parsed.tags,
|
|
status: parsed.status ?? 'active',
|
|
statusChangedAt: parsed.statusChangedAt ?? null,
|
|
moveReason: parsed.moveReason ?? null,
|
|
dueDate: parsed.dueDate ?? null,
|
|
dueDateEditedByUser: parsed.dueDateEditedByUser ?? false
|
|
});
|
|
if (r.status !== 'skipped') changedCount += 1;
|
|
}
|
|
return { changedCount };
|
|
}
|
|
```
|
|
|
|
(Skip media handling — sync 시 media 는 git 이 binary 변경 자동 sync, MediaStore 디렉토리 구조는 동일 layout 가정. F26 dogfood verify.)
|
|
|
|
- [ ] **Step 4: test PASS + typecheck**
|
|
|
|
- [ ] **Step 5: commit**
|
|
|
|
```bash
|
|
git add src/main/services/ImportService.ts \
|
|
src/main/services/importFormat.ts \
|
|
src/main/services/exportFormat.ts \
|
|
tests/unit/ImportService.applySyncFromDir.test.ts
|
|
git commit -m "feat(v030): ImportService.applySyncFromDir + import/export frontmatter status fields"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: SyncService.sync — 양방향 6단계 흐름
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/main/services/SyncService.ts`
|
|
- Create: `tests/unit/SyncService.bidirectional.test.ts`
|
|
|
|
기존 `sync()` 를 6단계로 교체:
|
|
|
|
1. local export → addAll
|
|
2. local commit (변경 있을 때만)
|
|
3. fetch
|
|
4. rebase
|
|
5. re-import (`applySyncFromDir` 호출)
|
|
6. push
|
|
|
|
`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 }>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 1: failing test** — `tests/unit/SyncService.bidirectional.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { SyncService } from '../../src/main/services/SyncService.js';
|
|
|
|
vi.mock('../../src/main/services/GitClient.js');
|
|
import { GitClient } from '../../src/main/services/GitClient.js';
|
|
|
|
describe('SyncService.sync — 양방향', () => {
|
|
let svc: SyncService;
|
|
let exportSvc: { export: ReturnType<typeof vi.fn> };
|
|
let importSvc: { applySyncFromDir: ReturnType<typeof vi.fn> };
|
|
let gitInstance: {
|
|
isRepo: ReturnType<typeof vi.fn>;
|
|
hasRemote: ReturnType<typeof vi.fn>;
|
|
addAll: ReturnType<typeof vi.fn>;
|
|
hasUncommittedChanges: ReturnType<typeof vi.fn>;
|
|
commit: ReturnType<typeof vi.fn>;
|
|
fetch: ReturnType<typeof vi.fn>;
|
|
rebaseOnto: ReturnType<typeof vi.fn>;
|
|
rebaseAbort: ReturnType<typeof vi.fn>;
|
|
listConflicts: ReturnType<typeof vi.fn>;
|
|
push: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
exportSvc = { export: vi.fn(async () => {}) };
|
|
importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) };
|
|
gitInstance = {
|
|
isRepo: vi.fn(async () => true),
|
|
hasRemote: vi.fn(async () => true),
|
|
addAll: vi.fn(async () => {}),
|
|
hasUncommittedChanges: vi.fn(async () => true),
|
|
commit: vi.fn(async () => ({ changed: true, sha: 'abc' })),
|
|
fetch: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
|
rebaseOnto: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
|
rebaseAbort: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
|
listConflicts: vi.fn(async () => []),
|
|
push: vi.fn(async () => {})
|
|
};
|
|
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => gitInstance);
|
|
svc = new SyncService(
|
|
'/tmp/profile',
|
|
exportSvc as unknown as never,
|
|
importSvc as unknown as never
|
|
);
|
|
});
|
|
|
|
it('happy path — 6단계 모두 호출, ok:true', async () => {
|
|
const r = await svc.sync();
|
|
expect(exportSvc.export).toHaveBeenCalled();
|
|
expect(gitInstance.addAll).toHaveBeenCalled();
|
|
expect(gitInstance.commit).toHaveBeenCalled();
|
|
expect(gitInstance.fetch).toHaveBeenCalled();
|
|
expect(gitInstance.rebaseOnto).toHaveBeenCalledWith('origin/main');
|
|
expect(importSvc.applySyncFromDir).toHaveBeenCalled();
|
|
expect(gitInstance.push).toHaveBeenCalled();
|
|
expect(r.ok).toBe(true);
|
|
expect(r.pushed).toBe(true);
|
|
});
|
|
|
|
it('local 변경 없음 → commit skip + 다음 단계 진행', async () => {
|
|
gitInstance.hasUncommittedChanges.mockResolvedValueOnce(false);
|
|
const r = await svc.sync();
|
|
expect(gitInstance.commit).not.toHaveBeenCalled();
|
|
expect(gitInstance.fetch).toHaveBeenCalled();
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('rebase 실패 → abort + reason=conflict + conflicts 포함', async () => {
|
|
gitInstance.rebaseOnto.mockResolvedValueOnce({ stdout: '', stderr: 'CONFLICT', exitCode: 1 });
|
|
gitInstance.listConflicts.mockResolvedValueOnce(['notes/aaa.md', 'notes/bbb.md']);
|
|
const r = await svc.sync();
|
|
expect(gitInstance.rebaseAbort).toHaveBeenCalled();
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toBe('conflict');
|
|
expect(r.conflicts?.length).toBe(2);
|
|
expect(gitInstance.push).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fetch 실패 → reason 반환', async () => {
|
|
gitInstance.fetch.mockResolvedValueOnce({ stdout: '', stderr: 'no network', exitCode: 1 });
|
|
const r = await svc.sync();
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('fetch failed');
|
|
expect(gitInstance.rebaseOnto).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('not configured → ok:false + reason=not_configured', async () => {
|
|
gitInstance.isRepo.mockResolvedValueOnce(false);
|
|
const r = await svc.sync();
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toBe('not_configured');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: implementation** — `src/main/services/SyncService.ts`:
|
|
|
|
```ts
|
|
import { join } from 'node:path';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import type { ExportService } from './ExportService.js';
|
|
import type { ImportService } from './ImportService.js';
|
|
import { GitClient } from './GitClient.js';
|
|
|
|
export interface SyncConflict {
|
|
noteId: string;
|
|
localText: string;
|
|
remoteText: string;
|
|
}
|
|
|
|
export interface SyncStatus {
|
|
ok: boolean;
|
|
reason?: string;
|
|
changed?: boolean;
|
|
localSha?: string | null;
|
|
pushed?: boolean;
|
|
importedCount?: number;
|
|
conflicts?: SyncConflict[];
|
|
}
|
|
|
|
export class SyncService {
|
|
private syncDir: string;
|
|
private lastConflicts: SyncConflict[] = [];
|
|
private lastResult: SyncStatus | null = null;
|
|
private lastAt: string | null = null;
|
|
|
|
constructor(
|
|
private profileDir: string,
|
|
private exportSvc: ExportService,
|
|
private importSvc: ImportService,
|
|
private now: () => Date = () => new Date()
|
|
) {
|
|
this.syncDir = join(profileDir, 'sync');
|
|
}
|
|
|
|
getSyncDir(): string { return this.syncDir; }
|
|
|
|
async isConfigured(): Promise<boolean> {
|
|
const git = new GitClient(this.syncDir);
|
|
if (!(await git.isRepo())) return false;
|
|
if (!(await git.hasRemote())) return false;
|
|
return true;
|
|
}
|
|
|
|
getLastStatus(): { lastAt: string | null; lastResult: SyncStatus | null } {
|
|
return { lastAt: this.lastAt, lastResult: this.lastResult };
|
|
}
|
|
|
|
listConflicts(): SyncConflict[] { return this.lastConflicts; }
|
|
|
|
async sync(): Promise<SyncStatus> {
|
|
const result = await this.runSync();
|
|
this.lastResult = result;
|
|
this.lastAt = this.now().toISOString();
|
|
if (result.reason === 'conflict' && result.conflicts) {
|
|
this.lastConflicts = result.conflicts;
|
|
} else if (result.ok) {
|
|
this.lastConflicts = [];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private async runSync(): Promise<SyncStatus> {
|
|
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
|
|
|
|
const git = new GitClient(this.syncDir);
|
|
|
|
// 1. local export
|
|
try {
|
|
await mkdir(this.syncDir, { recursive: true });
|
|
await this.exportSvc.export(this.syncDir, { includeMedia: true });
|
|
} catch (e) {
|
|
return { ok: false, reason: `export failed: ${(e as Error).message}` };
|
|
}
|
|
|
|
// 2. local commit (변경 있으면)
|
|
let localSha: string | null = null;
|
|
let localChanged = false;
|
|
try {
|
|
await git.addAll();
|
|
localChanged = await git.hasUncommittedChanges();
|
|
if (localChanged) {
|
|
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
|
|
localSha = c.sha;
|
|
}
|
|
} catch (e) {
|
|
return { ok: false, reason: `local commit failed: ${(e as Error).message}` };
|
|
}
|
|
|
|
// 3. fetch
|
|
const fetchR = await git.fetch();
|
|
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
|
|
|
// 4. rebase
|
|
const rebaseR = await git.rebaseOnto('origin/main');
|
|
if (rebaseR.exitCode !== 0) {
|
|
const files = await git.listConflicts();
|
|
await git.rebaseAbort();
|
|
return {
|
|
ok: false,
|
|
reason: 'conflict',
|
|
conflicts: files.map((path) => ({ noteId: this.pathToNoteId(path), localText: '', remoteText: '' }))
|
|
};
|
|
}
|
|
|
|
// 5. re-import
|
|
let importedCount = 0;
|
|
try {
|
|
const r = await this.importSvc.applySyncFromDir(this.syncDir);
|
|
importedCount = r.changedCount;
|
|
} catch (e) {
|
|
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
|
|
}
|
|
|
|
// 6. push
|
|
try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; }
|
|
|
|
return { ok: true, changed: localChanged || importedCount > 0, localSha, importedCount, pushed: true };
|
|
}
|
|
|
|
private pathToNoteId(path: string): string {
|
|
// notes/<id>.md → id
|
|
const m = /notes\/(.+)\.md$/.exec(path);
|
|
return m ? m[1]! : path;
|
|
}
|
|
}
|
|
```
|
|
|
|
NOTE: `SyncService` 생성자에 `importSvc` 파라미터 추가됨 — `src/main/index.ts` 의 인스턴스 생성부도 갱신 필요 (Task 10 에서 처리).
|
|
|
|
- [ ] **Step 3: tests + typecheck PASS** — full suite 회귀 (기존 SyncService 사용처 영향 확인)
|
|
|
|
- [ ] **Step 4: commit**
|
|
|
|
```bash
|
|
git add src/main/services/SyncService.ts tests/unit/SyncService.bidirectional.test.ts
|
|
git commit -m "feat(v030): SyncService.sync — 양방향 6단계 (export/commit/fetch/rebase/re-import/push) + conflict 반환"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: SyncService.resolveConflict — local / remote / both 3 choice
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/main/services/SyncService.ts`
|
|
- Create: `tests/unit/SyncService.resolveConflict.test.ts`
|
|
|
|
`resolveConflict(noteId, choice)` — git CLI 의 `checkout --ours` / `--theirs` / 양쪽 보존 후 `rebase --continue` → push.
|
|
|
|
`local` = local 채택 (origin 변경 폐기) = `git checkout --ours` + `git add` + `git rebase --continue`.
|
|
`remote` = origin 채택 (local 변경 → note_revisions 에 보존 — `updateRawText(id, remoteText)` 호출 후 git checkout --theirs) = `git checkout --theirs`.
|
|
`both` = local + origin 모두 note_revisions 에 INSERT (별도 INSERT), latest = remote = `git checkout --theirs`.
|
|
|
|
이 task 는 spec §5 의 단순화 — 본 cut 에서는 `local` / `remote` 만 구현. `both` 는 v0.3.1+ deferred (revision 분기 정책 검토 필요). spec 정정.
|
|
|
|
- [ ] **Step 1: spec §5 정정** — 'both' 옵션 deferred 마킹. UI 도 2 choice (내 것 / 원격) 만.
|
|
|
|
- [ ] **Step 2: failing test** — `tests/unit/SyncService.resolveConflict.test.ts`:
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { SyncService } from '../../src/main/services/SyncService.js';
|
|
|
|
vi.mock('../../src/main/services/GitClient.js');
|
|
import { GitClient } from '../../src/main/services/GitClient.js';
|
|
|
|
describe('SyncService.resolveConflict', () => {
|
|
let svc: SyncService;
|
|
let gitInstance: {
|
|
run: ReturnType<typeof vi.fn>;
|
|
addAll: ReturnType<typeof vi.fn>;
|
|
push: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
gitInstance = {
|
|
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
|
addAll: vi.fn(async () => {}),
|
|
push: vi.fn(async () => {})
|
|
};
|
|
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => gitInstance);
|
|
svc = new SyncService('/tmp', {} as never, {} as never);
|
|
});
|
|
|
|
it('local 선택 → checkout --ours + add + rebase --continue + push', async () => {
|
|
const r = await svc.resolveConflict('note-id', 'local');
|
|
expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--ours', 'notes/note-id.md']);
|
|
expect(gitInstance.run).toHaveBeenCalledWith(['rebase', '--continue']);
|
|
expect(gitInstance.push).toHaveBeenCalled();
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('remote 선택 → checkout --theirs + add + rebase --continue + push', async () => {
|
|
const r = await svc.resolveConflict('note-id', 'remote');
|
|
expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('checkout 실패 → ok:false + reason 반환', async () => {
|
|
gitInstance.run.mockResolvedValueOnce({ stdout: '', stderr: 'fail', exitCode: 1 });
|
|
const r = await svc.resolveConflict('note-id', 'local');
|
|
expect(r.ok).toBe(false);
|
|
expect(r.reason).toContain('checkout failed');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: implementation** — `SyncService` 클래스에 추가:
|
|
|
|
```ts
|
|
async resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
const git = new GitClient(this.syncDir);
|
|
const flag = choice === 'local' ? '--ours' : '--theirs';
|
|
const path = `notes/${noteId}.md`;
|
|
|
|
const checkout = await git.run(['checkout', flag, path]);
|
|
if (checkout.exitCode !== 0) return { ok: false, reason: `checkout failed: ${checkout.stderr}` };
|
|
|
|
await git.addAll();
|
|
const cont = await git.run(['rebase', '--continue']);
|
|
if (cont.exitCode !== 0) return { ok: false, reason: `rebase --continue failed: ${cont.stderr}` };
|
|
|
|
// re-import remote 변경 (remote 선택 시 local DB 의 raw_text 도 갱신해야 함)
|
|
if (choice === 'remote') {
|
|
await this.importSvc.applySyncFromDir(this.syncDir);
|
|
}
|
|
|
|
try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; }
|
|
|
|
// conflicts 캐시에서 해당 noteId 제거
|
|
this.lastConflicts = this.lastConflicts.filter((c) => c.noteId !== noteId);
|
|
|
|
return { ok: true };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: test PASS + commit**
|
|
|
|
```bash
|
|
git add src/main/services/SyncService.ts tests/unit/SyncService.resolveConflict.test.ts \
|
|
docs/superpowers/specs/2026-05-09-v030-cut-e-design.md
|
|
git commit -m "feat(v030): SyncService.resolveConflict — local/remote 2 choice (both deferred)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: settings — sync_repo_url / sync_auto_enabled / sync_interval_min
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/main/services/SettingsService.ts`
|
|
|
|
기존 SettingsService 의 zod schema 에 3 필드 추가 (default: enabled=true, interval_min=30, repo_url=null). getter/setter 메서드 추가. 기존 ai_enabled / onboarding_completed 와 동일 패턴.
|
|
|
|
- [ ] **Step 1: SettingsService 의 schema 갱신** — 적절한 zod field 추가 + default 처리
|
|
|
|
- [ ] **Step 2: 새 메서드 추가** — `setSyncRepoUrl(url)`, `getSyncRepoUrl()`, `setAutoSyncEnabled(b)`, `getAutoSyncEnabled()`, `setSyncIntervalMin(n)`, `getSyncIntervalMin()`. min interval = 5분 (validation).
|
|
|
|
- [ ] **Step 3: tests** — `tests/unit/SettingsService.test.ts` 의 적절한 describe 안에 6개 (set/get x 3 필드).
|
|
|
|
- [ ] **Step 4: commit**
|
|
|
|
```bash
|
|
git add src/main/services/SettingsService.ts tests/unit/SettingsService.test.ts
|
|
git commit -m "feat(v030): settings.sync_repo_url + sync_auto_enabled + sync_interval_min"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: types + IPC handlers + preload bridge
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/shared/types.ts` — `ConflictRow`, `SyncStatusResult` interface + InboxApi 5 메서드
|
|
- Modify: `src/main/ipc/settingsApi.ts` — 5 IPC handler
|
|
- Modify: `src/preload/index.ts` — 5 bridge 함수
|
|
- Create: `tests/unit/sync-ipc.test.ts`
|
|
|
|
5 채널:
|
|
- `settings:configure-sync` (url) — settings 갱신 + git init / remote add
|
|
- `settings:test-sync-connection` () — `git ls-remote` 결과
|
|
- `sync:list-conflicts` () — SyncService.listConflicts() 반환
|
|
- `sync:resolve-conflict` (noteId, choice) — SyncService.resolveConflict
|
|
- `sync:get-status` () — { lastAt, lastResult, nextAt } (nextAt 은 SyncTimer 가 계산)
|
|
|
|
(테스트 패턴은 Cut C/D 의 inboxApi-revisions / inboxApi-search-review 동일 — 5 handler 별 happy + edge case = 약 5-7 tests)
|
|
|
|
- [ ] **Step 1: types** — 적절한 interface 추가
|
|
- [ ] **Step 2: failing test** — handler 5개 mock-based
|
|
- [ ] **Step 3: handler 구현**
|
|
- [ ] **Step 4: preload bridge**
|
|
- [ ] **Step 5: tests + commit**
|
|
|
|
```bash
|
|
git add src/shared/types.ts src/main/ipc/settingsApi.ts src/preload/index.ts \
|
|
tests/unit/sync-ipc.test.ts
|
|
git commit -m "feat(v030): sync IPC + preload (configure / test / list-conflicts / resolve / status)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: SettingsPage 의 SyncSection — Configure UI
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/renderer/inbox/components/settings/SyncSection.tsx`
|
|
- Modify: `src/renderer/inbox/components/SettingsPage.tsx` — SyncSection mount
|
|
- Create: `tests/unit/SyncSection.test.tsx`
|
|
|
|
`AiProviderSection.tsx` (Cut B) 패턴 참고. URL 입력 + 저장 + 연결 테스트 + 자동 sync 토글 + interval input + 지금 동기화 버튼 + 마지막 sync 결과 표시 + 충돌 해결 버튼 (활성 시).
|
|
|
|
- [ ] **Step 1-5: 컴포넌트 + test + mount + commit** (다른 Section 패턴 따름)
|
|
|
|
```bash
|
|
git add src/renderer/inbox/components/settings/SyncSection.tsx \
|
|
src/renderer/inbox/components/SettingsPage.tsx \
|
|
tests/unit/SyncSection.test.tsx
|
|
git commit -m "feat(v030): SettingsPage SyncSection — Configure UI (URL + 자동 sync + interval + 충돌 해결)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: ConflictModal — 충돌 해결 UI
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/renderer/inbox/components/ConflictModal.tsx`
|
|
- Modify: `src/renderer/inbox/components/settings/SyncSection.tsx` — modal mount
|
|
- Create: `tests/unit/ConflictModal.test.tsx`
|
|
|
|
`MoveStatusModal` / `RevisionHistoryModal` 패턴. open 시 `inbox.listConflicts()` → 각 conflict 별 양쪽 텍스트 + "내 것 사용" / "원격 사용" 버튼. 클릭 → `inbox.resolveConflict(noteId, choice)`.
|
|
|
|
```bash
|
|
git add src/renderer/inbox/components/ConflictModal.tsx \
|
|
src/renderer/inbox/components/settings/SyncSection.tsx \
|
|
tests/unit/ConflictModal.test.tsx
|
|
git commit -m "feat(v030): ConflictModal — 충돌 해결 UI (local / remote 2 choice)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: SyncTimer — main process 자동 주기 sync
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/main/services/SyncTimer.ts`
|
|
- Modify: `src/main/index.ts` — SyncTimer 인스턴스 생성 + start
|
|
- Create: `tests/unit/SyncTimer.test.ts`
|
|
|
|
```ts
|
|
export class SyncTimer {
|
|
private handle: NodeJS.Timeout | null = null;
|
|
|
|
constructor(
|
|
private syncSvc: SyncService,
|
|
private settings: SettingsService
|
|
) {}
|
|
|
|
async start(): Promise<void> {
|
|
const enabled = await this.settings.getAutoSyncEnabled();
|
|
if (!enabled) return;
|
|
const intervalMin = await this.settings.getSyncIntervalMin();
|
|
const ms = Math.max(5, intervalMin) * 60 * 1000;
|
|
this.handle = setInterval(() => { void this.syncSvc.sync(); }, ms);
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.handle) { clearInterval(this.handle); this.handle = null; }
|
|
}
|
|
|
|
/** settings 변경 시 호출 — 새 interval 로 재시작. */
|
|
async reconfigure(): Promise<void> {
|
|
this.stop();
|
|
await this.start();
|
|
}
|
|
}
|
|
```
|
|
|
|
main 의 `src/main/index.ts` 에서 SyncService + ImportService 인스턴스 생성 + SyncTimer 생성 + start. settings:configure-sync IPC 가 reconfigure 호출.
|
|
|
|
`tests/unit/SyncTimer.test.ts` — fake timers + sync.mock 검증.
|
|
|
|
```bash
|
|
git add src/main/services/SyncTimer.ts src/main/index.ts tests/unit/SyncTimer.test.ts
|
|
git commit -m "feat(v030): SyncTimer — 자동 주기 sync (settings.sync_interval_min, default 30)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: dogfood promoted + version bump + release commit
|
|
|
|
- [ ] F21 promoted 마킹 (✅ v0.3.0 Cut E — A+B+C 옵션, both choice deferred)
|
|
- [ ] package.json: 0.2.11 → 0.3.0 + package-lock.json
|
|
- [ ] full unit + typecheck + e2e 검증
|
|
|
|
```bash
|
|
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json package-lock.json
|
|
git commit -m "chore(release): v0.3.0 — Cut E (양방향 git sync + Configure UI + Conflict resolution)"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Checklist (수행자: 모든 task 완료 후 1회 점검)
|
|
|
|
- [ ] **Spec coverage**: §3 sync 6단계 (Task 4) / §3-2 ImportService.applySyncFromDir (Task 3) / §3-3 GitClient 확장 (Task 1) / §4 Configure UI (Task 8) / §5 Conflict UI (Task 9, both deferred 정정 반영) / §6 자동 주기 (Task 10) / §7 IPC (Task 7) / §8 테스트 전략 카운트 일치
|
|
- [ ] **Single write path 강제 (Cut C/D 정책 확장)**: `upsertFromSync` 가 raw_text 변경 path 에서 `updateRawText` 호출 (capture revision chain 보존) + tags 변경 path 에서 `rebuildFtsTagsForNote` 호출. 사용 case ALL — `applySyncFromDir` (Task 3) → `upsertFromSync` (Task 2). 직접 `notes_fts` / `note_revisions` mutate path 없음 검증.
|
|
- [ ] **Type 일관성**: `SyncStatus` (Task 4) ↔ `SyncStatusResult` (Task 7 IPC) ↔ `ConflictRow` (Task 7) 모두 일치
|
|
- [ ] **단위 카운트**: GitClient 5 + upsertFromSync 5 + applySyncFromDir 2 + sync 5 + resolveConflict 3 + settings 6 + IPC 5-7 + SyncSection 2 + ConflictModal 2 + SyncTimer 3 = **약 38-40 신규** (목표 27 보다 많음 — sync 인프라 전수 검증)
|
|
- [ ] **regression 회귀 패턴 (Cut D 확립)**: spec 단계 invariant 항목 (`note_tags` INSERT path 3곳 / `notes_fts` write path / `note_revisions` write path) 별 grep 점검. importNote 같은 secondary path 누락 회귀 방지.
|
|
|
|
---
|
|
|
|
## Risk
|
|
|
|
- **인증 외부 의존**: SSH key / git credential helper — 사용자 OS 설정 의존, 빌드 단계 검증 한계. Configure UI 의 "연결 테스트" 가 사용자 안내 첫 단계
|
|
- **다기기 환경 필요**: 본인 dogfood 가 Mac + Windows 양 기기 sync 환경에서만 가치 검증. Windows 단독 dogfood = sync 흐름 시뮬레이션 한계
|
|
- **conflict 빈도 측정**: 단위 테스트는 가능. 실 사용 conflict 빈도는 다기기 dogfood 1주 필수
|
|
- **timestamp 단조 가정**: NTP 부재 + 양 기기 시계 어긋남 시 upsertFromSync 의 updated_at 비교 부정확 → 잘못된 skip/update. dogfood 검증
|
|
- **`both` 옵션 deferred**: revision branch 분기 정책 미정. v0.3.1+ 에서 검토 (Cut C revision history 와 sync chain merge 통합 설계)
|
|
- **자동 주기 sync 의 silent 충돌 누적**: interval mode 충돌 시 notification + 충돌 UI 자동 popup option (현재 cut: notification 만)
|
|
- **e2e**: Cut C/D 와 동일 — worktree node_modules 비어 있음, 본 cut 의 변경은 SettingsPage SyncSection + ConflictModal — capture/onboarding flow 무관. main 머지 후 검증
|
|
- **SyncService 생성자 시그니처 변경 (Task 4)**: 기존 push-only 사용처 (예: `src/main/index.ts`, 테스트 mock) 모두 importSvc 파라미터 추가 필요. compile error 로 catch — 각 사용처 갱신 필수
|