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:
altair823
2026-05-10 03:40:09 +09:00
parent 9a1f0e269a
commit 33588b09df
6 changed files with 207 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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