From 33588b09df4b191219060902e086f7ad35ceca26 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:40:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(v030):=20SyncService.sync=20=E2=80=94=20?= =?UTF-8?q?=EC=96=91=EB=B0=A9=ED=96=A5=206=EB=8B=A8=EA=B3=84=20(export/com?= =?UTF-8?q?mit/fetch/rebase/re-import/push)=20+=20conflict=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/index.ts | 2 +- src/main/ipc/settingsApi.ts | 2 +- src/main/services/GitClient.ts | 6 + src/main/services/SyncService.ts | 111 ++++++++++++++++--- tests/unit/SyncService.bidirectional.test.ts | 95 ++++++++++++++++ tests/unit/SyncService.test.ts | 14 ++- 6 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 tests/unit/SyncService.bidirectional.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 8aee3ec..d93c027 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -196,7 +196,7 @@ app.whenReady().then(async () => { const exportSvc = new ExportService(repo, store); const importSvc = new ImportService(repo, store); - const syncSvc = new SyncService(paths.profileDir, exportSvc); + const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc); const backup = new BackupService(db, join(paths.profileDir, 'backups')); void backup.runDaily() diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index a1c86ad..2aa7ad0 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -239,7 +239,7 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { ok: true } as const; } if (r.changed) { - logger.info('sync.done', { sha: r.sha, pushed: r.pushed }); + logger.info('sync.done', { sha: r.localSha, pushed: r.pushed }); new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show(); } else { new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show(); diff --git a/src/main/services/GitClient.ts b/src/main/services/GitClient.ts index 71ba3b0..5fd5c01 100644 --- a/src/main/services/GitClient.ts +++ b/src/main/services/GitClient.ts @@ -107,6 +107,12 @@ export class GitClient { return r.stdout.trim().length > 0; } + /** ref (branch, tag, remote branch) 존재 여부 확인. `git rev-parse --verify`. */ + async refExists(ref: string): Promise { + const r = await this.run(['rev-parse', '--verify', ref]); + return r.exitCode === 0; + } + /** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */ async listConflicts(): Promise { const r = await this.run(['diff', '--name-only', '--diff-filter=U']); diff --git a/src/main/services/SyncService.ts b/src/main/services/SyncService.ts index 0a790ab..fcfcad4 100644 --- a/src/main/services/SyncService.ts +++ b/src/main/services/SyncService.ts @@ -1,22 +1,35 @@ 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; // why the sync was skipped or failed - changed?: boolean; // true if a new commit was created - sha?: string | null; + 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'); @@ -33,31 +46,97 @@ export class SyncService { return true; } + getLastStatus(): { lastAt: string | null; lastResult: SyncStatus | null } { + return { lastAt: this.lastAt, lastResult: this.lastResult }; + } + + listConflicts(): SyncConflict[] { + return this.lastConflicts; + } + async sync(): Promise { - if (!(await this.isConfigured())) { - return { ok: false, reason: 'not_configured' }; + 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 = []; } - // 1. Re-export the full tree into syncDir (idempotent). + return result; + } + + private async runSync(): Promise { + 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. git add + commit + push - const git = new GitClient(this.syncDir); + + // 2. local commit (only if changed) + let localSha: string | null = null; + let localChanged = false; try { await git.addAll(); - const ts = this.now().toISOString(); - const message = `chore(notes): sync ${ts}`; - const commit = await git.commit(message); - if (!commit.changed) { - return { ok: true, changed: false, pushed: false }; + localChanged = await git.hasUncommittedChanges(); + if (localChanged) { + const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`); + localSha = c.sha; } - await git.push(); - return { ok: true, changed: true, sha: commit.sha, pushed: true }; } catch (e) { - return { ok: false, reason: (e as Error).message }; + 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 — skip if origin/main doesn't exist yet (first-push, empty remote) + const hasOriginMain = await git.refExists('origin/main'); + if (hasOriginMain) { + 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/.md or notes/--.md → extract id (best effort). + // Cut E note: F5 export uses date-id8-slug filename; full id is in frontmatter. + // For Cut E conflict listing we expose the file path-derived heuristic; the + // conflict modal will read frontmatter to recover the full id when needed. + const m = /notes\/(.+)\.md$/.exec(path); + return m ? m[1]! : path; } } diff --git a/tests/unit/SyncService.bidirectional.test.ts b/tests/unit/SyncService.bidirectional.test.ts new file mode 100644 index 0000000..6468dd8 --- /dev/null +++ b/tests/unit/SyncService.bidirectional.test.ts @@ -0,0 +1,95 @@ +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 }; + let importSvc: { applySyncFromDir: ReturnType }; + let gitInstance: { + isRepo: ReturnType; + hasRemote: ReturnType; + addAll: ReturnType; + hasUncommittedChanges: ReturnType; + commit: ReturnType; + fetch: ReturnType; + refExists: ReturnType; + rebaseOnto: ReturnType; + rebaseAbort: ReturnType; + listConflicts: ReturnType; + push: ReturnType; + }; + + 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 })), + refExists: vi.fn(async () => true), + 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).mockImplementation(function () { return 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'); + }); +}); diff --git a/tests/unit/SyncService.test.ts b/tests/unit/SyncService.test.ts index ea10c84..1244a66 100644 --- a/tests/unit/SyncService.test.ts +++ b/tests/unit/SyncService.test.ts @@ -9,6 +9,7 @@ import { runMigrations } from '@main/db/migrations/index.js'; import { NoteRepository } from '@main/repository/NoteRepository.js'; import { MediaStore } from '@main/services/MediaStore.js'; import { ExportService } from '@main/services/ExportService.js'; +import { ImportService } from '@main/services/ImportService.js'; import { SyncService } from '@main/services/SyncService.js'; const execFileAsync = promisify(execFile); @@ -47,6 +48,7 @@ describe('SyncService', () => { let repo: NoteRepository; let mediaStore: MediaStore; let exportSvc: ExportService; + let importSvc: ImportService; let svc: SyncService; let remoteDir: string | null = null; let prevEnv: NodeJS.ProcessEnv; @@ -73,7 +75,8 @@ describe('SyncService', () => { repo = new NoteRepository(db); mediaStore = new MediaStore(profileDir); exportSvc = new ExportService(repo, mediaStore, () => new Date('2026-04-26T12:00:00Z')); - svc = new SyncService(profileDir, exportSvc, () => new Date('2026-04-26T12:00:00Z')); + importSvc = new ImportService(repo, mediaStore); + svc = new SyncService(profileDir, exportSvc, importSvc, () => new Date('2026-04-26T12:00:00Z')); }); afterEach(() => { @@ -110,7 +113,7 @@ describe('SyncService', () => { expect(r.ok).toBe(true); expect(r.changed).toBe(true); expect(r.pushed).toBe(true); - expect(r.sha).toMatch(/^[0-9a-f]{40}$/); + expect(r.localSha).toMatch(/^[0-9a-f]{40}$/); expect(existsSync(join(svc.getSyncDir(), 'manifest.json'))).toBe(true); expect(existsSync(join(svc.getSyncDir(), 'notes'))).toBe(true); expect(existsSync(join(svc.getSyncDir(), 'index.jsonl'))).toBe(true); @@ -122,10 +125,11 @@ describe('SyncService', () => { const first = await svc.sync(); expect(first.ok).toBe(true); expect(first.changed).toBe(true); - // Re-sync without DB change. With fixed now() → identical files → git sees no change. + // Re-sync without DB change. With fixed now() → identical files → git sees no local change. + // New bidirectional flow: always does fetch+rebase+re-import+push. const second = await svc.sync(); expect(second.ok).toBe(true); - expect(second.changed).toBe(false); - expect(second.pushed).toBe(false); + expect(second.changed).toBe(false); // no local commit + importedCount=0 + expect(second.pushed).toBe(true); // push always runs on success }); });