feat(v030): SyncService.resolveConflict — local/remote 2 choice (both deferred)
This commit is contained in:
@@ -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<SyncStatus> {
|
||||
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
|
||||
|
||||
|
||||
60
tests/unit/SyncService.resolveConflict.test.ts
Normal file
60
tests/unit/SyncService.resolveConflict.test.ts
Normal file
@@ -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<typeof vi.fn> };
|
||||
let gitInstance: {
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
addAll: ReturnType<typeof vi.fn>;
|
||||
push: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<typeof vi.fn>).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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user