diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 8086b20..4d62971 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -359,12 +359,12 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { // sync:list-conflicts — SyncService 캐시 결과 ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts()); - // sync:resolve-conflict — local/remote 2 choice - ipcMain.handle('sync:resolve-conflict', async (_e, noteId: string, choice: 'local' | 'remote') => { + // sync:resolve-conflict — local/remote 2 choice. path = git index conflict 경로. + ipcMain.handle('sync:resolve-conflict', async (_e, path: string, choice: 'local' | 'remote') => { if (choice !== 'local' && choice !== 'remote') { return { ok: false as const, reason: 'invalid choice' }; } - return deps.syncSvc.resolveConflict(noteId, choice); + return deps.syncSvc.resolveConflict(path, choice); }); // sync:get-status — lastAt + lastResult + nextAt 계산 diff --git a/src/main/services/SyncService.ts b/src/main/services/SyncService.ts index 621b198..06c37f2 100644 --- a/src/main/services/SyncService.ts +++ b/src/main/services/SyncService.ts @@ -4,8 +4,14 @@ import type { ExportService } from './ExportService.js'; import type { ImportService } from './ImportService.js'; import { GitClient } from './GitClient.js'; +/** + * Cut E final review fix: 'noteId' was misleading — F5 export filenames are + * `--.md` (composeFilename), not `.md`. The git checkout / + * resolve operations use the FULL relative path (e.g., `notes/2026-05-09-abc12345-회의.md`). + * `path` matches what we actually pass to `git checkout --ours/theirs`. + */ export interface SyncConflict { - noteId: string; + path: string; localText: string; remoteText: string; } @@ -68,21 +74,23 @@ export class SyncService { /** * v0.3.0 Cut E — conflict 해결. local/remote 2 choice (both deferred to v0.3.1+). - * 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 noteId 별 호출. + * 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 conflict 의 path 별 호출. * * - 'local' = 내 것 사용 (origin 변경 폐기) → git checkout --ours * - 'remote' = 원격 사용 → git checkout --theirs + applySyncFromDir (local DB 갱신) * * 모든 conflict 해결 후 rebase --continue 가 성공 → push. * UI 가 여러 conflict 를 loop 호출하면 마지막 호출에서 push 까지 완료. + * + * Cut E final review fix: 파라미터를 path 로 변경 (옛 noteId 는 export filename slug, + * UUID 아님 — 혼동 회피). */ async resolveConflict( - noteId: string, + path: 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) { @@ -111,8 +119,8 @@ export class SyncService { 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); + // Remove this path from cached conflicts list + this.lastConflicts = this.lastConflicts.filter((c) => c.path !== path); return { ok: true }; } @@ -154,12 +162,21 @@ export class SyncService { const rebaseR = await git.rebaseOnto('origin/main'); if (rebaseR.exitCode !== 0) { const files = await git.listConflicts(); + // Cut E final review fix — populate localText/remoteText from rebase index + // BEFORE aborting. `git show :2:` = ours (local during rebase), + // `:3:` = theirs (remote being applied). UI shows side-by-side diff. + const conflicts: SyncConflict[] = []; + for (const path of files) { + const ours = await git.run(['show', `:2:${path}`]); + const theirs = await git.run(['show', `:3:${path}`]); + conflicts.push({ + path, + localText: ours.exitCode === 0 ? ours.stdout : '', + remoteText: theirs.exitCode === 0 ? theirs.stdout : '' + }); + } await git.rebaseAbort(); - return { - ok: false, - reason: 'conflict', - conflicts: files.map((path) => ({ noteId: this.pathToNoteId(path), localText: '', remoteText: '' })) - }; + return { ok: false, reason: 'conflict', conflicts }; } } @@ -182,12 +199,4 @@ export class SyncService { 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/src/preload/index.ts b/src/preload/index.ts index 2f50a5f..539f4cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -92,8 +92,8 @@ const api: InklingApi = { configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url), testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'), listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'), - resolveConflict: (noteId: string, choice: 'local' | 'remote') => - ipcRenderer.invoke('sync:resolve-conflict', noteId, choice), + resolveConflict: (path: string, choice: 'local' | 'remote') => + ipcRenderer.invoke('sync:resolve-conflict', path, choice), getSyncStatus: () => ipcRenderer.invoke('sync:get-status'), setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value), setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value), diff --git a/src/renderer/inbox/components/ConflictModal.tsx b/src/renderer/inbox/components/ConflictModal.tsx index 76441b9..675572a 100644 --- a/src/renderer/inbox/components/ConflictModal.tsx +++ b/src/renderer/inbox/components/ConflictModal.tsx @@ -36,16 +36,16 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen return () => { cancelled = true; }; }, []); - async function onChoose(noteId: string, choice: 'local' | 'remote') { - setBusy(noteId); + async function onChoose(path: string, choice: 'local' | 'remote') { + setBusy(path); setError(null); - const r = await inboxApi.resolveConflict(noteId, choice); + const r = await inboxApi.resolveConflict(path, choice); setBusy(null); if (!r.ok) { setError(`해결 실패: ${r.reason}`); return; } - const next = conflicts.filter((c) => c.noteId !== noteId); + const next = conflicts.filter((c) => c.path !== path); setConflicts(next); if (next.length === 0) { onResolved(); @@ -62,8 +62,8 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen {error !== null &&
{error}
} {conflicts.map((c) => ( -
-
note: {c.noteId}
+
+
{c.path}
내 기기
@@ -76,18 +76,18 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen
diff --git a/src/shared/types.ts b/src/shared/types.ts index ed165a0..6bcbbf2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,8 +41,10 @@ export interface ReviewAggregate { } // v0.3.0 Cut E — 양방향 sync 결과 + conflict. +// `path` = git index 의 conflict 파일 상대경로 (예: 'notes/2026-05-09-abc12345-회의.md'). +// F5 export 의 filename 은 date-id8-slug 패턴 — UUID 가 아니라 path 가 맞는 식별자. export interface SyncConflict { - noteId: string; + path: string; localText: string; remoteText: string; } @@ -212,7 +214,7 @@ export interface InboxApi { configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>; testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>; listConflicts(): Promise; - resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>; + resolveConflict(path: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>; getSyncStatus(): Promise; setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>; setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>; diff --git a/tests/unit/ConflictModal.test.tsx b/tests/unit/ConflictModal.test.tsx index 3bee267..ff0d9a1 100644 --- a/tests/unit/ConflictModal.test.tsx +++ b/tests/unit/ConflictModal.test.tsx @@ -20,32 +20,34 @@ describe('ConflictModal', () => { vi.clearAllMocks(); cleanup(); mockListConflicts.mockResolvedValue([ - { noteId: 'n1', localText: 'local A', remoteText: 'remote A' }, - { noteId: 'n2', localText: 'local B', remoteText: 'remote B' } + { path: 'notes/n1.md', localText: 'local A', remoteText: 'remote A' }, + { path: 'notes/n2.md', localText: 'local B', remoteText: 'remote B' } ]); mockResolveConflict.mockResolvedValue({ ok: true }); }); - it('open 시 listConflicts 호출 + 양 conflict 표시', async () => { + it('open 시 listConflicts 호출 + 양 conflict preview 표시', async () => { render( {}} onResolved={() => {}} />); await waitFor(() => screen.getByText(/local A/)); expect(screen.getByText(/local A/)).toBeInTheDocument(); expect(screen.getByText(/remote A/)).toBeInTheDocument(); expect(screen.getByText(/local B/)).toBeInTheDocument(); + // path 가 표시됨 (Cut E final review fix — noteId → path) + expect(screen.getByText('notes/n1.md')).toBeInTheDocument(); }); - it('내 것 사용 클릭 → resolveConflict(noteId, "local") 호출', async () => { + it('내 것 사용 클릭 → resolveConflict(path, "local") 호출', async () => { render( {}} onResolved={() => {}} />); await waitFor(() => screen.getByText(/local A/)); const buttons = screen.getAllByRole('button', { name: /내 것 사용/ }); fireEvent.click(buttons[0]!); await waitFor(() => { - expect(mockResolveConflict).toHaveBeenCalledWith('n1', 'local'); + expect(mockResolveConflict).toHaveBeenCalledWith('notes/n1.md', 'local'); }); }); it('마지막 conflict 해결 → onResolved + onClose 호출', async () => { - mockListConflicts.mockResolvedValueOnce([{ noteId: 'n1', localText: 'a', remoteText: 'b' }]); + mockListConflicts.mockResolvedValueOnce([{ path: 'notes/n1.md', localText: 'a', remoteText: 'b' }]); const onResolved = vi.fn(); const onClose = vi.fn(); render(); diff --git a/tests/unit/SyncService.bidirectional.test.ts b/tests/unit/SyncService.bidirectional.test.ts index 6468dd8..0d5f01b 100644 --- a/tests/unit/SyncService.bidirectional.test.ts +++ b/tests/unit/SyncService.bidirectional.test.ts @@ -20,6 +20,7 @@ describe('SyncService.sync — 양방향', () => { rebaseAbort: ReturnType; listConflicts: ReturnType; push: ReturnType; + run: ReturnType; }; beforeEach(() => { @@ -36,7 +37,8 @@ describe('SyncService.sync — 양방향', () => { rebaseOnto: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), rebaseAbort: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), listConflicts: vi.fn(async () => []), - push: vi.fn(async () => {}) + push: vi.fn(async () => {}), + run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) }; (GitClient as unknown as ReturnType).mockImplementation(function () { return gitInstance; }); svc = new SyncService( @@ -67,14 +69,24 @@ describe('SyncService.sync — 양방향', () => { expect(r.ok).toBe(true); }); - it('rebase 실패 → abort + reason=conflict + conflicts 포함', async () => { + it('rebase 실패 → abort + reason=conflict + conflicts 포함 (path + localText/remoteText)', async () => { gitInstance.rebaseOnto.mockResolvedValueOnce({ stdout: '', stderr: 'CONFLICT', exitCode: 1 }); gitInstance.listConflicts.mockResolvedValueOnce(['notes/aaa.md', 'notes/bbb.md']); + // Cut E final review fix — runSync calls git.run(['show', ':2:path']) and ':3:path' + // for each conflict. Mock returns ours/theirs text per call. + gitInstance.run + .mockResolvedValueOnce({ stdout: 'aaa local', stderr: '', exitCode: 0 }) // :2:notes/aaa.md + .mockResolvedValueOnce({ stdout: 'aaa remote', stderr: '', exitCode: 0 }) // :3:notes/aaa.md + .mockResolvedValueOnce({ stdout: 'bbb local', stderr: '', exitCode: 0 }) // :2:notes/bbb.md + .mockResolvedValueOnce({ stdout: 'bbb remote', stderr: '', exitCode: 0 }); // :3: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(r.conflicts).toEqual([ + { path: 'notes/aaa.md', localText: 'aaa local', remoteText: 'aaa remote' }, + { path: 'notes/bbb.md', localText: 'bbb local', remoteText: 'bbb remote' } + ]); expect(gitInstance.push).not.toHaveBeenCalled(); }); diff --git a/tests/unit/SyncService.resolveConflict.test.ts b/tests/unit/SyncService.resolveConflict.test.ts index e5cb1c3..98d2ba7 100644 --- a/tests/unit/SyncService.resolveConflict.test.ts +++ b/tests/unit/SyncService.resolveConflict.test.ts @@ -25,7 +25,7 @@ describe('SyncService.resolveConflict', () => { }); it('local 선택 → checkout --ours + add + rebase --continue + push', async () => { - const r = await svc.resolveConflict('note-id', 'local'); + const r = await svc.resolveConflict('notes/note-id.md', 'local'); expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--ours', 'notes/note-id.md']); expect(gitInstance.run).toHaveBeenCalledWith(['rebase', '--continue']); expect(gitInstance.push).toHaveBeenCalled(); @@ -33,7 +33,7 @@ describe('SyncService.resolveConflict', () => { }); it('remote 선택 → checkout --theirs + add + rebase --continue + applySyncFromDir + push', async () => { - const r = await svc.resolveConflict('note-id', 'remote'); + const r = await svc.resolveConflict('notes/note-id.md', 'remote'); expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']); expect(importSvc.applySyncFromDir).toHaveBeenCalled(); expect(gitInstance.push).toHaveBeenCalled(); @@ -42,7 +42,7 @@ describe('SyncService.resolveConflict', () => { it('checkout 실패 → ok:false + reason 반환', async () => { gitInstance.run.mockResolvedValueOnce({ stdout: '', stderr: 'fail', exitCode: 1 }); - const r = await svc.resolveConflict('note-id', 'local'); + const r = await svc.resolveConflict('notes/note-id.md', 'local'); expect(r.ok).toBe(false); expect((r as { reason: string }).reason).toContain('checkout failed'); expect(gitInstance.push).not.toHaveBeenCalled(); @@ -52,7 +52,7 @@ describe('SyncService.resolveConflict', () => { 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'); + const r = await svc.resolveConflict('notes/note-id.md', 'local'); expect(r.ok).toBe(false); expect((r as { reason: string }).reason).toContain('rebase --continue failed'); expect(gitInstance.push).not.toHaveBeenCalled(); diff --git a/tests/unit/sync-ipc.test.ts b/tests/unit/sync-ipc.test.ts index d2f459a..866991a 100644 --- a/tests/unit/sync-ipc.test.ts +++ b/tests/unit/sync-ipc.test.ts @@ -25,7 +25,7 @@ function makeDeps() { const syncSvc = { getSyncDir: vi.fn(() => '/tmp/sync'), - listConflicts: vi.fn(() => [] as { noteId: string; localText: string; remoteText: string }[]), + listConflicts: vi.fn(() => [] as { path: string; localText: string; remoteText: string }[]), resolveConflict: vi.fn(async () => ({ ok: true as const })), getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null })) }; @@ -171,7 +171,7 @@ describe('sync IPC channels', () => { describe('sync:list-conflicts', () => { it('returns syncSvc.listConflicts() result', () => { const { deps, syncSvc } = makeDeps(); - const conflicts = [{ noteId: 'abc', localText: 'local', remoteText: 'remote' }]; + const conflicts = [{ path: 'notes/abc.md', localText: 'local', remoteText: 'remote' }]; syncSvc.listConflicts.mockReturnValue(conflicts); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:list-conflicts'); @@ -181,28 +181,28 @@ describe('sync IPC channels', () => { }); describe('sync:resolve-conflict', () => { - it('valid choice "local" → delegates to syncSvc.resolveConflict', async () => { + it('valid choice "local" → delegates to syncSvc.resolveConflict (path)', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); - const r = await h({}, 'note-1', 'local'); - expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-1', 'local'); + const r = await h({}, 'notes/note-1.md', 'local'); + expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-1.md', 'local'); expect(r).toEqual({ ok: true }); }); - it('valid choice "remote" → delegates to syncSvc.resolveConflict', async () => { + it('valid choice "remote" → delegates to syncSvc.resolveConflict (path)', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); - await h({}, 'note-2', 'remote'); - expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-2', 'remote'); + await h({}, 'notes/note-2.md', 'remote'); + expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-2.md', 'remote'); }); it('invalid choice → ok: false, reason: invalid choice', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); - const r = await h({}, 'note-1', 'both'); + const r = await h({}, 'notes/note-1.md', 'both'); expect(syncSvc.resolveConflict).not.toHaveBeenCalled(); expect(r).toEqual({ ok: false, reason: 'invalid choice' }); });