diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index a56981b..0e46a9b 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -22,18 +22,14 @@ interface Rename { } interface ManifestDiff { - localChanged: string[] - remoteChanged: string[] + changed: string[] localOnly: string[] remoteOnly: string[] - localDeleted: string[] - remoteDeleted: string[] - conflicts: string[] renamed: Rename[] localManifest: Manifest remoteManifest: Manifest | null - baseManifest: Manifest | null remoteVersion: string | null + serverChanged: boolean } export async function historyApp(name?: string) { @@ -127,9 +123,8 @@ export async function getApp(name: string) { writeFileSync(fullPath, content) } - // Write sync state so future status/push/pull has a baseline if (result.version) { - writeSyncState(appPath, { version: result.version, manifest: result.manifest }) + writeSyncState(appPath, { version: result.version }) } console.log(color.green(`✓ Downloaded ${name}`)) @@ -148,45 +143,31 @@ export async function pushApp(options: { quiet?: boolean, force?: boolean } = {} return } - const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed, localManifest, remoteManifest } = diff + const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff if (!remoteManifest) { const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) if (!ok) return } - // Abort if there are unpulled remote changes or conflicts (unless --force) - const hasRemoteChanges = remoteChanged.length > 0 || remoteOnly.length > 0 || remoteDeleted.length > 0 - if (!options.force && (hasRemoteChanges || conflicts.length > 0)) { - console.error('Cannot push: server has changes you haven\'t pulled') - for (const file of remoteChanged) { - console.error(` ${color.yellow('~')} ${file} (changed on server)`) - } - for (const file of remoteOnly) { - console.error(` ${color.green('+')} ${file} (new on server)`) - } - for (const file of remoteDeleted) { - console.error(` ${color.red('-')} ${file} (deleted on server)`) - } - for (const file of conflicts) { - console.error(` ${color.red('!')} ${file} (conflict)`) - } + // If server changed, abort unless --force + if (serverChanged && !options.force) { + console.error('Cannot push: server has changed since last sync') console.error('\nRun `toes pull` first, or `toes push --force` to overwrite') return } - // Files to upload: locally changed/added + conflicts (when forcing) - const toUpload = [...localChanged, ...localOnly, ...(options.force ? conflicts : [])] - // Files to delete on server: locally deleted + remote-only when forcing - const toDelete = [...localDeleted, ...(options.force ? remoteOnly : [])] + // Files to upload: changed + localOnly + const toUpload = [...changed, ...localOnly] + // Files to delete on server: remoteOnly (local deletions when version matches, or forced) + const toDelete = !serverChanged || options.force ? [...remoteOnly] : [] // Detect renames among upload/delete pairs (same hash, different path) const renames: Rename[] = [...renamed] - const serverManifest = remoteManifest const remoteByHash = new Map() - if (serverManifest) { + if (remoteManifest) { for (const file of toDelete) { - const info = serverManifest.files[file] + const info = remoteManifest.files[file] if (info) remoteByHash.set(info.hash, file) } } @@ -283,9 +264,8 @@ export async function pushApp(options: { quiet?: boolean, force?: boolean } = {} return } - // 5. Write sync state after successful push - const newManifest = generateManifest(process.cwd(), appName) - writeSyncState(process.cwd(), { version, manifest: newManifest }) + // 5. Write sync version after successful push + writeSyncState(process.cwd(), { version }) console.log(color.green(`✓ Deployed and activated version ${version}`)) } @@ -303,45 +283,49 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {} return } - const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, remoteManifest, remoteVersion } = diff + const { changed, localOnly, remoteOnly, remoteManifest, remoteVersion, serverChanged } = diff if (!remoteManifest) { console.error('App not found on server') return } - // Check for local changes that would be overwritten - const hasLocalChanges = localChanged.length > 0 || localOnly.length > 0 || localDeleted.length > 0 || conflicts.length > 0 - if (hasLocalChanges && !options.force) { + if (!serverChanged) { + // Server hasn't changed — all diffs are local, nothing to pull + if (!options.quiet) { + if (changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0) { + console.log('Server is up to date. You have local changes — use `toes push`.') + } else { + console.log('Already up to date') + } + } + return + } + + // Server changed — download diffs from remote + const hasDiffs = changed.length > 0 || localOnly.length > 0 + if (hasDiffs && !options.force) { console.error('Cannot pull: you have local changes that would be overwritten') - for (const file of localChanged) { + for (const file of changed) { console.error(` ${color.yellow('~')} ${file}`) } for (const file of localOnly) { console.error(` ${color.green('+')} ${file} (local only)`) } - for (const file of localDeleted) { - console.error(` ${color.red('-')} ${file} (deleted locally)`) - } - for (const file of conflicts) { - console.error(` ${color.red('!')} ${file} (conflict)`) - } console.error('\nUse `toes pull --force` to overwrite local changes') return } - // Files to download: remote changed + remote only + conflicts (when forcing) - const toDownload = [...remoteChanged, ...remoteOnly, ...(options.force ? [...localChanged, ...conflicts] : [])] - - // Files to delete locally: remote deleted + local only (when forcing) - const toDelete = [...remoteDeleted, ...(options.force ? localOnly : [])] - - // Restore locally deleted files from remote - if (options.force && localDeleted.length > 0) { - toDownload.push(...localDeleted) - } + // Files to download: changed + remoteOnly + const toDownload = [...changed, ...remoteOnly] + // Files to delete locally: only when forcing + const toDelete = options.force ? localOnly : [] if (toDownload.length === 0 && toDelete.length === 0) { + // Server version changed but files are identical — just update stored version + if (remoteVersion) { + writeSyncState(process.cwd(), { version: remoteVersion }) + } if (!options.quiet) console.log('Already up to date') return } @@ -380,10 +364,8 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {} } } - // Write sync state after successful pull if (remoteVersion) { - const newManifest = generateManifest(process.cwd(), appName) - writeSyncState(process.cwd(), { version: remoteVersion, manifest: newManifest }) + writeSyncState(process.cwd(), { version: remoteVersion }) } console.log(color.green('✓ Pull complete')) @@ -402,14 +384,9 @@ export async function diffApp() { return } - const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed } = diff + const { changed, localOnly, remoteOnly, renamed } = diff - const hasChanges = localChanged.length > 0 || remoteChanged.length > 0 || - localOnly.length > 0 || remoteOnly.length > 0 || - localDeleted.length > 0 || remoteDeleted.length > 0 || - conflicts.length > 0 || renamed.length > 0 - - if (!hasChanges) { + if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) { return } @@ -420,73 +397,27 @@ export async function diffApp() { console.log(color.gray('─'.repeat(60))) } - // Fetch remote content for changed/conflict files - const changedFiles = [...localChanged, ...remoteChanged, ...conflicts] + // Fetch all changed files in parallel const remoteContents = await Promise.all( - changedFiles.map(file => download(`/api/sync/apps/${appName}/files/${file}`)) + changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`)) ) - // Show diffs for locally changed files - if (localChanged.length > 0) { - for (let i = 0; i < localChanged.length; i++) { - const file = localChanged[i]! - const remoteContent = remoteContents[i] - const localContent = readFileSync(join(process.cwd(), file), 'utf-8') + // Show diffs for changed files + for (let i = 0; i < changed.length; i++) { + const file = changed[i]! + const remoteContent = remoteContents[i] + const localContent = readFileSync(join(process.cwd(), file), 'utf-8') - if (!remoteContent) { - console.log(color.red(`Failed to fetch remote version of ${file}`)) - continue - } - - const remoteText = new TextDecoder().decode(remoteContent) - - console.log(color.green('\nLocal change')) - console.log(color.bold(`${file}`)) - console.log(color.gray('─'.repeat(60))) - showDiff(remoteText, localContent) + if (!remoteContent) { + console.log(color.red(`Failed to fetch remote version of ${file}`)) + continue } - } - // Show diffs for remotely changed files - if (remoteChanged.length > 0) { - for (let i = 0; i < remoteChanged.length; i++) { - const file = remoteChanged[i]! - const remoteContent = remoteContents[localChanged.length + i] - const localContent = readFileSync(join(process.cwd(), file), 'utf-8') + const remoteText = new TextDecoder().decode(remoteContent) - if (!remoteContent) { - console.log(color.red(`Failed to fetch remote version of ${file}`)) - continue - } - - const remoteText = new TextDecoder().decode(remoteContent) - - console.log(color.yellow('\nRemote change')) - console.log(color.bold(`${file}`)) - console.log(color.gray('─'.repeat(60))) - showDiff(localContent, remoteText) - } - } - - // Show diffs for conflicts - if (conflicts.length > 0) { - for (let i = 0; i < conflicts.length; i++) { - const file = conflicts[i]! - const remoteContent = remoteContents[localChanged.length + remoteChanged.length + i] - const localContent = readFileSync(join(process.cwd(), file), 'utf-8') - - if (!remoteContent) { - console.log(color.red(`Failed to fetch remote version of ${file}`)) - continue - } - - const remoteText = new TextDecoder().decode(remoteContent) - - console.log(color.red('\nConflict (changed on both sides)')) - console.log(color.bold(`${file}`)) - console.log(color.gray('─'.repeat(60))) - showDiff(remoteText, localContent) - } + console.log(color.bold(`\n${file}`)) + console.log(color.gray('─'.repeat(60))) + showDiff(remoteText, localContent) } // Show local-only files @@ -514,14 +445,14 @@ export async function diffApp() { const file = remoteOnly[i]! const content = remoteOnlyContents[i] - console.log(color.yellow('\nNew file (remote only)')) - console.log(color.bold(`${file}`)) + console.log(color.bold(`\n${file}`)) console.log(color.gray('─'.repeat(60))) + console.log(color.red('Remote only')) if (content) { const text = new TextDecoder().decode(content) const lines = text.split('\n') for (let i = 0; i < Math.min(lines.length, 10); i++) { - console.log(color.green(`+ ${lines[i]}`)) + console.log(color.red(`- ${lines[i]}`)) } if (lines.length > 10) { console.log(color.gray(`... ${lines.length - 10} more lines`)) @@ -529,20 +460,6 @@ export async function diffApp() { } } - // Show locally deleted files - for (const file of localDeleted) { - console.log(color.red('\nDeleted locally')) - console.log(color.bold(`${file}`)) - console.log(color.gray('─'.repeat(60))) - } - - // Show remotely deleted files - for (const file of remoteDeleted) { - console.log(color.red('\nDeleted on server')) - console.log(color.bold(`${file}`)) - console.log(color.gray('─'.repeat(60))) - } - console.log() } @@ -560,7 +477,7 @@ export async function statusApp() { return } - const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed, localManifest, remoteManifest, baseManifest } = diff + const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff if (!remoteManifest) { console.log(color.yellow('App does not exist on server')) @@ -569,58 +486,55 @@ export async function statusApp() { return } - const toPush = [...localChanged, ...localOnly, ...localDeleted] - const toPull = [...remoteChanged, ...remoteOnly, ...remoteDeleted] + const hasDiffs = changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0 || renamed.length > 0 - // Push status - if (toPush.length > 0 || renamed.length > 0) { + if (!hasDiffs) { + if (serverChanged) { + // Files identical but version changed — update stored version silently + const { remoteVersion } = diff + if (remoteVersion) { + writeSyncState(process.cwd(), { version: remoteVersion }) + } + } + console.log(color.green('✓ In sync with server')) + return + } + + if (!serverChanged) { + // Server hasn't moved — all diffs are local changes to push console.log(color.bold('Changes to push:')) for (const { from, to } of renamed) { console.log(` ${color.cyan('→')} ${from} → ${to}`) } - for (const file of localChanged) { + for (const file of changed) { console.log(` ${color.green('↑')} ${file}`) } for (const file of localOnly) { console.log(` ${color.green('+')} ${file}`) } - for (const file of localDeleted) { + for (const file of remoteOnly) { console.log(` ${color.red('-')} ${file}`) } console.log() - } - - // Pull status - if (toPull.length > 0) { - console.log(color.bold('Changes to pull:')) - for (const file of remoteChanged) { - console.log(` ${color.green('↓')} ${file}`) + } else { + // Server changed — show diffs neutrally + console.log(color.yellow('Server has changed since last sync\n')) + console.log(color.bold('Differences:')) + for (const { from, to } of renamed) { + console.log(` ${color.cyan('→')} ${from} → ${to}`) + } + for (const file of changed) { + console.log(` ${color.yellow('~')} ${file}`) + } + for (const file of localOnly) { + console.log(` ${color.green('+')} ${file} (local only)`) } for (const file of remoteOnly) { - console.log(` ${color.green('+')} ${file}`) - } - for (const file of remoteDeleted) { - console.log(` ${color.red('-')} ${file}`) + console.log(` ${color.green('+')} ${file} (remote only)`) } + console.log(`\nRun ${color.bold('toes pull')} to update, or ${color.bold('toes push --force')} to overwrite server`) console.log() } - - // Conflicts - if (conflicts.length > 0) { - console.log(color.bold(color.red('Conflicts:'))) - for (const file of conflicts) { - console.log(` ${color.red('!')} ${file}`) - } - if (!baseManifest) { - console.log(color.gray('\n No sync baseline. Re-download with `toes get` to establish one.')) - } - console.log() - } - - // Summary - if (toPush.length === 0 && toPull.length === 0 && conflicts.length === 0 && renamed.length === 0) { - console.log(color.green('✓ In sync with server')) - } } export async function syncApp() { @@ -641,7 +555,7 @@ export async function syncApp() { console.log(`Syncing ${color.bold(appName)}...`) - // Initial sync: merge using three-way diff, then push merged state + // Initial sync: pull remote changes, then push local await mergeSync(appName) const gitignore = loadGitignore(process.cwd()) @@ -826,8 +740,8 @@ export async function stashApp() { return } - const { localChanged, localOnly } = diff - const toStash = [...localChanged, ...localOnly] + const { changed, localOnly } = diff + const toStash = [...changed, ...localOnly] if (toStash.length === 0) { console.log('No local changes to stash') @@ -849,7 +763,7 @@ export async function stashApp() { app: appName, timestamp: new Date().toISOString(), files: toStash, - changed: localChanged, + changed, localOnly, } writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2)) @@ -870,9 +784,9 @@ export async function stashApp() { } // Restore changed files from server - if (localChanged.length > 0) { - console.log(`\nRestoring ${localChanged.length} changed files from server...`) - for (const file of localChanged) { + if (changed.length > 0) { + console.log(`\nRestoring ${changed.length} changed files from server...`) + for (const file of changed) { const content = await download(`/api/sync/apps/${appName}/files/${file}`) if (content) { writeFileSync(join(process.cwd(), file), content) @@ -1067,34 +981,27 @@ async function mergeSync(appName: string): Promise { const diff = await getManifestDiff(appName) if (!diff) return - const { remoteChanged, remoteOnly, remoteDeleted, remoteManifest } = diff + const { changed, remoteOnly, remoteManifest, serverChanged } = diff if (!remoteManifest) return - // Pull remote changes - const toPull = [...remoteChanged, ...remoteOnly] + if (serverChanged) { + // Pull remote changes + const toPull = [...changed, ...remoteOnly] - if (toPull.length > 0) { - for (const file of toPull) { - const content = await download(`/api/sync/apps/${appName}/files/${file}`) - if (!content) continue + if (toPull.length > 0) { + for (const file of toPull) { + const content = await download(`/api/sync/apps/${appName}/files/${file}`) + if (!content) continue - const fullPath = join(process.cwd(), file) - const dir = dirname(fullPath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) + const fullPath = join(process.cwd(), file) + const dir = dirname(fullPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + writeFileSync(fullPath, content) + console.log(` ${color.green('↓')} ${file}`) } - - writeFileSync(fullPath, content) - console.log(` ${color.green('↓')} ${file}`) - } - } - - // Delete files that were deleted on remote - for (const file of remoteDeleted) { - const fullPath = join(process.cwd(), file) - if (existsSync(fullPath)) { - unlinkSync(fullPath) - console.log(` ${color.red('✗')} ${file}`) } } @@ -1114,79 +1021,37 @@ async function getManifestDiff(appName: string): Promise { const remoteManifest = result.manifest ?? null const remoteVersion = result.version ?? null const syncState = readSyncState(process.cwd()) - const baseManifest = syncState?.manifest ?? null + const serverChanged = !syncState || syncState.version !== remoteVersion const localFiles = new Set(Object.keys(localManifest.files)) const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {})) - const baseFiles = new Set(Object.keys(baseManifest?.files ?? {})) - // Collect all unique paths - const allPaths = new Set([...localFiles, ...remoteFiles, ...baseFiles]) + // Files that differ + const changed: string[] = [] + for (const file of localFiles) { + if (remoteFiles.has(file)) { + const local = localManifest.files[file]! + const remote = remoteManifest!.files[file]! + if (local.hash !== remote.hash) { + changed.push(file) + } + } + } - const gitignore = loadGitignore(process.cwd()) - - const localChanged: string[] = [] - const remoteChanged: string[] = [] + // Files only in local const localOnly: string[] = [] + for (const file of localFiles) { + if (!remoteFiles.has(file)) { + localOnly.push(file) + } + } + + // Files only in remote (filtered by local gitignore) + const gitignore = loadGitignore(process.cwd()) const remoteOnly: string[] = [] - const localDeleted: string[] = [] - const remoteDeleted: string[] = [] - const conflicts: string[] = [] - - for (const path of allPaths) { - if (gitignore.shouldExclude(path)) continue - - const inLocal = localFiles.has(path) - const inRemote = remoteFiles.has(path) - const inBase = baseFiles.has(path) - - const localHash = inLocal ? localManifest.files[path]!.hash : null - const remoteHash = inRemote ? remoteManifest!.files[path]!.hash : null - const baseHash = inBase ? baseManifest!.files[path]!.hash : null - - if (baseManifest) { - // Three-way diff against baseline - const localChanged_ = localHash !== baseHash - const remoteChanged_ = remoteHash !== baseHash - - if (localHash === remoteHash) { - // Both sides agree - no diff to report - continue - } - - if (!localChanged_ && remoteChanged_) { - // Only remote changed - if (inRemote && !inBase) { - remoteOnly.push(path) - } else if (!inRemote && inBase) { - remoteDeleted.push(path) - } else { - remoteChanged.push(path) - } - } else if (localChanged_ && !remoteChanged_) { - // Only local changed - if (inLocal && !inBase) { - localOnly.push(path) - } else if (!inLocal && inBase) { - localDeleted.push(path) - } else { - localChanged.push(path) - } - } else { - // Both changed to different values = conflict - conflicts.push(path) - } - } else { - // No baseline: fall back to two-way comparison - if (inLocal && inRemote) { - if (localHash !== remoteHash) { - conflicts.push(path) - } - } else if (inLocal && !inRemote) { - localOnly.push(path) - } else if (!inLocal && inRemote) { - remoteOnly.push(path) - } + for (const file of remoteFiles) { + if (!localFiles.has(file) && !gitignore.shouldExclude(file)) { + remoteOnly.push(file) } } @@ -1211,18 +1076,14 @@ async function getManifestDiff(appName: string): Promise { } return { - localChanged, - remoteChanged, + changed, localOnly: localOnly.filter(f => !matchedLocal.has(f)), remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)), - localDeleted, - remoteDeleted, - conflicts, renamed, localManifest, remoteManifest, - baseManifest, remoteVersion, + serverChanged, } } diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 22363d7..5da375b 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -8,7 +8,6 @@ import { join, relative } from 'path' export interface SyncState { version: string - manifest: Manifest } export function readSyncState(appPath: string): SyncState | null {