feat(v030): SyncService.sync — 양방향 6단계 (export/commit/fetch/rebase/re-import/push) + conflict 반환
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<string[]> {
|
||||
const r = await this.run(['diff', '--name-only', '--diff-filter=U']);
|
||||
|
||||
@@ -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<SyncStatus> {
|
||||
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<SyncStatus> {
|
||||
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/<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;
|
||||
}
|
||||
}
|
||||
|
||||
95
tests/unit/SyncService.bidirectional.test.ts
Normal file
95
tests/unit/SyncService.bidirectional.test.ts
Normal file
@@ -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<typeof vi.fn> };
|
||||
let importSvc: { applySyncFromDir: ReturnType<typeof vi.fn> };
|
||||
let gitInstance: {
|
||||
isRepo: ReturnType<typeof vi.fn>;
|
||||
hasRemote: ReturnType<typeof vi.fn>;
|
||||
addAll: ReturnType<typeof vi.fn>;
|
||||
hasUncommittedChanges: ReturnType<typeof vi.fn>;
|
||||
commit: ReturnType<typeof vi.fn>;
|
||||
fetch: ReturnType<typeof vi.fn>;
|
||||
refExists: ReturnType<typeof vi.fn>;
|
||||
rebaseOnto: ReturnType<typeof vi.fn>;
|
||||
rebaseAbort: ReturnType<typeof vi.fn>;
|
||||
listConflicts: ReturnType<typeof vi.fn>;
|
||||
push: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user