fix(v030): SyncConflict noteId→path + populate localText/remoteText (final review fix)

final code review (Opus) 발견 2 important issues:

1. SyncConflict.noteId 가 실제로 export filename slug (date-id8-slug) 였음 — UUID 가
   아니라 git checkout path 의 stem. 명명 혼동 → 'path' 로 rename (실제 의미와 일치).
2. ConflictModal preview 가 항상 빈 문자열이라 사용자가 비교 없이 local/remote 선택해야
   했음. runSync 의 conflict 분기에서 `git show :2:<path>` (ours) + `:3:<path>`
   (theirs) 호출 추가하여 localText/remoteText 채움.

영향:
- SyncService.SyncConflict + shared/types.ts.SyncConflict: noteId → path
- SyncService.resolveConflict(path, choice) — 'notes/...md' 그대로 받음
- pathToNoteId 헬퍼 제거 (불필요)
- ConflictModal: c.noteId → c.path, busy 상태 + 표시 모두 path 키
- IPC handler / preload bridge / InboxApi 시그니처 모두 path 로 통일
- SyncService.bidirectional/resolveConflict/sync-ipc/ConflictModal 4 test 갱신

regression 회귀 패턴 검사: rename 후 NoteRepository / SyncService / IPC / UI 의 모든
conflict-related path 일관 (typecheck 0).
This commit is contained in:
altair823
2026-05-10 04:10:59 +09:00
parent 2ef4802050
commit 401414608b
9 changed files with 85 additions and 60 deletions

View File

@@ -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 계산

View File

@@ -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
* `<date>-<id8>-<slug>.md` (composeFilename), not `<uuid>.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:<path>` = ours (local during rebase),
// `:3:<path>` = 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/<id>.md or notes/<date>-<id8>-<slug>.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;
}
}

View File

@@ -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),

View File

@@ -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
</div>
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
{conflicts.map((c) => (
<div key={c.noteId} style={rowStyle}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>note: {c.noteId}</div>
<div key={c.path} style={rowStyle}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}> </div>
@@ -76,18 +76,18 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen
</div>
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => { void onChoose(c.noteId, 'local'); }}
disabled={busy === c.noteId}
onClick={() => { void onChoose(c.path, 'local'); }}
disabled={busy === c.path}
style={chooseBtnStyle('#0a4b80')}
>
{busy === c.noteId ? '처리 중…' : '내 것 사용'}
{busy === c.path ? '처리 중…' : '내 것 사용'}
</button>
<button
onClick={() => { void onChoose(c.noteId, 'remote'); }}
disabled={busy === c.noteId}
onClick={() => { void onChoose(c.path, 'remote'); }}
disabled={busy === c.path}
style={chooseBtnStyle('#236b1a')}
>
{busy === c.noteId ? '처리 중…' : '원격 사용'}
{busy === c.path ? '처리 중…' : '원격 사용'}
</button>
</div>
</div>

View File

@@ -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<SyncConflict[]>;
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<SyncStatusSnapshot>;
setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>;
setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>;

View File

@@ -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(<ConflictModal onClose={() => {}} 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(<ConflictModal onClose={() => {}} 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(<ConflictModal onClose={onClose} onResolved={onResolved} />);

View File

@@ -20,6 +20,7 @@ describe('SyncService.sync — 양방향', () => {
rebaseAbort: ReturnType<typeof vi.fn>;
listConflicts: ReturnType<typeof vi.fn>;
push: ReturnType<typeof vi.fn>;
run: ReturnType<typeof vi.fn>;
};
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<typeof vi.fn>).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();
});

View File

@@ -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();

View File

@@ -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' });
});