way simpler
This commit is contained in:
parent
b6e9ec73de
commit
681a3f2f9e
|
|
@ -22,18 +22,14 @@ interface Rename {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ManifestDiff {
|
interface ManifestDiff {
|
||||||
localChanged: string[]
|
changed: string[]
|
||||||
remoteChanged: string[]
|
|
||||||
localOnly: string[]
|
localOnly: string[]
|
||||||
remoteOnly: string[]
|
remoteOnly: string[]
|
||||||
localDeleted: string[]
|
|
||||||
remoteDeleted: string[]
|
|
||||||
conflicts: string[]
|
|
||||||
renamed: Rename[]
|
renamed: Rename[]
|
||||||
localManifest: Manifest
|
localManifest: Manifest
|
||||||
remoteManifest: Manifest | null
|
remoteManifest: Manifest | null
|
||||||
baseManifest: Manifest | null
|
|
||||||
remoteVersion: string | null
|
remoteVersion: string | null
|
||||||
|
serverChanged: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function historyApp(name?: string) {
|
export async function historyApp(name?: string) {
|
||||||
|
|
@ -127,9 +123,8 @@ export async function getApp(name: string) {
|
||||||
writeFileSync(fullPath, content)
|
writeFileSync(fullPath, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write sync state so future status/push/pull has a baseline
|
|
||||||
if (result.version) {
|
if (result.version) {
|
||||||
writeSyncState(appPath, { version: result.version, manifest: result.manifest })
|
writeSyncState(appPath, { version: result.version })
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(color.green(`✓ Downloaded ${name}`))
|
console.log(color.green(`✓ Downloaded ${name}`))
|
||||||
|
|
@ -148,45 +143,31 @@ export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed, localManifest, remoteManifest } = diff
|
const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff
|
||||||
|
|
||||||
if (!remoteManifest) {
|
if (!remoteManifest) {
|
||||||
const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`)
|
const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Abort if there are unpulled remote changes or conflicts (unless --force)
|
// If server changed, abort unless --force
|
||||||
const hasRemoteChanges = remoteChanged.length > 0 || remoteOnly.length > 0 || remoteDeleted.length > 0
|
if (serverChanged && !options.force) {
|
||||||
if (!options.force && (hasRemoteChanges || conflicts.length > 0)) {
|
console.error('Cannot push: server has changed since last sync')
|
||||||
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)`)
|
|
||||||
}
|
|
||||||
console.error('\nRun `toes pull` first, or `toes push --force` to overwrite')
|
console.error('\nRun `toes pull` first, or `toes push --force` to overwrite')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files to upload: locally changed/added + conflicts (when forcing)
|
// Files to upload: changed + localOnly
|
||||||
const toUpload = [...localChanged, ...localOnly, ...(options.force ? conflicts : [])]
|
const toUpload = [...changed, ...localOnly]
|
||||||
// Files to delete on server: locally deleted + remote-only when forcing
|
// Files to delete on server: remoteOnly (local deletions when version matches, or forced)
|
||||||
const toDelete = [...localDeleted, ...(options.force ? remoteOnly : [])]
|
const toDelete = !serverChanged || options.force ? [...remoteOnly] : []
|
||||||
|
|
||||||
// Detect renames among upload/delete pairs (same hash, different path)
|
// Detect renames among upload/delete pairs (same hash, different path)
|
||||||
const renames: Rename[] = [...renamed]
|
const renames: Rename[] = [...renamed]
|
||||||
const serverManifest = remoteManifest
|
|
||||||
const remoteByHash = new Map<string, string>()
|
const remoteByHash = new Map<string, string>()
|
||||||
if (serverManifest) {
|
if (remoteManifest) {
|
||||||
for (const file of toDelete) {
|
for (const file of toDelete) {
|
||||||
const info = serverManifest.files[file]
|
const info = remoteManifest.files[file]
|
||||||
if (info) remoteByHash.set(info.hash, file)
|
if (info) remoteByHash.set(info.hash, file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -283,9 +264,8 @@ export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Write sync state after successful push
|
// 5. Write sync version after successful push
|
||||||
const newManifest = generateManifest(process.cwd(), appName)
|
writeSyncState(process.cwd(), { version })
|
||||||
writeSyncState(process.cwd(), { version, manifest: newManifest })
|
|
||||||
|
|
||||||
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
||||||
}
|
}
|
||||||
|
|
@ -303,45 +283,49 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, remoteManifest, remoteVersion } = diff
|
const { changed, localOnly, remoteOnly, remoteManifest, remoteVersion, serverChanged } = diff
|
||||||
|
|
||||||
if (!remoteManifest) {
|
if (!remoteManifest) {
|
||||||
console.error('App not found on server')
|
console.error('App not found on server')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for local changes that would be overwritten
|
if (!serverChanged) {
|
||||||
const hasLocalChanges = localChanged.length > 0 || localOnly.length > 0 || localDeleted.length > 0 || conflicts.length > 0
|
// Server hasn't changed — all diffs are local, nothing to pull
|
||||||
if (hasLocalChanges && !options.force) {
|
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')
|
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}`)
|
console.error(` ${color.yellow('~')} ${file}`)
|
||||||
}
|
}
|
||||||
for (const file of localOnly) {
|
for (const file of localOnly) {
|
||||||
console.error(` ${color.green('+')} ${file} (local only)`)
|
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')
|
console.error('\nUse `toes pull --force` to overwrite local changes')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files to download: remote changed + remote only + conflicts (when forcing)
|
// Files to download: changed + remoteOnly
|
||||||
const toDownload = [...remoteChanged, ...remoteOnly, ...(options.force ? [...localChanged, ...conflicts] : [])]
|
const toDownload = [...changed, ...remoteOnly]
|
||||||
|
// Files to delete locally: only when forcing
|
||||||
// Files to delete locally: remote deleted + local only (when forcing)
|
const toDelete = options.force ? localOnly : []
|
||||||
const toDelete = [...remoteDeleted, ...(options.force ? localOnly : [])]
|
|
||||||
|
|
||||||
// Restore locally deleted files from remote
|
|
||||||
if (options.force && localDeleted.length > 0) {
|
|
||||||
toDownload.push(...localDeleted)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toDownload.length === 0 && toDelete.length === 0) {
|
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')
|
if (!options.quiet) console.log('Already up to date')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -380,10 +364,8 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write sync state after successful pull
|
|
||||||
if (remoteVersion) {
|
if (remoteVersion) {
|
||||||
const newManifest = generateManifest(process.cwd(), appName)
|
writeSyncState(process.cwd(), { version: remoteVersion })
|
||||||
writeSyncState(process.cwd(), { version: remoteVersion, manifest: newManifest })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(color.green('✓ Pull complete'))
|
console.log(color.green('✓ Pull complete'))
|
||||||
|
|
@ -402,14 +384,9 @@ export async function diffApp() {
|
||||||
return
|
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 ||
|
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) {
|
||||||
localOnly.length > 0 || remoteOnly.length > 0 ||
|
|
||||||
localDeleted.length > 0 || remoteDeleted.length > 0 ||
|
|
||||||
conflicts.length > 0 || renamed.length > 0
|
|
||||||
|
|
||||||
if (!hasChanges) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,73 +397,27 @@ export async function diffApp() {
|
||||||
console.log(color.gray('─'.repeat(60)))
|
console.log(color.gray('─'.repeat(60)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch remote content for changed/conflict files
|
// Fetch all changed files in parallel
|
||||||
const changedFiles = [...localChanged, ...remoteChanged, ...conflicts]
|
|
||||||
const remoteContents = await Promise.all(
|
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
|
// Show diffs for changed files
|
||||||
if (localChanged.length > 0) {
|
for (let i = 0; i < changed.length; i++) {
|
||||||
for (let i = 0; i < localChanged.length; i++) {
|
const file = changed[i]!
|
||||||
const file = localChanged[i]!
|
const remoteContent = remoteContents[i]
|
||||||
const remoteContent = remoteContents[i]
|
const localContent = readFileSync(join(process.cwd(), file), 'utf-8')
|
||||||
const localContent = readFileSync(join(process.cwd(), file), 'utf-8')
|
|
||||||
|
|
||||||
if (!remoteContent) {
|
if (!remoteContent) {
|
||||||
console.log(color.red(`Failed to fetch remote version of ${file}`))
|
console.log(color.red(`Failed to fetch remote version of ${file}`))
|
||||||
continue
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Show diffs for remotely changed files
|
const remoteText = new TextDecoder().decode(remoteContent)
|
||||||
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')
|
|
||||||
|
|
||||||
if (!remoteContent) {
|
console.log(color.bold(`\n${file}`))
|
||||||
console.log(color.red(`Failed to fetch remote version of ${file}`))
|
console.log(color.gray('─'.repeat(60)))
|
||||||
continue
|
showDiff(remoteText, localContent)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show local-only files
|
// Show local-only files
|
||||||
|
|
@ -514,14 +445,14 @@ export async function diffApp() {
|
||||||
const file = remoteOnly[i]!
|
const file = remoteOnly[i]!
|
||||||
const content = remoteOnlyContents[i]
|
const content = remoteOnlyContents[i]
|
||||||
|
|
||||||
console.log(color.yellow('\nNew file (remote only)'))
|
console.log(color.bold(`\n${file}`))
|
||||||
console.log(color.bold(`${file}`))
|
|
||||||
console.log(color.gray('─'.repeat(60)))
|
console.log(color.gray('─'.repeat(60)))
|
||||||
|
console.log(color.red('Remote only'))
|
||||||
if (content) {
|
if (content) {
|
||||||
const text = new TextDecoder().decode(content)
|
const text = new TextDecoder().decode(content)
|
||||||
const lines = text.split('\n')
|
const lines = text.split('\n')
|
||||||
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
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) {
|
if (lines.length > 10) {
|
||||||
console.log(color.gray(`... ${lines.length - 10} more lines`))
|
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()
|
console.log()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,7 +477,7 @@ export async function statusApp() {
|
||||||
return
|
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) {
|
if (!remoteManifest) {
|
||||||
console.log(color.yellow('App does not exist on server'))
|
console.log(color.yellow('App does not exist on server'))
|
||||||
|
|
@ -569,58 +486,55 @@ export async function statusApp() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const toPush = [...localChanged, ...localOnly, ...localDeleted]
|
const hasDiffs = changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0 || renamed.length > 0
|
||||||
const toPull = [...remoteChanged, ...remoteOnly, ...remoteDeleted]
|
|
||||||
|
|
||||||
// Push status
|
if (!hasDiffs) {
|
||||||
if (toPush.length > 0 || renamed.length > 0) {
|
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:'))
|
console.log(color.bold('Changes to push:'))
|
||||||
for (const { from, to } of renamed) {
|
for (const { from, to } of renamed) {
|
||||||
console.log(` ${color.cyan('→')} ${from} → ${to}`)
|
console.log(` ${color.cyan('→')} ${from} → ${to}`)
|
||||||
}
|
}
|
||||||
for (const file of localChanged) {
|
for (const file of changed) {
|
||||||
console.log(` ${color.green('↑')} ${file}`)
|
console.log(` ${color.green('↑')} ${file}`)
|
||||||
}
|
}
|
||||||
for (const file of localOnly) {
|
for (const file of localOnly) {
|
||||||
console.log(` ${color.green('+')} ${file}`)
|
console.log(` ${color.green('+')} ${file}`)
|
||||||
}
|
}
|
||||||
for (const file of localDeleted) {
|
for (const file of remoteOnly) {
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
console.log(` ${color.red('-')} ${file}`)
|
||||||
}
|
}
|
||||||
console.log()
|
console.log()
|
||||||
}
|
} else {
|
||||||
|
// Server changed — show diffs neutrally
|
||||||
// Pull status
|
console.log(color.yellow('Server has changed since last sync\n'))
|
||||||
if (toPull.length > 0) {
|
console.log(color.bold('Differences:'))
|
||||||
console.log(color.bold('Changes to pull:'))
|
for (const { from, to } of renamed) {
|
||||||
for (const file of remoteChanged) {
|
console.log(` ${color.cyan('→')} ${from} → ${to}`)
|
||||||
console.log(` ${color.green('↓')} ${file}`)
|
}
|
||||||
|
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) {
|
for (const file of remoteOnly) {
|
||||||
console.log(` ${color.green('+')} ${file}`)
|
console.log(` ${color.green('+')} ${file} (remote only)`)
|
||||||
}
|
|
||||||
for (const file of remoteDeleted) {
|
|
||||||
console.log(` ${color.red('-')} ${file}`)
|
|
||||||
}
|
}
|
||||||
|
console.log(`\nRun ${color.bold('toes pull')} to update, or ${color.bold('toes push --force')} to overwrite server`)
|
||||||
console.log()
|
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() {
|
export async function syncApp() {
|
||||||
|
|
@ -641,7 +555,7 @@ export async function syncApp() {
|
||||||
|
|
||||||
console.log(`Syncing ${color.bold(appName)}...`)
|
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)
|
await mergeSync(appName)
|
||||||
|
|
||||||
const gitignore = loadGitignore(process.cwd())
|
const gitignore = loadGitignore(process.cwd())
|
||||||
|
|
@ -826,8 +740,8 @@ export async function stashApp() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { localChanged, localOnly } = diff
|
const { changed, localOnly } = diff
|
||||||
const toStash = [...localChanged, ...localOnly]
|
const toStash = [...changed, ...localOnly]
|
||||||
|
|
||||||
if (toStash.length === 0) {
|
if (toStash.length === 0) {
|
||||||
console.log('No local changes to stash')
|
console.log('No local changes to stash')
|
||||||
|
|
@ -849,7 +763,7 @@ export async function stashApp() {
|
||||||
app: appName,
|
app: appName,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
files: toStash,
|
files: toStash,
|
||||||
changed: localChanged,
|
changed,
|
||||||
localOnly,
|
localOnly,
|
||||||
}
|
}
|
||||||
writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2))
|
writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2))
|
||||||
|
|
@ -870,9 +784,9 @@ export async function stashApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore changed files from server
|
// Restore changed files from server
|
||||||
if (localChanged.length > 0) {
|
if (changed.length > 0) {
|
||||||
console.log(`\nRestoring ${localChanged.length} changed files from server...`)
|
console.log(`\nRestoring ${changed.length} changed files from server...`)
|
||||||
for (const file of localChanged) {
|
for (const file of changed) {
|
||||||
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
||||||
if (content) {
|
if (content) {
|
||||||
writeFileSync(join(process.cwd(), file), content)
|
writeFileSync(join(process.cwd(), file), content)
|
||||||
|
|
@ -1067,34 +981,27 @@ async function mergeSync(appName: string): Promise<void> {
|
||||||
const diff = await getManifestDiff(appName)
|
const diff = await getManifestDiff(appName)
|
||||||
if (!diff) return
|
if (!diff) return
|
||||||
|
|
||||||
const { remoteChanged, remoteOnly, remoteDeleted, remoteManifest } = diff
|
const { changed, remoteOnly, remoteManifest, serverChanged } = diff
|
||||||
if (!remoteManifest) return
|
if (!remoteManifest) return
|
||||||
|
|
||||||
// Pull remote changes
|
if (serverChanged) {
|
||||||
const toPull = [...remoteChanged, ...remoteOnly]
|
// Pull remote changes
|
||||||
|
const toPull = [...changed, ...remoteOnly]
|
||||||
|
|
||||||
if (toPull.length > 0) {
|
if (toPull.length > 0) {
|
||||||
for (const file of toPull) {
|
for (const file of toPull) {
|
||||||
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
||||||
if (!content) continue
|
if (!content) continue
|
||||||
|
|
||||||
const fullPath = join(process.cwd(), file)
|
const fullPath = join(process.cwd(), file)
|
||||||
const dir = dirname(fullPath)
|
const dir = dirname(fullPath)
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true })
|
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<ManifestDiff | null> {
|
||||||
const remoteManifest = result.manifest ?? null
|
const remoteManifest = result.manifest ?? null
|
||||||
const remoteVersion = result.version ?? null
|
const remoteVersion = result.version ?? null
|
||||||
const syncState = readSyncState(process.cwd())
|
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 localFiles = new Set(Object.keys(localManifest.files))
|
||||||
const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {}))
|
const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {}))
|
||||||
const baseFiles = new Set(Object.keys(baseManifest?.files ?? {}))
|
|
||||||
|
|
||||||
// Collect all unique paths
|
// Files that differ
|
||||||
const allPaths = new Set([...localFiles, ...remoteFiles, ...baseFiles])
|
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())
|
// Files only in local
|
||||||
|
|
||||||
const localChanged: string[] = []
|
|
||||||
const remoteChanged: string[] = []
|
|
||||||
const localOnly: string[] = []
|
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 remoteOnly: string[] = []
|
||||||
const localDeleted: string[] = []
|
for (const file of remoteFiles) {
|
||||||
const remoteDeleted: string[] = []
|
if (!localFiles.has(file) && !gitignore.shouldExclude(file)) {
|
||||||
const conflicts: string[] = []
|
remoteOnly.push(file)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1211,18 +1076,14 @@ async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
localChanged,
|
changed,
|
||||||
remoteChanged,
|
|
||||||
localOnly: localOnly.filter(f => !matchedLocal.has(f)),
|
localOnly: localOnly.filter(f => !matchedLocal.has(f)),
|
||||||
remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)),
|
remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)),
|
||||||
localDeleted,
|
|
||||||
remoteDeleted,
|
|
||||||
conflicts,
|
|
||||||
renamed,
|
renamed,
|
||||||
localManifest,
|
localManifest,
|
||||||
remoteManifest,
|
remoteManifest,
|
||||||
baseManifest,
|
|
||||||
remoteVersion,
|
remoteVersion,
|
||||||
|
serverChanged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { join, relative } from 'path'
|
||||||
|
|
||||||
export interface SyncState {
|
export interface SyncState {
|
||||||
version: string
|
version: string
|
||||||
manifest: Manifest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readSyncState(appPath: string): SyncState | null {
|
export function readSyncState(appPath: string): SyncState | null {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user