From bffa4236e7dd5892533284d2d063765156f37cf7 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Mon, 9 Feb 2026 22:03:49 -0800 Subject: [PATCH] detect renames --- src/cli/commands/sync.ts | 108 +++++++++++++++++++++++++++++++++------ src/server/api/sync.ts | 27 +++++++++- 2 files changed, 119 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index cd5fa99..6e9c2da 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -16,10 +16,16 @@ function notAppError(): string { return 'Not a toes app' } +interface Rename { + from: string + to: string +} + interface ManifestDiff { changed: string[] localOnly: string[] remoteOnly: string[] + renamed: Rename[] localManifest: Manifest remoteManifest: Manifest | null } @@ -34,6 +40,7 @@ export async function historyApp(name?: string) { added: string[] modified: string[] deleted: string[] + renamed: string[] } type HistoryResponse = { history: HistoryEntry[] } @@ -54,11 +61,14 @@ export async function historyApp(name?: string) { const suffix = entry.current ? ` ${color.green('(current)')}` : '' console.log(`${label} ${color.gray(date)}${suffix}`) - const hasChanges = entry.added.length > 0 || entry.modified.length > 0 || entry.deleted.length > 0 + const hasChanges = entry.added.length > 0 || entry.modified.length > 0 || entry.deleted.length > 0 || entry.renamed.length > 0 if (!hasChanges) { console.log(color.gray(' No changes')) } + for (const rename of entry.renamed) { + console.log(` ${color.cyan('→')} ${rename}`) + } for (const file of entry.added) { console.log(` ${color.green('+')} ${file}`) } @@ -155,6 +165,26 @@ export async function pushApp(options: { quiet?: boolean } = {}) { } } + // Detect renames among upload/delete pairs (same hash, different path) + const renames: Rename[] = [] + const remoteByHash = new Map() + for (const file of toDelete) { + const hash = result.manifest!.files[file]!.hash + remoteByHash.set(hash, file) + } + + const renamedUploads = new Set() + const renamedDeletes = new Set() + for (const file of toUpload) { + const hash = localManifest.files[file]!.hash + const remoteFile = remoteByHash.get(hash) + if (remoteFile && !renamedDeletes.has(remoteFile)) { + renames.push({ from: remoteFile, to: file }) + renamedUploads.add(file) + renamedDeletes.add(remoteFile) + } + } + if (toUpload.length === 0 && toDelete.length === 0) { if (!options.quiet) console.log('Already up to date') return @@ -174,11 +204,28 @@ export async function pushApp(options: { quiet?: boolean } = {}) { console.log(`Deploying version ${color.bold(version)}...`) // 2. Upload changed files to new version - if (toUpload.length > 0) { - console.log(`Uploading ${toUpload.length} files...`) + const actualUploads = toUpload.filter(f => !renamedUploads.has(f)) + const actualDeletes = toDelete.filter(f => !renamedDeletes.has(f)) + + if (renames.length > 0) { + console.log(`Renaming ${renames.length} files...`) + for (const { from, to } of renames) { + const content = readFileSync(join(process.cwd(), to)) + const uploadOk = await put(`/api/sync/apps/${appName}/files/${to}?version=${version}`, content) + const deleteOk = await del(`/api/sync/apps/${appName}/files/${from}?version=${version}`) + if (uploadOk && deleteOk) { + console.log(` ${color.cyan('→')} ${from} → ${to}`) + } else { + console.log(` ${color.red('✗')} ${from} → ${to} (failed)`) + } + } + } + + if (actualUploads.length > 0) { + console.log(`Uploading ${actualUploads.length} files...`) let failedUploads = 0 - for (const file of toUpload) { + for (const file of actualUploads) { const content = readFileSync(join(process.cwd(), file)) const success = await put(`/api/sync/apps/${appName}/files/${file}?version=${version}`, content) if (success) { @@ -197,9 +244,9 @@ export async function pushApp(options: { quiet?: boolean } = {}) { } // 3. Delete files that no longer exist locally - if (toDelete.length > 0) { - console.log(`Deleting ${toDelete.length} files...`) - for (const file of toDelete) { + if (actualDeletes.length > 0) { + console.log(`Deleting ${actualDeletes.length} files...`) + for (const file of actualDeletes) { const success = await del(`/api/sync/apps/${appName}/files/${file}?version=${version}`) if (success) { console.log(` ${color.red('✗')} ${file}`) @@ -308,13 +355,20 @@ export async function diffApp() { return } - const { changed, localOnly, remoteOnly } = diff + const { changed, localOnly, remoteOnly, renamed } = diff - if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) { + if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) { // console.log(color.green('✓ No differences')) return } + // Show renames + for (const { from, to } of renamed) { + console.log(color.cyan('\nRenamed')) + console.log(color.bold(`${from} → ${to}`)) + console.log(color.gray('─'.repeat(60))) + } + // Fetch all changed files in parallel const remoteContents = await Promise.all( changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`)) @@ -395,13 +449,13 @@ export async function statusApp() { return } - const { changed, localOnly, remoteOnly, localManifest, remoteManifest } = diff + const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest } = diff // toPush = changed + localOnly (new or modified locally) const toPush = [...changed, ...localOnly] // Local changes block pull - const hasLocalChanges = toPush.length > 0 + const hasLocalChanges = toPush.length > 0 || renamed.length > 0 // Display status // console.log(`Status for ${color.bold(appName)}:\n`) @@ -414,8 +468,11 @@ export async function statusApp() { } // Push status - if (toPush.length > 0 || remoteOnly.length > 0) { + if (toPush.length > 0 || remoteOnly.length > 0 || renamed.length > 0) { console.log(color.bold('Changes to push:')) + for (const { from, to } of renamed) { + console.log(` ${color.cyan('→')} ${from} → ${to}`) + } for (const file of toPush) { console.log(` ${color.green('↑')} ${file}`) } @@ -435,7 +492,7 @@ export async function statusApp() { } // Summary - if (toPush.length === 0 && remoteOnly.length === 0) { + if (toPush.length === 0 && remoteOnly.length === 0 && renamed.length === 0) { console.log(color.green('✓ In sync with server')) } } @@ -958,10 +1015,31 @@ async function getManifestDiff(appName: string): Promise { } } + // Detect renames: localOnly + remoteOnly files with matching hashes + const renamed: Rename[] = [] + const remoteByHash = new Map() + for (const file of remoteOnly) { + const hash = result.manifest!.files[file]!.hash + remoteByHash.set(hash, file) + } + + const matchedLocal = new Set() + const matchedRemote = new Set() + for (const file of localOnly) { + const hash = localManifest.files[file]!.hash + const remoteFile = remoteByHash.get(hash) + if (remoteFile && !matchedRemote.has(remoteFile)) { + renamed.push({ from: remoteFile, to: file }) + matchedLocal.add(file) + matchedRemote.add(remoteFile) + } + } + return { changed, - localOnly, - remoteOnly, + localOnly: localOnly.filter(f => !matchedLocal.has(f)), + remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)), + renamed, localManifest, remoteManifest: result.manifest ?? null, } diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts index 9b31935..c3fa421 100644 --- a/src/server/api/sync.ts +++ b/src/server/api/sync.ts @@ -106,7 +106,32 @@ router.get('/apps/:app/history', c => { } } - return { version, current, added: added.sort(), modified: modified.sort(), deleted: deleted.sort() } + // Detect renames: added + deleted files with matching hashes + const renamed: string[] = [] + const deletedByHash = new Map() + for (const path of deleted) { + deletedByHash.set(olderFiles[path]!.hash, path) + } + + const matchedAdded = new Set() + const matchedDeleted = new Set() + for (const path of added) { + const oldPath = deletedByHash.get(files[path]!.hash) + if (oldPath && !matchedDeleted.has(oldPath)) { + renamed.push(`${oldPath} → ${path}`) + matchedAdded.add(path) + matchedDeleted.add(oldPath) + } + } + + return { + version, + current, + added: added.filter(f => !matchedAdded.has(f)).sort(), + modified: modified.sort(), + deleted: deleted.filter(f => !matchedDeleted.has(f)).sort(), + renamed: renamed.sort(), + } }) return c.json({ history })