feat(sync): SyncService — F5 export + git add/commit/push to <profileDir>/sync/
F6-L2 MVP 의 오케스트레이터.
- isConfigured(): syncDir 가 git repo + origin remote 있을 때만 true
- sync():
1) ExportService.export(<syncDir>, includeMedia: true) — F5 트리 그대로 덮어쓰기
2) git add -A
3) git commit -m "chore(notes): sync <ts>"
4) "nothing to commit" 이면 changed=false 로 정상 반환
5) git push (upstream 미설정이면 -u origin <branch> 자동)
- GitClient.push() 에 hasUpstream() + 자동 -u 추가 (첫 push 케이스)
- 5 vitest cases — bare local remote 로 push 검증, 두 번째 sync 는 변경 없음 확인
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,8 +65,21 @@ export class GitClient {
|
||||
throw new Error(`git commit failed: ${r.stderr || r.stdout}`);
|
||||
}
|
||||
|
||||
async hasUpstream(): Promise<boolean> {
|
||||
const r = await this.run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
||||
return r.exitCode === 0;
|
||||
}
|
||||
|
||||
async push(remote: string = 'origin', branch?: string): Promise<void> {
|
||||
const args = branch ? ['push', remote, branch] : ['push', remote];
|
||||
// If no upstream is configured, set one with -u so subsequent `git push` works.
|
||||
const upstream = await this.hasUpstream();
|
||||
let args: string[];
|
||||
if (!upstream) {
|
||||
const targetBranch = branch ?? (await this.currentBranch());
|
||||
args = ['push', '-u', remote, targetBranch];
|
||||
} else {
|
||||
args = branch ? ['push', remote, branch] : ['push', remote];
|
||||
}
|
||||
const r = await this.run(args);
|
||||
if (r.exitCode !== 0) throw new Error(`git push failed: ${r.stderr}`);
|
||||
}
|
||||
|
||||
63
src/main/services/SyncService.ts
Normal file
63
src/main/services/SyncService.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import type { ExportService } from './ExportService.js';
|
||||
import { GitClient } from './GitClient.js';
|
||||
|
||||
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;
|
||||
pushed?: boolean;
|
||||
}
|
||||
|
||||
export class SyncService {
|
||||
private syncDir: string;
|
||||
|
||||
constructor(
|
||||
private profileDir: string,
|
||||
private exportSvc: ExportService,
|
||||
private now: () => Date = () => new Date()
|
||||
) {
|
||||
this.syncDir = join(profileDir, 'sync');
|
||||
}
|
||||
|
||||
getSyncDir(): string {
|
||||
return this.syncDir;
|
||||
}
|
||||
|
||||
async isConfigured(): Promise<boolean> {
|
||||
const git = new GitClient(this.syncDir);
|
||||
if (!(await git.isRepo())) return false;
|
||||
if (!(await git.hasRemote())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async sync(): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) {
|
||||
return { ok: false, reason: 'not_configured' };
|
||||
}
|
||||
// 1. Re-export the full tree into syncDir (idempotent).
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
await git.push();
|
||||
return { ok: true, changed: true, sha: commit.sha, pushed: true };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: (e as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
131
tests/unit/SyncService.test.ts
Normal file
131
tests/unit/SyncService.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync, mkdirSync, existsSync, closeSync, openSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
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 { SyncService } from '@main/services/SyncService.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let emptyConfigPath: string;
|
||||
let emptyConfigDir: string;
|
||||
|
||||
function gitEnv(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: emptyConfigPath,
|
||||
GIT_CONFIG_SYSTEM: emptyConfigPath,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@inkling.local',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@inkling.local'
|
||||
};
|
||||
}
|
||||
|
||||
async function initRepoWithBareRemote(workDir: string): Promise<string> {
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
const env = gitEnv();
|
||||
await execFileAsync('git', ['init', '-b', 'main', workDir], { env });
|
||||
await execFileAsync('git', ['-C', workDir, 'config', 'user.email', 'test@inkling.local'], { env });
|
||||
await execFileAsync('git', ['-C', workDir, 'config', 'user.name', 'Test'], { env });
|
||||
// Bare local remote so push works without network.
|
||||
const remoteDir = `${workDir}-remote.git`;
|
||||
await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir], { env });
|
||||
await execFileAsync('git', ['-C', workDir, 'remote', 'add', 'origin', remoteDir], { env });
|
||||
return remoteDir;
|
||||
}
|
||||
|
||||
describe('SyncService', () => {
|
||||
let profileDir: string;
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let mediaStore: MediaStore;
|
||||
let exportSvc: ExportService;
|
||||
let svc: SyncService;
|
||||
let remoteDir: string | null = null;
|
||||
let prevEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
emptyConfigDir = mkdtempSync(join(tmpdir(), 'inkling-sync-cfg-'));
|
||||
emptyConfigPath = join(emptyConfigDir, 'empty.gitconfig');
|
||||
closeSync(openSync(emptyConfigPath, 'w'));
|
||||
|
||||
prevEnv = { ...process.env };
|
||||
Object.assign(process.env, {
|
||||
GIT_CONFIG_GLOBAL: emptyConfigPath,
|
||||
GIT_CONFIG_SYSTEM: emptyConfigPath,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@inkling.local',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@inkling.local'
|
||||
});
|
||||
|
||||
profileDir = mkdtempSync(join(tmpdir(), 'inkling-sync-'));
|
||||
mkdirSync(join(profileDir, 'media'), { recursive: true });
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
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'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(profileDir, { recursive: true, force: true });
|
||||
if (remoteDir !== null) {
|
||||
rmSync(remoteDir, { recursive: true, force: true });
|
||||
remoteDir = null;
|
||||
}
|
||||
rmSync(emptyConfigDir, { recursive: true, force: true });
|
||||
for (const k of Object.keys(process.env)) delete process.env[k];
|
||||
Object.assign(process.env, prevEnv);
|
||||
});
|
||||
|
||||
it('isConfigured returns false before sync dir is set up', async () => {
|
||||
expect(await svc.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('sync returns not_configured when sync dir missing', async () => {
|
||||
const r = await svc.sync();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('not_configured');
|
||||
});
|
||||
|
||||
it('isConfigured returns true after init + remote', async () => {
|
||||
remoteDir = await initRepoWithBareRemote(svc.getSyncDir());
|
||||
expect(await svc.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('sync writes export tree, commits, pushes — first run', async () => {
|
||||
remoteDir = await initRepoWithBareRemote(svc.getSyncDir());
|
||||
repo.create({ rawText: 'first note' });
|
||||
const r = await svc.sync();
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.pushed).toBe(true);
|
||||
expect(r.sha).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);
|
||||
});
|
||||
|
||||
it('sync returns changed=false when DB unchanged on second run', async () => {
|
||||
remoteDir = await initRepoWithBareRemote(svc.getSyncDir());
|
||||
repo.create({ rawText: 'first note' });
|
||||
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.
|
||||
const second = await svc.sync();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.changed).toBe(false);
|
||||
expect(second.pushed).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user