diff --git a/src/main/services/GitClient.ts b/src/main/services/GitClient.ts index 49da9fb..e4db135 100644 --- a/src/main/services/GitClient.ts +++ b/src/main/services/GitClient.ts @@ -65,8 +65,21 @@ export class GitClient { throw new Error(`git commit failed: ${r.stderr || r.stdout}`); } + async hasUpstream(): Promise { + 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 { - 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}`); } diff --git a/src/main/services/SyncService.ts b/src/main/services/SyncService.ts new file mode 100644 index 0000000..0a790ab --- /dev/null +++ b/src/main/services/SyncService.ts @@ -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 { + const git = new GitClient(this.syncDir); + if (!(await git.isRepo())) return false; + if (!(await git.hasRemote())) return false; + return true; + } + + async sync(): Promise { + 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 }; + } + } +} diff --git a/tests/unit/SyncService.test.ts b/tests/unit/SyncService.test.ts new file mode 100644 index 0000000..ea10c84 --- /dev/null +++ b/tests/unit/SyncService.test.ts @@ -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 { + 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); + }); +});