From 8436846657921fdd9b616f2f41a7156eb4b1bb0f Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:42:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(v030):=20SyncService.resolveConflict=20?= =?UTF-8?q?=E2=80=94=20local/remote=202=20choice=20(both=20deferred)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/SyncService.ts | 51 ++++++++++++++++ .../unit/SyncService.resolveConflict.test.ts | 60 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/unit/SyncService.resolveConflict.test.ts diff --git a/src/main/services/SyncService.ts b/src/main/services/SyncService.ts index fcfcad4..621b198 100644 --- a/src/main/services/SyncService.ts +++ b/src/main/services/SyncService.ts @@ -66,6 +66,57 @@ export class SyncService { return result; } + /** + * v0.3.0 Cut E — conflict 해결. local/remote 2 choice (both deferred to v0.3.1+). + * 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 noteId 별 호출. + * + * - 'local' = 내 것 사용 (origin 변경 폐기) → git checkout --ours + * - 'remote' = 원격 사용 → git checkout --theirs + applySyncFromDir (local DB 갱신) + * + * 모든 conflict 해결 후 rebase --continue 가 성공 → push. + * UI 가 여러 conflict 를 loop 호출하면 마지막 호출에서 push 까지 완료. + */ + 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) { + // Likely other unresolved files — UI will call resolveConflict for them. + return { ok: false, reason: `rebase --continue failed: ${cont.stderr}` }; + } + + if (choice === 'remote') { + try { + await this.importSvc.applySyncFromDir(this.syncDir); + } catch (e) { + return { ok: false, reason: `re-import failed: ${(e as Error).message}` }; + } + } + + try { + await git.push(); + } catch (e) { + return { ok: false, reason: `push failed: ${(e as Error).message}` }; + } + + // Remove this noteId from cached conflicts list + this.lastConflicts = this.lastConflicts.filter((c) => c.noteId !== noteId); + + return { ok: true }; + } + private async runSync(): Promise { if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' }; diff --git a/tests/unit/SyncService.resolveConflict.test.ts b/tests/unit/SyncService.resolveConflict.test.ts new file mode 100644 index 0000000..e5cb1c3 --- /dev/null +++ b/tests/unit/SyncService.resolveConflict.test.ts @@ -0,0 +1,60 @@ +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 importSvc: { applySyncFromDir: ReturnType }; + let gitInstance: { + run: ReturnType; + addAll: ReturnType; + push: ReturnType; + }; + + beforeEach(() => { + importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) }; + gitInstance = { + run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + addAll: vi.fn(async () => {}), + push: vi.fn(async () => {}) + }; + (GitClient as unknown as ReturnType).mockImplementation(function () { return gitInstance; }); + svc = new SyncService('/tmp', {} as never, importSvc 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 + applySyncFromDir + push', async () => { + const r = await svc.resolveConflict('note-id', 'remote'); + expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']); + expect(importSvc.applySyncFromDir).toHaveBeenCalled(); + expect(gitInstance.push).toHaveBeenCalled(); + 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 as { reason: string }).reason).toContain('checkout failed'); + expect(gitInstance.push).not.toHaveBeenCalled(); + }); + + it('rebase --continue 실패 (다른 파일 미해결) → ok:false', async () => { + gitInstance.run + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // checkout + .mockResolvedValueOnce({ stdout: '', stderr: 'still unresolved', exitCode: 1 }); // rebase --continue + const r = await svc.resolveConflict('note-id', 'local'); + expect(r.ok).toBe(false); + expect((r as { reason: string }).reason).toContain('rebase --continue failed'); + expect(gitInstance.push).not.toHaveBeenCalled(); + }); +});