diff --git a/packages/boxel-cli/src/commands/realm/index.ts b/packages/boxel-cli/src/commands/realm/index.ts index fd723b8d29..da81f24d47 100644 --- a/packages/boxel-cli/src/commands/realm/index.ts +++ b/packages/boxel-cli/src/commands/realm/index.ts @@ -2,6 +2,7 @@ import type { Command } from 'commander'; import { registerCreateCommand } from './create'; import { registerPullCommand } from './pull'; import { registerPushCommand } from './push'; +import { registerSyncCommand } from './sync'; export function registerRealmCommand(program: Command): void { let realm = program @@ -11,4 +12,5 @@ export function registerRealmCommand(program: Command): void { registerCreateCommand(realm); registerPullCommand(realm); registerPushCommand(realm); + registerSyncCommand(realm); } diff --git a/packages/boxel-cli/src/commands/realm/push.ts b/packages/boxel-cli/src/commands/realm/push.ts index 1b6d04411a..937bc58177 100644 --- a/packages/boxel-cli/src/commands/realm/push.ts +++ b/packages/boxel-cli/src/commands/realm/push.ts @@ -12,85 +12,13 @@ import { getProfileManager, type ProfileManager, } from '../../lib/profile-manager'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import * as crypto from 'crypto'; - -interface SyncManifest { - realmUrl: string; - files: Record; // relativePath -> contentHash - remoteMtimes?: Record; // relativePath -> last-seen server mtime -} - -function isValidManifest(value: unknown): value is SyncManifest { - if (typeof value !== 'object' || value === null) return false; - const v = value as Record; - if (typeof v.realmUrl !== 'string') return false; - if (typeof v.files !== 'object' || v.files === null) return false; - for (const hash of Object.values(v.files as Record)) { - if (typeof hash !== 'string') return false; - } - if (v.remoteMtimes !== undefined) { - if (typeof v.remoteMtimes !== 'object' || v.remoteMtimes === null) { - return false; - } - for (const mtime of Object.values( - v.remoteMtimes as Record, - )) { - if (typeof mtime !== 'number') return false; - } - } - return true; -} - -async function pathExists(p: string): Promise { - try { - await fs.access(p); - return true; - } catch { - return false; - } -} - -async function computeFileHash(filePath: string): Promise { - const content = await fs.readFile(filePath); - return crypto.createHash('md5').update(content).digest('hex'); -} - -async function loadManifest(localDir: string): Promise { - const manifestPath = path.join(localDir, '.boxel-sync.json'); - let content: string; - try { - content = await fs.readFile(manifestPath, 'utf8'); - } catch (err: any) { - if (err.code === 'ENOENT') return null; - throw err; - } - - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch { - return null; - } - - if (!isValidManifest(parsed)) { - console.warn( - 'Warning: .boxel-sync.json is malformed or has an unexpected shape; falling back to a full upload.', - ); - return null; - } - - return parsed; -} - -async function saveManifest( - localDir: string, - manifest: SyncManifest, -): Promise { - const manifestPath = path.join(localDir, '.boxel-sync.json'); - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); -} +import { + type SyncManifest, + computeFileHash, + loadManifest, + saveManifest, + pathExists, +} from '../../lib/sync-manifest'; interface PushOptions extends SyncOptions { deleteRemote?: boolean; diff --git a/packages/boxel-cli/src/commands/realm/sync.ts b/packages/boxel-cli/src/commands/realm/sync.ts new file mode 100644 index 0000000000..13cdf8daa3 --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/sync.ts @@ -0,0 +1,587 @@ +import type { Command } from 'commander'; +import { + RealmSyncBase, + isProtectedFile, + type SyncOptions, +} from '../../lib/realm-sync-base'; +import { + CheckpointManager, + type CheckpointChange, +} from '../../lib/checkpoint-manager'; +import { + getProfileManager, + type ProfileManager, +} from '../../lib/profile-manager'; +import { + type SyncManifest, + computeFileHash, + loadManifest, + saveManifest, + pathExists, +} from '../../lib/sync-manifest'; +import * as path from 'path'; +import { + FG_GREEN, + FG_YELLOW, + FG_RED, + FG_CYAN, + DIM, + RESET, +} from '../../lib/colors'; +import { + classifyLocal, + classifyRemote, + determineAction, + resolveConflict, + type FileClassification, + type ConflictStrategy, +} from '../../lib/sync-logic'; + +interface BiSyncOptions extends SyncOptions { + preferLocal?: boolean; + preferRemote?: boolean; + preferNewest?: boolean; + deleteSync?: boolean; +} + +class RealmSyncer extends RealmSyncBase { + hasError = false; + + constructor( + private syncOptions: BiSyncOptions, + profileManager: ProfileManager, + ) { + super(syncOptions, profileManager); + } + + private get conflictStrategy(): ConflictStrategy | null { + if (this.syncOptions.preferLocal) return 'prefer-local'; + if (this.syncOptions.preferRemote) return 'prefer-remote'; + if (this.syncOptions.preferNewest) return 'prefer-newest'; + return null; + } + + async sync(): Promise { + console.log( + `Starting sync between ${this.options.localDir} and ${this.options.realmUrl}`, + ); + + console.log('Testing realm access...'); + let remoteFileList: Map | undefined; + try { + remoteFileList = await this.getRemoteFileList(''); + } catch (error) { + console.error('Failed to access realm:', error); + throw new Error( + 'Cannot proceed with sync: Authentication or access failed. ' + + 'Please check your Matrix credentials and realm permissions.', + ); + } + console.log('Realm access verified'); + + // Phase 1: Gather state (single local traversal — derive localFiles from mtimes result) + const [localFilesWithMtimes, remoteMtimes, manifest] = await Promise.all([ + this.getLocalFileListWithMtimes(), + this.getRemoteMtimes(), + loadManifest(this.options.localDir), + ]); + + const localFiles = new Map(); + for (const [rel, info] of localFilesWithMtimes) { + localFiles.set(rel, info.path); + } + + // Fall back to file listing when _mtimes endpoint is unavailable + if (remoteMtimes.size === 0 && remoteFileList && remoteFileList.size > 0) { + console.log( + 'Remote mtimes unavailable, falling back to file listing for remote detection', + ); + for (const [filePath] of remoteFileList) { + remoteMtimes.set(filePath, 0); + } + } + + console.log(`Found ${localFiles.size} local files`); + console.log(`Found ${remoteMtimes.size} remote files`); + + if (manifest && manifest.realmUrl !== this.normalizedRealmUrl) { + console.warn( + `${FG_YELLOW}Warning:${RESET} Manifest realm URL (${manifest.realmUrl}) differs from target (${this.normalizedRealmUrl}). Treating as first sync.`, + ); + } + + const effectiveManifest = + manifest && manifest.realmUrl === this.normalizedRealmUrl + ? manifest + : null; + + // Compute local file hashes + const localHashes = new Map(); + await Promise.all( + Array.from(localFiles.entries()).map(async ([rel, absPath]) => { + if (!isProtectedFile(rel)) { + localHashes.set(rel, await computeFileHash(absPath)); + } + }), + ); + + // Phase 2: Classify each file + const allPaths = new Set(); + for (const p of localFiles.keys()) allPaths.add(p); + for (const p of remoteMtimes.keys()) allPaths.add(p); + if (effectiveManifest) { + for (const p of Object.keys(effectiveManifest.files)) allPaths.add(p); + if (effectiveManifest.remoteMtimes) { + for (const p of Object.keys(effectiveManifest.remoteMtimes)) + allPaths.add(p); + } + } + + const classifications: FileClassification[] = []; + + for (const relativePath of allPaths) { + if (isProtectedFile(relativePath)) continue; + + const localStatus = classifyLocal( + relativePath, + localHashes, + effectiveManifest, + ); + const remoteStatus = classifyRemote( + relativePath, + remoteMtimes, + effectiveManifest, + ); + + const action = determineAction( + localStatus, + remoteStatus, + this.syncOptions, + ); + + classifications.push({ relativePath, localStatus, remoteStatus, action }); + } + + // Phase 3: Summarize and resolve conflicts + const toPush: string[] = []; + const toPull: string[] = []; + const toPushDelete: string[] = []; + const toPullDelete: string[] = []; + const conflicts: FileClassification[] = []; + let noopCount = 0; + + for (const c of classifications) { + switch (c.action) { + case 'push': + toPush.push(c.relativePath); + break; + case 'pull': + toPull.push(c.relativePath); + break; + case 'push-delete': + toPushDelete.push(c.relativePath); + break; + case 'pull-delete': + toPullDelete.push(c.relativePath); + break; + case 'conflict': + conflicts.push(c); + break; + case 'noop': + noopCount++; + break; + } + } + + // Resolve conflicts + const skippedConflicts: string[] = []; + for (const c of conflicts) { + const resolved = resolveConflict( + c, + localFilesWithMtimes, + remoteMtimes, + this.conflictStrategy, + ); + switch (resolved) { + case 'push': + toPush.push(c.relativePath); + break; + case 'pull': + toPull.push(c.relativePath); + break; + case 'push-delete': + toPushDelete.push(c.relativePath); + break; + case 'pull-delete': + toPullDelete.push(c.relativePath); + break; + case 'noop': + // deleted on both sides + break; + default: + skippedConflicts.push(c.relativePath); + break; + } + } + + // Print summary + console.log(`\n${DIM}Sync plan:${RESET}`); + if (toPush.length > 0) + console.log(` ${FG_GREEN}↑ Push:${RESET} ${toPush.length} file(s)`); + if (toPull.length > 0) + console.log(` ${FG_CYAN}↓ Pull:${RESET} ${toPull.length} file(s)`); + if (toPushDelete.length > 0) + console.log( + ` ${FG_RED}↑ Delete remote:${RESET} ${toPushDelete.length} file(s)`, + ); + if (toPullDelete.length > 0) + console.log( + ` ${FG_RED}↓ Delete local:${RESET} ${toPullDelete.length} file(s)`, + ); + if (skippedConflicts.length > 0) { + console.log( + ` ${FG_YELLOW}⚠ Conflicts skipped:${RESET} ${skippedConflicts.length} file(s)`, + ); + for (const p of skippedConflicts) { + console.log(` ${p}`); + } + console.log( + ` ${DIM}Use --prefer-local, --prefer-remote, or --prefer-newest to resolve.${RESET}`, + ); + } + if (noopCount > 0) + console.log(` ${DIM}Unchanged: ${noopCount} file(s)${RESET}`); + + const totalOps = + toPush.length + toPull.length + toPushDelete.length + toPullDelete.length; + + if (totalOps === 0) { + console.log('\nEverything is up to date'); + if ( + !this.options.dryRun && + !effectiveManifest && + skippedConflicts.length === 0 + ) { + // First sync with no changes needed - still write manifest + await this.writeManifest(localHashes, remoteMtimes); + } + return; + } + + // Phase 5: Execute operations (order: pulls, pushes, remote deletes, local deletes) + const pulledFiles: string[] = []; + const pushedFiles: string[] = []; + const remoteDeletedFiles: string[] = []; + const localDeletedFiles: string[] = []; + + // Downloads (pulls) + if (toPull.length > 0) { + console.log(`\nPulling ${toPull.length} file(s)...`); + const results = await Promise.all( + toPull.map((rel) => + this.remoteLimit(async () => { + try { + const localPath = path.join(this.options.localDir, rel); + await this.downloadFile(rel, localPath); + return rel; + } catch (error) { + this.hasError = true; + console.error(`Error downloading ${rel}:`, error); + return null; + } + }), + ), + ); + pulledFiles.push(...results.filter((f): f is string => f !== null)); + } + + // Uploads (pushes) via atomic + if (toPush.length > 0) { + console.log(`\nPushing ${toPush.length} file(s)...`); + const filesToUpload = new Map(); + for (const rel of toPush) { + const absPath = localFiles.get(rel); + if (absPath) filesToUpload.set(rel, absPath); + } + + // Determine add vs update based on whether file exists in manifest or on remote + const addPaths = new Set(); + for (const rel of filesToUpload.keys()) { + const inManifest = effectiveManifest?.files[rel] !== undefined; + const existsOnRemote = remoteMtimes.has(rel); + if (!inManifest && !existsOnRemote) { + addPaths.add(rel); + } + } + + const result = await this.uploadFilesAtomic(filesToUpload, addPaths); + if (result.error) { + this.hasError = true; + console.error(result.error.message); + for (const entry of result.error.perFile) { + console.error(` ${entry.path}: ${entry.title}`); + } + } else { + pushedFiles.push(...result.succeeded); + } + } + + // Remote deletions + if (toPushDelete.length > 0) { + console.log(`\nDeleting ${toPushDelete.length} remote file(s)...`); + const deleteResults = await Promise.all( + toPushDelete.map((rel) => + this.remoteLimit(async () => { + try { + await this.deleteFile(rel); + return rel; + } catch (error) { + this.hasError = true; + console.error(`Error deleting remote ${rel}:`, error); + return null; + } + }), + ), + ); + remoteDeletedFiles.push( + ...deleteResults.filter((f): f is string => f !== null), + ); + } + + // Local deletions + if (toPullDelete.length > 0) { + console.log(`\nDeleting ${toPullDelete.length} local file(s)...`); + const localDeleteResults = await Promise.all( + toPullDelete.map(async (rel) => { + try { + const localPath = localFiles.get(rel); + if (localPath) { + await this.deleteLocalFile(localPath); + return rel; + } + return null; + } catch (error) { + this.hasError = true; + console.error(`Error deleting local ${rel}:`, error); + return null; + } + }), + ); + localDeletedFiles.push( + ...localDeleteResults.filter((f): f is string => f !== null), + ); + } + + // Phase 6: Update manifest + if (!this.options.dryRun && !this.hasError) { + // Build updated hashes from prior manifest + current local files + executed ops. + // Start with the previous manifest so that files deleted locally but not + // propagated (no --delete) retain their entries and aren't re-pulled next sync. + const updatedHashes = new Map(); + if (effectiveManifest) { + for (const [rel, hash] of Object.entries(effectiveManifest.files)) { + updatedHashes.set(rel, hash); + } + } + // Overlay current local file hashes (covers new, changed, and unchanged local files) + for (const [rel, hash] of localHashes) { + updatedHashes.set(rel, hash); + } + // Recompute hashes for pushed files (content may have been normalized) + for (const rel of pushedFiles) { + const absPath = localFiles.get(rel); + if (absPath) { + updatedHashes.set(rel, await computeFileHash(absPath)); + } + } + // Add hashes for pulled files (newly downloaded) + for (const rel of pulledFiles) { + const absPath = path.join(this.options.localDir, rel); + updatedHashes.set(rel, await computeFileHash(absPath)); + } + // Remove files that were actually deleted (propagated deletions only) + for (const rel of remoteDeletedFiles) updatedHashes.delete(rel); + for (const rel of localDeletedFiles) updatedHashes.delete(rel); + + // Refresh remote mtimes after pushes + let freshMtimes = remoteMtimes; + if (pushedFiles.length > 0 || remoteDeletedFiles.length > 0) { + try { + freshMtimes = await this.getRemoteMtimes(); + } catch { + console.warn('Could not refresh remote mtimes after sync'); + } + } + + await this.writeManifest(updatedHashes, freshMtimes); + } + + // Phase 7: Checkpoint + if (!this.options.dryRun) { + const allChanges: CheckpointChange[] = [ + ...pushedFiles.map((f) => ({ + file: f, + status: 'modified' as const, + })), + ...pulledFiles.map((f) => ({ + file: f, + status: 'modified' as const, + })), + ...remoteDeletedFiles.map((f) => ({ + file: f, + status: 'deleted' as const, + })), + ...localDeletedFiles.map((f) => ({ + file: f, + status: 'deleted' as const, + })), + ]; + + if (allChanges.length > 0) { + const checkpointManager = new CheckpointManager(this.options.localDir); + const checkpoint = await checkpointManager.createCheckpoint( + 'local', + allChanges, + ); + if (checkpoint) { + const tag = checkpoint.isMajor ? '[MAJOR]' : '[minor]'; + console.log( + `\nCheckpoint created: ${checkpoint.shortHash} ${tag} ${checkpoint.message}`, + ); + } + } + } + + console.log('\nSync completed'); + } + + private async writeManifest( + hashes: Map, + remoteMtimes: Map, + ): Promise { + const manifest: SyncManifest = { + realmUrl: this.normalizedRealmUrl, + files: {}, + remoteMtimes: {}, + }; + + for (const [rel, hash] of hashes) { + manifest.files[rel] = hash; + const mtime = remoteMtimes.get(rel); + if (mtime !== undefined && mtime !== 0) { + manifest.remoteMtimes![rel] = mtime; + } + } + + if ( + manifest.remoteMtimes && + Object.keys(manifest.remoteMtimes).length === 0 + ) { + delete manifest.remoteMtimes; + } + + await saveManifest(this.options.localDir, manifest); + } +} + +export interface SyncCommandOptions { + preferLocal?: boolean; + preferRemote?: boolean; + preferNewest?: boolean; + delete?: boolean; + dryRun?: boolean; + profileManager?: ProfileManager; +} + +export function registerSyncCommand(realm: Command): void { + realm + .command('sync') + .description( + 'Bidirectional sync between a local directory and a Boxel realm', + ) + .argument('', 'The local directory to sync') + .argument( + '', + 'The URL of the target realm (e.g., https://app.boxel.ai/demo/)', + ) + .option('--prefer-local', 'Resolve conflicts by keeping local version') + .option('--prefer-remote', 'Resolve conflicts by keeping remote version') + .option('--prefer-newest', 'Resolve conflicts by keeping newest version') + .option('--delete', 'Sync deletions both ways') + .option('--dry-run', 'Preview without making changes') + .action( + async ( + localDir: string, + realmUrl: string, + options: { + preferLocal?: boolean; + preferRemote?: boolean; + preferNewest?: boolean; + delete?: boolean; + dryRun?: boolean; + }, + ) => { + await syncCommand(localDir, realmUrl, options); + }, + ); +} + +export async function syncCommand( + localDir: string, + realmUrl: string, + options: SyncCommandOptions, +): Promise { + let pm = options.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + console.error( + 'Error: no active profile. Run `boxel profile add` to create one.', + ); + process.exit(1); + } + + // Validate mutually exclusive strategies + const strategies = [ + options.preferLocal, + options.preferRemote, + options.preferNewest, + ].filter(Boolean); + if (strategies.length > 1) { + console.error( + 'Error: only one conflict strategy can be specified (--prefer-local, --prefer-remote, or --prefer-newest)', + ); + process.exit(1); + } + + if (!(await pathExists(localDir))) { + console.error(`Local directory does not exist: ${localDir}`); + process.exit(1); + } + + try { + const syncer = new RealmSyncer( + { + realmUrl, + localDir, + preferLocal: options.preferLocal, + preferRemote: options.preferRemote, + preferNewest: options.preferNewest, + deleteSync: options.delete, + dryRun: options.dryRun, + }, + pm, + ); + + await syncer.sync(); + + if (syncer.hasError) { + console.log('Sync did not complete successfully. View logs for details'); + process.exit(2); + } else { + console.log('Sync completed successfully'); + } + } catch (error) { + console.error('Sync failed:', error); + process.exit(1); + } +} diff --git a/packages/boxel-cli/src/lib/sync-logic.ts b/packages/boxel-cli/src/lib/sync-logic.ts new file mode 100644 index 0000000000..303abd4397 --- /dev/null +++ b/packages/boxel-cli/src/lib/sync-logic.ts @@ -0,0 +1,169 @@ +import type { SyncManifest } from './sync-manifest'; + +export type SideStatus = 'unchanged' | 'changed' | 'added' | 'deleted'; +export type SyncAction = + | 'push' + | 'pull' + | 'push-delete' + | 'pull-delete' + | 'conflict' + | 'noop'; + +export interface FileClassification { + relativePath: string; + localStatus: SideStatus; + remoteStatus: SideStatus; + action: SyncAction; +} + +export type ConflictStrategy = + | 'prefer-local' + | 'prefer-remote' + | 'prefer-newest'; + +export interface SyncOptions { + deleteSync?: boolean; + preferLocal?: boolean; + preferRemote?: boolean; +} + +export function classifyLocal( + relativePath: string, + localHashes: Map, + manifest: SyncManifest | null, +): SideStatus { + const hasLocal = localHashes.has(relativePath); + const inManifest = manifest?.files[relativePath] !== undefined; + + if (hasLocal && inManifest) { + return localHashes.get(relativePath) === manifest!.files[relativePath] + ? 'unchanged' + : 'changed'; + } + if (hasLocal && !inManifest) return 'added'; + if (!hasLocal && inManifest) return 'deleted'; + // Not local, not in manifest — this file only exists remotely + return 'unchanged'; // not relevant on local side +} + +export function classifyRemote( + relativePath: string, + remoteMtimes: Map, + manifest: SyncManifest | null, +): SideStatus { + const hasRemote = remoteMtimes.has(relativePath); + const inManifestMtimes = manifest?.remoteMtimes?.[relativePath] !== undefined; + // Use manifest.files as secondary known-paths set when remoteMtimes is missing + const inManifestFiles = manifest?.files[relativePath] !== undefined; + const knownInManifest = inManifestMtimes || inManifestFiles; + + if (hasRemote && inManifestMtimes) { + return remoteMtimes.get(relativePath) === + manifest!.remoteMtimes![relativePath] + ? 'unchanged' + : 'changed'; + } + // Known in manifest.files but no mtime to compare — treat as changed, not added + if (hasRemote && inManifestFiles) return 'changed'; + if (hasRemote && !knownInManifest) return 'added'; + if (!hasRemote && knownInManifest) return 'deleted'; + // Not remote, not in manifest — only exists locally + return 'unchanged'; // not relevant on remote side +} + +export function determineAction( + local: SideStatus, + remote: SideStatus, + syncOptions: SyncOptions, +): SyncAction { + // Both unchanged + if (local === 'unchanged' && remote === 'unchanged') return 'noop'; + + // One side changed, other unchanged + if (local === 'changed' && remote === 'unchanged') return 'push'; + if (local === 'unchanged' && remote === 'changed') return 'pull'; + + // One side added, other doesn't exist + if (local === 'added' && remote === 'unchanged') return 'push'; + if (local === 'unchanged' && remote === 'added') return 'pull'; + + // Both changed or both added — conflict + if ( + (local === 'changed' && remote === 'changed') || + (local === 'added' && remote === 'added') + ) { + return 'conflict'; + } + + // Cross-state conflicts (e.g., manifest missing remoteMtimes) + if ( + (local === 'changed' && remote === 'added') || + (local === 'added' && remote === 'changed') + ) { + return 'conflict'; + } + + // Deletions + if (local === 'deleted' && remote === 'unchanged') { + return syncOptions.deleteSync || syncOptions.preferLocal + ? 'push-delete' + : 'noop'; + } + if (local === 'unchanged' && remote === 'deleted') { + return syncOptions.deleteSync || syncOptions.preferRemote + ? 'pull-delete' + : 'noop'; + } + + // Delete vs change conflicts + if (local === 'deleted' && remote === 'changed') return 'conflict'; + if (local === 'changed' && remote === 'deleted') return 'conflict'; + + // Both deleted + if (local === 'deleted' && remote === 'deleted') return 'noop'; + + // Added vs deleted (shouldn't normally happen but handle gracefully) + if (local === 'added' && remote === 'deleted') return 'push'; + if (local === 'deleted' && remote === 'added') return 'pull'; + + return 'noop'; +} + +export function resolveConflict( + classification: FileClassification, + localFilesWithMtimes: Map, + remoteMtimes: Map, + strategy: ConflictStrategy | null, +): SyncAction | null { + const { localStatus, remoteStatus, relativePath } = classification; + + if (!strategy) return null; // skip — no strategy + + switch (strategy) { + case 'prefer-local': + if (localStatus === 'deleted') return 'push-delete'; + return 'push'; + + case 'prefer-remote': + if (remoteStatus === 'deleted') return 'pull-delete'; + return 'pull'; + + case 'prefer-newest': { + // For delete-vs-change, the change always wins + if (localStatus === 'deleted' && remoteStatus === 'changed') + return 'pull'; + if (localStatus === 'changed' && remoteStatus === 'deleted') + return 'push'; + + const localInfo = localFilesWithMtimes.get(relativePath); + const remoteMtime = remoteMtimes.get(relativePath); + + if (localInfo && remoteMtime !== undefined) { + // Remote mtimes are in seconds (epoch), local mtimes are in ms + return localInfo.mtime > remoteMtime * 1000 ? 'push' : 'pull'; + } + // If we can't compare, prefer local (it's what the user has) + return 'push'; + } + } +} diff --git a/packages/boxel-cli/src/lib/sync-manifest.ts b/packages/boxel-cli/src/lib/sync-manifest.ts new file mode 100644 index 0000000000..c6944be723 --- /dev/null +++ b/packages/boxel-cli/src/lib/sync-manifest.ts @@ -0,0 +1,81 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +export interface SyncManifest { + realmUrl: string; + files: Record; // relativePath -> contentHash + remoteMtimes?: Record; // relativePath -> last-seen server mtime +} + +export function isValidManifest(value: unknown): value is SyncManifest { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + if (typeof v.realmUrl !== 'string') return false; + if (typeof v.files !== 'object' || v.files === null) return false; + for (const hash of Object.values(v.files as Record)) { + if (typeof hash !== 'string') return false; + } + if (v.remoteMtimes !== undefined) { + if (typeof v.remoteMtimes !== 'object' || v.remoteMtimes === null) { + return false; + } + for (const mtime of Object.values( + v.remoteMtimes as Record, + )) { + if (typeof mtime !== 'number') return false; + } + } + return true; +} + +export async function pathExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +export async function computeFileHash(filePath: string): Promise { + const content = await fs.readFile(filePath); + return crypto.createHash('md5').update(content).digest('hex'); +} + +export async function loadManifest( + localDir: string, +): Promise { + const manifestPath = path.join(localDir, '.boxel-sync.json'); + let content: string; + try { + content = await fs.readFile(manifestPath, 'utf8'); + } catch (err: any) { + if (err.code === 'ENOENT') return null; + throw err; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + + if (!isValidManifest(parsed)) { + console.warn( + 'Warning: .boxel-sync.json is malformed or has an unexpected shape; falling back to a full upload.', + ); + return null; + } + + return parsed; +} + +export async function saveManifest( + localDir: string, + manifest: SyncManifest, +): Promise { + const manifestPath = path.join(localDir, '.boxel-sync.json'); + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); +} diff --git a/packages/boxel-cli/tests/integration/realm-sync.test.ts b/packages/boxel-cli/tests/integration/realm-sync.test.ts new file mode 100644 index 0000000000..b7f3a369e9 --- /dev/null +++ b/packages/boxel-cli/tests/integration/realm-sync.test.ts @@ -0,0 +1,538 @@ +import '../helpers/setup-realm-server'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { syncCommand } from '../../src/commands/realm/sync'; +import { pushCommand } from '../../src/commands/realm/push'; +import { createRealm } from '../../src/commands/realm/create'; +import { CheckpointManager } from '../../src/lib/checkpoint-manager'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + setupTestProfile, + uniqueRealmName, +} from '../helpers/integration'; +import type { ProfileManager } from '../../src/lib/profile-manager'; + +let profileManager: ProfileManager; +let cleanupProfile: () => void; +let localDirs: string[] = []; + +function makeLocalDir(): string { + let dir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-sync-int-')); + localDirs.push(dir); + return dir; +} + +function writeLocalFile(localDir: string, relPath: string, content: string) { + let fullPath = path.join(localDir, relPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); +} + +function readLocalFile(localDir: string, relPath: string): string { + return fs.readFileSync(path.join(localDir, relPath), 'utf8'); +} + +function localFileExists(localDir: string, relPath: string): boolean { + return fs.existsSync(path.join(localDir, relPath)); +} + +interface SyncManifest { + realmUrl: string; + files: Record; + remoteMtimes?: Record; +} + +function readManifest(localDir: string): SyncManifest { + return JSON.parse( + fs.readFileSync(path.join(localDir, '.boxel-sync.json'), 'utf8'), + ); +} + +function manifestExists(localDir: string): boolean { + return fs.existsSync(path.join(localDir, '.boxel-sync.json')); +} + +async function createTestRealm(): Promise { + let name = uniqueRealmName(); + await createRealm(name, `Test ${name}`, { profileManager }); + + let realmTokens = + profileManager.getActiveProfile()!.profile.realmTokens ?? {}; + let entry = Object.entries(realmTokens).find(([url]) => url.includes(name)); + if (!entry) { + throw new Error(`No realm JWT stored for ${name}`); + } + return entry[0]; +} + +function buildFileUrl(realmUrl: string, relPath: string): string { + let base = realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`; + return `${base}${relPath.replace(/^\/+/, '')}`; +} + +async function fetchRemoteFile( + realmUrl: string, + relPath: string, +): Promise { + let url = buildFileUrl(realmUrl, relPath); + let response = await profileManager.authedRealmFetch(url, { + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok) { + throw new Error( + `Fetching ${url} failed: ${response.status} ${response.statusText}`, + ); + } + return response.text(); +} + +async function remoteFileExists( + realmUrl: string, + relPath: string, +): Promise { + let url = buildFileUrl(realmUrl, relPath); + let response = await profileManager.authedRealmFetch(url, { + headers: { Accept: 'application/vnd.card+source' }, + }); + return response.ok; +} + +async function writeRemoteFile( + realmUrl: string, + relPath: string, + content: string, +): Promise { + let url = buildFileUrl(realmUrl, relPath); + let response = await profileManager.authedRealmFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain;charset=UTF-8', + Accept: 'application/vnd.card+source', + }, + body: content, + }); + if (!response.ok) { + throw new Error( + `Write ${url} failed: ${response.status} ${response.statusText}`, + ); + } +} + +async function deleteRemoteFile( + realmUrl: string, + relPath: string, +): Promise { + let url = buildFileUrl(realmUrl, relPath); + let response = await profileManager.authedRealmFetch(url, { + method: 'DELETE', + headers: { Accept: 'application/vnd.card+source' }, + }); + if (!response.ok && response.status !== 404) { + throw new Error( + `Delete ${url} failed: ${response.status} ${response.statusText}`, + ); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Helper: establish a synced baseline by pushing local files. +// Waits 1s after push so that subsequent remote writes get a different mtime +// (realm server mtimes use second-precision). +async function establishBaseline( + localDir: string, + realmUrl: string, + files: Record, +): Promise { + for (const [relPath, content] of Object.entries(files)) { + writeLocalFile(localDir, relPath, content); + } + await pushCommand(localDir, realmUrl, { profileManager }); + await sleep(1100); +} + +beforeAll(async () => { + await startTestRealmServer(); + + let testProfile = createTestProfileDir(); + profileManager = testProfile.profileManager; + cleanupProfile = testProfile.cleanup; + await setupTestProfile(profileManager); +}); + +afterAll(async () => { + for (let dir of localDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + cleanupProfile?.(); + await stopTestRealmServer(); +}); + +describe('realm sync (integration)', () => { + it('pushes local-only files to remote', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalFile(localDir, 'card.gts', 'export const card = true;\n'); + writeLocalFile(localDir, 'data.json', '{"title":"Hello"}\n'); + + await syncCommand(localDir, realmUrl, { + preferLocal: true, + profileManager, + }); + + expect(await remoteFileExists(realmUrl, 'card.gts')).toBe(true); + expect(await remoteFileExists(realmUrl, 'data.json')).toBe(true); + expect(await fetchRemoteFile(realmUrl, 'card.gts')).toContain( + 'card = true', + ); + + expect(manifestExists(localDir)).toBe(true); + let manifest = readManifest(localDir); + expect(manifest.realmUrl).toBe(realmUrl); + expect(Object.keys(manifest.files).sort()).toContain('card.gts'); + expect(Object.keys(manifest.files).sort()).toContain('data.json'); + }); + + it('pulls remote-only files to local', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + // Write files directly to remote + await writeRemoteFile(realmUrl, 'remote-only.gts', 'export const r = 1;\n'); + + await syncCommand(localDir, realmUrl, { + preferRemote: true, + profileManager, + }); + + expect(localFileExists(localDir, 'remote-only.gts')).toBe(true); + expect(readLocalFile(localDir, 'remote-only.gts')).toContain('r = 1'); + }); + + it('syncs bidirectionally: pushes local changes and pulls remote changes', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + // Establish baseline with two files + await establishBaseline(localDir, realmUrl, { + 'a.gts': 'export const a = 1;\n', + 'b.gts': 'export const b = 1;\n', + }); + + // Modify a.gts locally, modify b.gts remotely + writeLocalFile(localDir, 'a.gts', 'export const a = 2;\n'); + await writeRemoteFile(realmUrl, 'b.gts', 'export const b = 2;\n'); + + await syncCommand(localDir, realmUrl, { profileManager }); + + // a.gts should be pushed (local change) + expect(await fetchRemoteFile(realmUrl, 'a.gts')).toContain('a = 2'); + // b.gts should be pulled (remote change) + expect(readLocalFile(localDir, 'b.gts')).toContain('b = 2'); + }); + + it('resolves conflict with --prefer-local: local version wins', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'conflict.gts': 'export const v = 1;\n', + }); + + // Modify both sides + writeLocalFile(localDir, 'conflict.gts', 'export const v = "local";\n'); + await writeRemoteFile( + realmUrl, + 'conflict.gts', + 'export const v = "remote";\n', + ); + + await syncCommand(localDir, realmUrl, { + preferLocal: true, + profileManager, + }); + + // Local version should win + expect(await fetchRemoteFile(realmUrl, 'conflict.gts')).toContain( + 'v = "local"', + ); + expect(readLocalFile(localDir, 'conflict.gts')).toContain('v = "local"'); + }); + + it('resolves conflict with --prefer-remote: remote version wins', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'conflict.gts': 'export const v = 1;\n', + }); + + // Modify both sides + writeLocalFile(localDir, 'conflict.gts', 'export const v = "local";\n'); + await writeRemoteFile( + realmUrl, + 'conflict.gts', + 'export const v = "remote";\n', + ); + + await syncCommand(localDir, realmUrl, { + preferRemote: true, + profileManager, + }); + + // Remote version should win + expect(readLocalFile(localDir, 'conflict.gts')).toContain('v = "remote"'); + }); + + it('deletes remote file when local is deleted with --prefer-local', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'to-delete.gts': 'export const d = 1;\n', + 'keep.gts': 'export const k = 1;\n', + }); + + // Delete locally + fs.unlinkSync(path.join(localDir, 'to-delete.gts')); + + await syncCommand(localDir, realmUrl, { + preferLocal: true, + profileManager, + }); + + expect(await remoteFileExists(realmUrl, 'to-delete.gts')).toBe(false); + expect(await remoteFileExists(realmUrl, 'keep.gts')).toBe(true); + }); + + it('deletes local file when remote is deleted with --prefer-remote', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'to-delete.gts': 'export const d = 1;\n', + 'keep.gts': 'export const k = 1;\n', + }); + + // Delete remotely + await deleteRemoteFile(realmUrl, 'to-delete.gts'); + + await syncCommand(localDir, realmUrl, { + preferRemote: true, + profileManager, + }); + + expect(localFileExists(localDir, 'to-delete.gts')).toBe(false); + expect(localFileExists(localDir, 'keep.gts')).toBe(true); + }); + + it('syncs deletions both ways with --delete', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'local-del.gts': 'export const ld = 1;\n', + 'remote-del.gts': 'export const rd = 1;\n', + 'keep.gts': 'export const k = 1;\n', + }); + + // Delete local-del locally, remote-del remotely + fs.unlinkSync(path.join(localDir, 'local-del.gts')); + await deleteRemoteFile(realmUrl, 'remote-del.gts'); + + await syncCommand(localDir, realmUrl, { + delete: true, + profileManager, + }); + + // local-del should be deleted from remote + expect(await remoteFileExists(realmUrl, 'local-del.gts')).toBe(false); + // remote-del should be deleted from local + expect(localFileExists(localDir, 'remote-del.gts')).toBe(false); + // keep should remain + expect(await remoteFileExists(realmUrl, 'keep.gts')).toBe(true); + expect(localFileExists(localDir, 'keep.gts')).toBe(true); + }); + + it('dry-run makes no changes', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalFile(localDir, 'local-only.gts', 'export const lo = 1;\n'); + await writeRemoteFile( + realmUrl, + 'remote-only.gts', + 'export const ro = 1;\n', + ); + + await syncCommand(localDir, realmUrl, { + preferLocal: true, + dryRun: true, + profileManager, + }); + + // Nothing should have changed + expect(await remoteFileExists(realmUrl, 'local-only.gts')).toBe(false); + expect(localFileExists(localDir, 'remote-only.gts')).toBe(false); + expect(manifestExists(localDir)).toBe(false); + }); + + it('manifest is updated correctly after bidirectional sync', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'a.gts': 'export const a = 1;\n', + 'b.gts': 'export const b = 1;\n', + }); + + let oldManifest = readManifest(localDir); + let oldHashA = oldManifest.files['a.gts']; + let oldHashB = oldManifest.files['b.gts']; + + // Modify a locally, b remotely + writeLocalFile(localDir, 'a.gts', 'export const a = 2;\n'); + await writeRemoteFile(realmUrl, 'b.gts', 'export const b = 2;\n'); + + await syncCommand(localDir, realmUrl, { profileManager }); + + let newManifest = readManifest(localDir); + // Both hashes should have changed + expect(newManifest.files['a.gts']).not.toBe(oldHashA); + expect(newManifest.files['b.gts']).not.toBe(oldHashB); + // Both should have valid hashes + expect(newManifest.files['a.gts']).toMatch(/^[0-9a-f]{32}$/); + expect(newManifest.files['b.gts']).toMatch(/^[0-9a-f]{32}$/); + // remoteMtimes should be present + expect(newManifest.remoteMtimes).toBeDefined(); + expect(typeof newManifest.remoteMtimes!['a.gts']).toBe('number'); + expect(typeof newManifest.remoteMtimes!['b.gts']).toBe('number'); + }); + + it('incremental sync is a no-op when nothing changed', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'stable.gts': 'export const s = 1;\n', + }); + + // First sync to pull any realm-default files (e.g. index.json) and stabilize + await syncCommand(localDir, realmUrl, { profileManager }); + + let cm = new CheckpointManager(localDir); + let before = await cm.getCheckpoints(); + + // Sync again with no changes + await syncCommand(localDir, realmUrl, { profileManager }); + + let after = await cm.getCheckpoints(); + // No new checkpoint should be created + expect(after.length).toBe(before.length); + }); + + it('protected files (.realm.json) are never synced', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalFile(localDir, '.realm.json', '{"name":"hacked"}\n'); + writeLocalFile(localDir, 'normal.gts', 'export const n = 1;\n'); + + await syncCommand(localDir, realmUrl, { + preferLocal: true, + profileManager, + }); + + // .realm.json should not appear in manifest + let manifest = readManifest(localDir); + expect(manifest.files['.realm.json']).toBeUndefined(); + }); + + it('creates checkpoint after sync with changes', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + writeLocalFile(localDir, 'new-file.gts', 'export const nf = 1;\n'); + + await syncCommand(localDir, realmUrl, { + preferLocal: true, + profileManager, + }); + + let cm = new CheckpointManager(localDir); + let checkpoints = await cm.getCheckpoints(); + expect(checkpoints.length).toBeGreaterThanOrEqual(1); + }); + + it('first sync with overlapping files resolves with --prefer-local', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + // Write same-named file to both sides without a prior sync + writeLocalFile(localDir, 'overlap.gts', 'export const v = "local";\n'); + await writeRemoteFile( + realmUrl, + 'overlap.gts', + 'export const v = "remote";\n', + ); + + await syncCommand(localDir, realmUrl, { + preferLocal: true, + profileManager, + }); + + // Local should win + expect(await fetchRemoteFile(realmUrl, 'overlap.gts')).toContain( + 'v = "local"', + ); + }); + + it('delete-vs-change conflict with --prefer-local deletes remote', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'dvc.gts': 'export const dvc = 1;\n', + }); + + // Delete locally, modify remotely + fs.unlinkSync(path.join(localDir, 'dvc.gts')); + await writeRemoteFile(realmUrl, 'dvc.gts', 'export const dvc = 2;\n'); + + await syncCommand(localDir, realmUrl, { + preferLocal: true, + profileManager, + }); + + // Local delete wins - remote should be gone + expect(await remoteFileExists(realmUrl, 'dvc.gts')).toBe(false); + }); + + it('change-vs-delete conflict with --prefer-remote deletes local', async () => { + let realmUrl = await createTestRealm(); + let localDir = makeLocalDir(); + + await establishBaseline(localDir, realmUrl, { + 'cvd.gts': 'export const cvd = 1;\n', + }); + + // Modify locally, delete remotely + writeLocalFile(localDir, 'cvd.gts', 'export const cvd = 2;\n'); + await deleteRemoteFile(realmUrl, 'cvd.gts'); + + await syncCommand(localDir, realmUrl, { + preferRemote: true, + profileManager, + }); + + // Remote delete wins - local should be gone + expect(localFileExists(localDir, 'cvd.gts')).toBe(false); + }); +}); diff --git a/packages/boxel-cli/tests/lib/sync-logic.test.ts b/packages/boxel-cli/tests/lib/sync-logic.test.ts new file mode 100644 index 0000000000..e6181a529a --- /dev/null +++ b/packages/boxel-cli/tests/lib/sync-logic.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect } from 'vitest'; +import { + classifyLocal, + classifyRemote, + determineAction, + resolveConflict, + type FileClassification, + type SyncOptions, +} from '../../src/lib/sync-logic'; +import type { SyncManifest } from '../../src/lib/sync-manifest'; + +function makeManifest( + files: Record = {}, + remoteMtimes?: Record, +): SyncManifest { + return { + realmUrl: 'http://test-realm/', + files, + remoteMtimes, + }; +} + +describe('classifyLocal', () => { + it('returns unchanged when hash matches manifest', () => { + const hashes = new Map([['a.json', 'abc123']]); + const manifest = makeManifest({ 'a.json': 'abc123' }); + expect(classifyLocal('a.json', hashes, manifest)).toBe('unchanged'); + }); + + it('returns changed when hash differs from manifest', () => { + const hashes = new Map([['a.json', 'new-hash']]); + const manifest = makeManifest({ 'a.json': 'old-hash' }); + expect(classifyLocal('a.json', hashes, manifest)).toBe('changed'); + }); + + it('returns added when not in manifest', () => { + const hashes = new Map([['new.json', 'abc123']]); + expect(classifyLocal('new.json', hashes, null)).toBe('added'); + expect(classifyLocal('new.json', hashes, makeManifest({}))).toBe('added'); + }); + + it('returns deleted when in manifest but not local', () => { + const hashes = new Map(); + const manifest = makeManifest({ 'gone.json': 'abc123' }); + expect(classifyLocal('gone.json', hashes, manifest)).toBe('deleted'); + }); + + it('returns unchanged for remote-only file (not local, not in manifest)', () => { + const hashes = new Map(); + expect(classifyLocal('remote-only.json', hashes, null)).toBe('unchanged'); + }); +}); + +describe('classifyRemote', () => { + it('returns unchanged when mtime matches manifest.remoteMtimes', () => { + const mtimes = new Map([['a.json', 1000]]); + const manifest = makeManifest({ 'a.json': 'hash' }, { 'a.json': 1000 }); + expect(classifyRemote('a.json', mtimes, manifest)).toBe('unchanged'); + }); + + it('returns changed when mtime differs from manifest.remoteMtimes', () => { + const mtimes = new Map([['a.json', 2000]]); + const manifest = makeManifest({ 'a.json': 'hash' }, { 'a.json': 1000 }); + expect(classifyRemote('a.json', mtimes, manifest)).toBe('changed'); + }); + + it('returns added when not in manifest at all', () => { + const mtimes = new Map([['new.json', 1000]]); + expect(classifyRemote('new.json', mtimes, null)).toBe('added'); + expect(classifyRemote('new.json', mtimes, makeManifest({}))).toBe('added'); + }); + + it('returns deleted when in manifest but not remote', () => { + const mtimes = new Map(); + const manifest = makeManifest( + { 'gone.json': 'hash' }, + { 'gone.json': 1000 }, + ); + expect(classifyRemote('gone.json', mtimes, manifest)).toBe('deleted'); + }); + + it('returns changed when known in manifest.files but no remoteMtimes entry', () => { + const mtimes = new Map([['a.json', 1000]]); + // Manifest has the file in files but no remoteMtimes (e.g., created when _mtimes was unavailable) + const manifest = makeManifest({ 'a.json': 'hash' }); + expect(classifyRemote('a.json', mtimes, manifest)).toBe('changed'); + }); + + it('returns deleted when file in manifest.files only and not on remote', () => { + const mtimes = new Map(); + const manifest = makeManifest({ 'gone.json': 'hash' }); + expect(classifyRemote('gone.json', mtimes, manifest)).toBe('deleted'); + }); + + it('returns unchanged for local-only file (not remote, not in manifest)', () => { + const mtimes = new Map(); + expect(classifyRemote('local-only.json', mtimes, null)).toBe('unchanged'); + }); +}); + +describe('determineAction', () => { + const noFlags: SyncOptions = {}; + const withDelete: SyncOptions = { deleteSync: true }; + const withPreferLocal: SyncOptions = { preferLocal: true }; + const withPreferRemote: SyncOptions = { preferRemote: true }; + + it('returns noop when both unchanged', () => { + expect(determineAction('unchanged', 'unchanged', noFlags)).toBe('noop'); + }); + + it('returns push when local changed, remote unchanged', () => { + expect(determineAction('changed', 'unchanged', noFlags)).toBe('push'); + }); + + it('returns pull when local unchanged, remote changed', () => { + expect(determineAction('unchanged', 'changed', noFlags)).toBe('pull'); + }); + + it('returns push when local added, remote unchanged', () => { + expect(determineAction('added', 'unchanged', noFlags)).toBe('push'); + }); + + it('returns pull when local unchanged, remote added', () => { + expect(determineAction('unchanged', 'added', noFlags)).toBe('pull'); + }); + + it('returns conflict when both changed', () => { + expect(determineAction('changed', 'changed', noFlags)).toBe('conflict'); + }); + + it('returns conflict when both added', () => { + expect(determineAction('added', 'added', noFlags)).toBe('conflict'); + }); + + it('returns conflict for cross-state: changed+added', () => { + expect(determineAction('changed', 'added', noFlags)).toBe('conflict'); + }); + + it('returns conflict for cross-state: added+changed', () => { + expect(determineAction('added', 'changed', noFlags)).toBe('conflict'); + }); + + describe('deletions', () => { + it('returns noop for local deleted without flags', () => { + expect(determineAction('deleted', 'unchanged', noFlags)).toBe('noop'); + }); + + it('returns push-delete for local deleted with --delete', () => { + expect(determineAction('deleted', 'unchanged', withDelete)).toBe( + 'push-delete', + ); + }); + + it('returns push-delete for local deleted with --prefer-local', () => { + expect(determineAction('deleted', 'unchanged', withPreferLocal)).toBe( + 'push-delete', + ); + }); + + it('returns noop for remote deleted without flags', () => { + expect(determineAction('unchanged', 'deleted', noFlags)).toBe('noop'); + }); + + it('returns pull-delete for remote deleted with --delete', () => { + expect(determineAction('unchanged', 'deleted', withDelete)).toBe( + 'pull-delete', + ); + }); + + it('returns pull-delete for remote deleted with --prefer-remote', () => { + expect(determineAction('unchanged', 'deleted', withPreferRemote)).toBe( + 'pull-delete', + ); + }); + }); + + describe('delete-vs-change conflicts', () => { + it('returns conflict for local deleted, remote changed', () => { + expect(determineAction('deleted', 'changed', noFlags)).toBe('conflict'); + }); + + it('returns conflict for local changed, remote deleted', () => { + expect(determineAction('changed', 'deleted', noFlags)).toBe('conflict'); + }); + }); + + it('returns noop when both deleted', () => { + expect(determineAction('deleted', 'deleted', noFlags)).toBe('noop'); + }); + + it('returns push for local added, remote deleted', () => { + expect(determineAction('added', 'deleted', noFlags)).toBe('push'); + }); + + it('returns pull for local deleted, remote added', () => { + expect(determineAction('deleted', 'added', noFlags)).toBe('pull'); + }); +}); + +describe('resolveConflict', () => { + function makeClassification( + local: string, + remote: string, + path = 'test.json', + ): FileClassification { + return { + relativePath: path, + localStatus: local as any, + remoteStatus: remote as any, + action: 'conflict', + }; + } + + const emptyMtimes = new Map(); + const emptyLocalMtimes = new Map(); + + it('returns null when no strategy', () => { + const c = makeClassification('changed', 'changed'); + expect(resolveConflict(c, emptyLocalMtimes, emptyMtimes, null)).toBe(null); + }); + + describe('prefer-local', () => { + it('returns push for changed files', () => { + const c = makeClassification('changed', 'changed'); + expect( + resolveConflict(c, emptyLocalMtimes, emptyMtimes, 'prefer-local'), + ).toBe('push'); + }); + + it('returns push-delete when local is deleted', () => { + const c = makeClassification('deleted', 'changed'); + expect( + resolveConflict(c, emptyLocalMtimes, emptyMtimes, 'prefer-local'), + ).toBe('push-delete'); + }); + }); + + describe('prefer-remote', () => { + it('returns pull for changed files', () => { + const c = makeClassification('changed', 'changed'); + expect( + resolveConflict(c, emptyLocalMtimes, emptyMtimes, 'prefer-remote'), + ).toBe('pull'); + }); + + it('returns pull-delete when remote is deleted', () => { + const c = makeClassification('changed', 'deleted'); + expect( + resolveConflict(c, emptyLocalMtimes, emptyMtimes, 'prefer-remote'), + ).toBe('pull-delete'); + }); + }); + + describe('prefer-newest', () => { + it('pulls when local deleted and remote changed (change wins)', () => { + const c = makeClassification('deleted', 'changed'); + expect( + resolveConflict(c, emptyLocalMtimes, emptyMtimes, 'prefer-newest'), + ).toBe('pull'); + }); + + it('pushes when local changed and remote deleted (change wins)', () => { + const c = makeClassification('changed', 'deleted'); + expect( + resolveConflict(c, emptyLocalMtimes, emptyMtimes, 'prefer-newest'), + ).toBe('push'); + }); + + it('pushes when local is newer', () => { + const c = makeClassification('changed', 'changed'); + const localMtimes = new Map([ + ['test.json', { path: '/tmp/test.json', mtime: 2000000 }], // 2000 seconds in ms + ]); + const remoteMtimes = new Map([['test.json', 1000]]); // 1000 seconds + expect( + resolveConflict(c, localMtimes, remoteMtimes, 'prefer-newest'), + ).toBe('push'); + }); + + it('pulls when remote is newer', () => { + const c = makeClassification('changed', 'changed'); + const localMtimes = new Map([ + ['test.json', { path: '/tmp/test.json', mtime: 500000 }], // 500 seconds in ms + ]); + const remoteMtimes = new Map([['test.json', 1000]]); // 1000 seconds + expect( + resolveConflict(c, localMtimes, remoteMtimes, 'prefer-newest'), + ).toBe('pull'); + }); + + it('falls back to push when mtime data is missing', () => { + const c = makeClassification('changed', 'changed'); + expect( + resolveConflict(c, emptyLocalMtimes, emptyMtimes, 'prefer-newest'), + ).toBe('push'); + }); + }); +});