detect renames

This commit is contained in:
Chris Wanstrath 2026-02-09 22:03:49 -08:00
parent 4a2223d3d7
commit bffa4236e7
2 changed files with 119 additions and 16 deletions

View File

@ -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<string, string>()
for (const file of toDelete) {
const hash = result.manifest!.files[file]!.hash
remoteByHash.set(hash, file)
}
const renamedUploads = new Set<string>()
const renamedDeletes = new Set<string>()
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<ManifestDiff | null> {
}
}
// Detect renames: localOnly + remoteOnly files with matching hashes
const renamed: Rename[] = []
const remoteByHash = new Map<string, string>()
for (const file of remoteOnly) {
const hash = result.manifest!.files[file]!.hash
remoteByHash.set(hash, file)
}
const matchedLocal = new Set<string>()
const matchedRemote = new Set<string>()
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,
}

View File

@ -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<string, string>()
for (const path of deleted) {
deletedByHash.set(olderFiles[path]!.hash, path)
}
const matchedAdded = new Set<string>()
const matchedDeleted = new Set<string>()
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 })