.toes
This commit is contained in:
parent
10154dfd4f
commit
b6e9ec73de
|
|
@ -1,6 +1,6 @@
|
|||
import type { Manifest } from '@types'
|
||||
import { loadGitignore } from '@gitignore'
|
||||
import { generateManifest } from '%sync'
|
||||
import { generateManifest, readSyncState, writeSyncState } from '%sync'
|
||||
import color from 'kleur'
|
||||
import { diffLines } from 'diff'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||
|
|
@ -22,12 +22,18 @@ interface Rename {
|
|||
}
|
||||
|
||||
interface ManifestDiff {
|
||||
changed: string[]
|
||||
localChanged: string[]
|
||||
remoteChanged: string[]
|
||||
localOnly: string[]
|
||||
remoteOnly: string[]
|
||||
localDeleted: string[]
|
||||
remoteDeleted: string[]
|
||||
conflicts: string[]
|
||||
renamed: Rename[]
|
||||
localManifest: Manifest
|
||||
remoteManifest: Manifest | null
|
||||
baseManifest: Manifest | null
|
||||
remoteVersion: string | null
|
||||
}
|
||||
|
||||
export async function historyApp(name?: string) {
|
||||
|
|
@ -87,8 +93,8 @@ export async function historyApp(name?: string) {
|
|||
export async function getApp(name: string) {
|
||||
console.log(`Fetching ${color.bold(name)} from server...`)
|
||||
|
||||
const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`)
|
||||
if (!manifest) {
|
||||
const result = await getManifest(name)
|
||||
if (!result || !result.exists || !result.manifest) {
|
||||
console.error(`App not found: ${name}`)
|
||||
return
|
||||
}
|
||||
|
|
@ -101,7 +107,7 @@ export async function getApp(name: string) {
|
|||
|
||||
mkdirSync(appPath, { recursive: true })
|
||||
|
||||
const files = Object.keys(manifest.files)
|
||||
const files = Object.keys(result.manifest.files)
|
||||
console.log(`Downloading ${files.length} files...`)
|
||||
|
||||
for (const file of files) {
|
||||
|
|
@ -121,64 +127,75 @@ 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 })
|
||||
}
|
||||
|
||||
console.log(color.green(`✓ Downloaded ${name}`))
|
||||
}
|
||||
|
||||
export async function pushApp(options: { quiet?: boolean } = {}) {
|
||||
export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}) {
|
||||
if (!isApp()) {
|
||||
console.error(notAppError())
|
||||
return
|
||||
}
|
||||
|
||||
const appName = getAppName()
|
||||
const diff = await getManifestDiff(appName)
|
||||
|
||||
const localManifest = generateManifest(process.cwd(), appName)
|
||||
const result = await getManifest(appName)
|
||||
|
||||
if (result === null) {
|
||||
// Connection error - already printed
|
||||
if (diff === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.exists) {
|
||||
const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed, localManifest, remoteManifest } = diff
|
||||
|
||||
if (!remoteManifest) {
|
||||
const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const localFiles = new Set(Object.keys(localManifest.files))
|
||||
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))
|
||||
|
||||
// Files to upload (new or changed)
|
||||
const toUpload: string[] = []
|
||||
for (const file of localFiles) {
|
||||
const local = localManifest.files[file]!
|
||||
const remote = result.manifest?.files[file]
|
||||
if (!remote || local.hash !== remote.hash) {
|
||||
toUpload.push(file)
|
||||
// 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)`)
|
||||
}
|
||||
console.error('\nRun `toes pull` first, or `toes push --force` to overwrite')
|
||||
return
|
||||
}
|
||||
|
||||
// Files to delete (exist on server but not locally, respecting gitignore)
|
||||
const gitignore = loadGitignore(process.cwd())
|
||||
const toDelete: string[] = []
|
||||
for (const file of remoteFiles) {
|
||||
if (!localFiles.has(file) && !gitignore.shouldExclude(file)) {
|
||||
toDelete.push(file)
|
||||
}
|
||||
}
|
||||
// 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 : [])]
|
||||
|
||||
// Detect renames among upload/delete pairs (same hash, different path)
|
||||
const renames: Rename[] = []
|
||||
const renames: Rename[] = [...renamed]
|
||||
const serverManifest = remoteManifest
|
||||
const remoteByHash = new Map<string, string>()
|
||||
for (const file of toDelete) {
|
||||
const hash = result.manifest!.files[file]!.hash
|
||||
remoteByHash.set(hash, file)
|
||||
if (serverManifest) {
|
||||
for (const file of toDelete) {
|
||||
const info = serverManifest.files[file]
|
||||
if (info) remoteByHash.set(info.hash, file)
|
||||
}
|
||||
}
|
||||
|
||||
const renamedUploads = new Set<string>()
|
||||
const renamedDeletes = new Set<string>()
|
||||
for (const file of toUpload) {
|
||||
const hash = localManifest.files[file]!.hash
|
||||
const hash = localManifest.files[file]?.hash
|
||||
if (!hash) continue
|
||||
const remoteFile = remoteByHash.get(hash)
|
||||
if (remoteFile && !renamedDeletes.has(remoteFile)) {
|
||||
renames.push({ from: remoteFile, to: file })
|
||||
|
|
@ -266,6 +283,10 @@ export async function pushApp(options: { quiet?: boolean } = {}) {
|
|||
return
|
||||
}
|
||||
|
||||
// 5. Write sync state after successful push
|
||||
const newManifest = generateManifest(process.cwd(), appName)
|
||||
writeSyncState(process.cwd(), { version, manifest: newManifest })
|
||||
|
||||
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +303,7 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}
|
|||
return
|
||||
}
|
||||
|
||||
const { changed, localOnly, remoteOnly, remoteManifest } = diff
|
||||
const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, remoteManifest, remoteVersion } = diff
|
||||
|
||||
if (!remoteManifest) {
|
||||
console.error('App not found on server')
|
||||
|
|
@ -290,19 +311,35 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}
|
|||
}
|
||||
|
||||
// Check for local changes that would be overwritten
|
||||
const wouldOverwrite = changed.length > 0 || localOnly.length > 0
|
||||
if (wouldOverwrite && !options.force) {
|
||||
const hasLocalChanges = localChanged.length > 0 || localOnly.length > 0 || localDeleted.length > 0 || conflicts.length > 0
|
||||
if (hasLocalChanges && !options.force) {
|
||||
console.error('Cannot pull: you have local changes that would be overwritten')
|
||||
console.error(' Use `toes status` and `toes diff` to see differences')
|
||||
console.error(' Use `toes pull --force` to overwrite local changes')
|
||||
for (const file of localChanged) {
|
||||
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: changed + remoteOnly
|
||||
const toDownload = [...changed, ...remoteOnly]
|
||||
// Files to download: remote changed + remote only + conflicts (when forcing)
|
||||
const toDownload = [...remoteChanged, ...remoteOnly, ...(options.force ? [...localChanged, ...conflicts] : [])]
|
||||
|
||||
// Files to delete: localOnly
|
||||
const toDelete = localOnly
|
||||
// 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)
|
||||
}
|
||||
|
||||
if (toDownload.length === 0 && toDelete.length === 0) {
|
||||
if (!options.quiet) console.log('Already up to date')
|
||||
|
|
@ -336,11 +373,19 @@ export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}
|
|||
console.log(`Deleting ${toDelete.length} local files...`)
|
||||
for (const file of toDelete) {
|
||||
const fullPath = join(process.cwd(), file)
|
||||
unlinkSync(fullPath)
|
||||
console.log(` ${color.red('✗')} ${file}`)
|
||||
if (existsSync(fullPath)) {
|
||||
unlinkSync(fullPath)
|
||||
console.log(` ${color.red('✗')} ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write sync state after successful pull
|
||||
if (remoteVersion) {
|
||||
const newManifest = generateManifest(process.cwd(), appName)
|
||||
writeSyncState(process.cwd(), { version: remoteVersion, manifest: newManifest })
|
||||
}
|
||||
|
||||
console.log(color.green('✓ Pull complete'))
|
||||
}
|
||||
|
||||
|
|
@ -357,10 +402,14 @@ export async function diffApp() {
|
|||
return
|
||||
}
|
||||
|
||||
const { changed, localOnly, remoteOnly, renamed } = diff
|
||||
const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed } = diff
|
||||
|
||||
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) {
|
||||
// console.log(color.green('✓ No differences'))
|
||||
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) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -371,27 +420,73 @@ export async function diffApp() {
|
|||
console.log(color.gray('─'.repeat(60)))
|
||||
}
|
||||
|
||||
// Fetch all changed files in parallel
|
||||
// Fetch remote content for changed/conflict files
|
||||
const changedFiles = [...localChanged, ...remoteChanged, ...conflicts]
|
||||
const remoteContents = await Promise.all(
|
||||
changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
|
||||
changedFiles.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
|
||||
)
|
||||
|
||||
// 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')
|
||||
// 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')
|
||||
|
||||
if (!remoteContent) {
|
||||
console.log(color.red(`Failed to fetch remote version of ${file}`))
|
||||
continue
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const remoteText = new TextDecoder().decode(remoteContent)
|
||||
// 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')
|
||||
|
||||
console.log(color.bold(`\n${file}`))
|
||||
console.log(color.gray('─'.repeat(60)))
|
||||
showDiff(remoteText, localContent)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Show local-only files
|
||||
|
|
@ -419,14 +514,14 @@ export async function diffApp() {
|
|||
const file = remoteOnly[i]!
|
||||
const content = remoteOnlyContents[i]
|
||||
|
||||
console.log(color.bold(`\n${file}`))
|
||||
console.log(color.yellow('\nNew file (remote only)'))
|
||||
console.log(color.bold(`${file}`))
|
||||
console.log(color.gray('─'.repeat(60)))
|
||||
console.log(color.red('Remote only (would be deleted on push)'))
|
||||
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.red(`- ${lines[i]}`))
|
||||
console.log(color.green(`+ ${lines[i]}`))
|
||||
}
|
||||
if (lines.length > 10) {
|
||||
console.log(color.gray(`... ${lines.length - 10} more lines`))
|
||||
|
|
@ -434,6 +529,20 @@ 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()
|
||||
}
|
||||
|
||||
|
|
@ -451,16 +560,7 @@ export async function statusApp() {
|
|||
return
|
||||
}
|
||||
|
||||
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 || renamed.length > 0
|
||||
|
||||
// Display status
|
||||
// console.log(`Status for ${color.bold(appName)}:\n`)
|
||||
const { localChanged, remoteChanged, localOnly, remoteOnly, localDeleted, remoteDeleted, conflicts, renamed, localManifest, remoteManifest, baseManifest } = diff
|
||||
|
||||
if (!remoteManifest) {
|
||||
console.log(color.yellow('App does not exist on server'))
|
||||
|
|
@ -469,32 +569,56 @@ export async function statusApp() {
|
|||
return
|
||||
}
|
||||
|
||||
const toPush = [...localChanged, ...localOnly, ...localDeleted]
|
||||
const toPull = [...remoteChanged, ...remoteOnly, ...remoteDeleted]
|
||||
|
||||
// Push status
|
||||
if (toPush.length > 0 || remoteOnly.length > 0 || renamed.length > 0) {
|
||||
if (toPush.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) {
|
||||
for (const file of localChanged) {
|
||||
console.log(` ${color.green('↑')} ${file}`)
|
||||
}
|
||||
for (const file of remoteOnly) {
|
||||
console.log(` ${color.red('✗')} ${file}`)
|
||||
for (const file of localOnly) {
|
||||
console.log(` ${color.green('+')} ${file}`)
|
||||
}
|
||||
for (const file of localDeleted) {
|
||||
console.log(` ${color.red('-')} ${file}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
// Pull status (only show if no local changes blocking)
|
||||
if (!hasLocalChanges && remoteOnly.length > 0) {
|
||||
// Pull status
|
||||
if (toPull.length > 0) {
|
||||
console.log(color.bold('Changes to pull:'))
|
||||
for (const file of remoteOnly) {
|
||||
for (const file of remoteChanged) {
|
||||
console.log(` ${color.green('↓')} ${file}`)
|
||||
}
|
||||
for (const file of remoteOnly) {
|
||||
console.log(` ${color.green('+')} ${file}`)
|
||||
}
|
||||
for (const file of remoteDeleted) {
|
||||
console.log(` ${color.red('-')} ${file}`)
|
||||
}
|
||||
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 && remoteOnly.length === 0 && renamed.length === 0) {
|
||||
if (toPush.length === 0 && toPull.length === 0 && conflicts.length === 0 && renamed.length === 0) {
|
||||
console.log(color.green('✓ In sync with server'))
|
||||
}
|
||||
}
|
||||
|
|
@ -517,7 +641,7 @@ export async function syncApp() {
|
|||
|
||||
console.log(`Syncing ${color.bold(appName)}...`)
|
||||
|
||||
// Initial sync: merge based on mtime, then push merged state
|
||||
// Initial sync: merge using three-way diff, then push merged state
|
||||
await mergeSync(appName)
|
||||
|
||||
const gitignore = loadGitignore(process.cwd())
|
||||
|
|
@ -702,8 +826,8 @@ export async function stashApp() {
|
|||
return
|
||||
}
|
||||
|
||||
const { changed, localOnly } = diff
|
||||
const toStash = [...changed, ...localOnly]
|
||||
const { localChanged, localOnly } = diff
|
||||
const toStash = [...localChanged, ...localOnly]
|
||||
|
||||
if (toStash.length === 0) {
|
||||
console.log('No local changes to stash')
|
||||
|
|
@ -725,7 +849,7 @@ export async function stashApp() {
|
|||
app: appName,
|
||||
timestamp: new Date().toISOString(),
|
||||
files: toStash,
|
||||
changed,
|
||||
changed: localChanged,
|
||||
localOnly,
|
||||
}
|
||||
writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2))
|
||||
|
|
@ -746,9 +870,9 @@ export async function stashApp() {
|
|||
}
|
||||
|
||||
// Restore changed files from server
|
||||
if (changed.length > 0) {
|
||||
console.log(`\nRestoring ${changed.length} changed files from server...`)
|
||||
for (const file of changed) {
|
||||
if (localChanged.length > 0) {
|
||||
console.log(`\nRestoring ${localChanged.length} changed files from server...`)
|
||||
for (const file of localChanged) {
|
||||
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
||||
if (content) {
|
||||
writeFileSync(join(process.cwd(), file), content)
|
||||
|
|
@ -943,20 +1067,12 @@ async function mergeSync(appName: string): Promise<void> {
|
|||
const diff = await getManifestDiff(appName)
|
||||
if (!diff) return
|
||||
|
||||
const { changed, remoteOnly, localManifest, remoteManifest } = diff
|
||||
const { remoteChanged, remoteOnly, remoteDeleted, remoteManifest } = diff
|
||||
if (!remoteManifest) return
|
||||
|
||||
// Determine which changed files to pull (remote is newer)
|
||||
const toPull: string[] = [...remoteOnly]
|
||||
for (const file of changed) {
|
||||
const localMtime = new Date(localManifest.files[file]!.mtime).getTime()
|
||||
const remoteMtime = new Date(remoteManifest.files[file]!.mtime).getTime()
|
||||
if (remoteMtime > localMtime) {
|
||||
toPull.push(file)
|
||||
}
|
||||
}
|
||||
// Pull remote changes
|
||||
const toPull = [...remoteChanged, ...remoteOnly]
|
||||
|
||||
// Pull remote-newer and remote-only files
|
||||
if (toPull.length > 0) {
|
||||
for (const file of toPull) {
|
||||
const content = await download(`/api/sync/apps/${appName}/files/${file}`)
|
||||
|
|
@ -973,6 +1089,15 @@ async function mergeSync(appName: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Push merged state to server
|
||||
await pushApp({ quiet: true })
|
||||
}
|
||||
|
|
@ -986,35 +1111,82 @@ async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
|||
return null
|
||||
}
|
||||
|
||||
const remoteManifest = result.manifest ?? null
|
||||
const remoteVersion = result.version ?? null
|
||||
const syncState = readSyncState(process.cwd())
|
||||
const baseManifest = syncState?.manifest ?? null
|
||||
|
||||
const localFiles = new Set(Object.keys(localManifest.files))
|
||||
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))
|
||||
const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {}))
|
||||
const baseFiles = new Set(Object.keys(baseManifest?.files ?? {}))
|
||||
|
||||
// Files that differ
|
||||
const changed: string[] = []
|
||||
for (const file of localFiles) {
|
||||
if (remoteFiles.has(file)) {
|
||||
const local = localManifest.files[file]!
|
||||
const remote = result.manifest!.files[file]!
|
||||
if (local.hash !== remote.hash) {
|
||||
changed.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collect all unique paths
|
||||
const allPaths = new Set([...localFiles, ...remoteFiles, ...baseFiles])
|
||||
|
||||
// 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 localChanged: string[] = []
|
||||
const remoteChanged: string[] = []
|
||||
const localOnly: string[] = []
|
||||
const remoteOnly: string[] = []
|
||||
for (const file of remoteFiles) {
|
||||
if (!localFiles.has(file) && !gitignore.shouldExclude(file)) {
|
||||
remoteOnly.push(file)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1022,7 +1194,7 @@ async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
|||
const renamed: Rename[] = []
|
||||
const remoteByHash = new Map<string, string>()
|
||||
for (const file of remoteOnly) {
|
||||
const hash = result.manifest!.files[file]!.hash
|
||||
const hash = remoteManifest!.files[file]!.hash
|
||||
remoteByHash.set(hash, file)
|
||||
}
|
||||
|
||||
|
|
@ -1039,12 +1211,18 @@ async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
|||
}
|
||||
|
||||
return {
|
||||
changed,
|
||||
localChanged,
|
||||
remoteChanged,
|
||||
localOnly: localOnly.filter(f => !matchedLocal.has(f)),
|
||||
remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)),
|
||||
localDeleted,
|
||||
remoteDeleted,
|
||||
conflicts,
|
||||
renamed,
|
||||
localManifest,
|
||||
remoteManifest: result.manifest ?? null,
|
||||
remoteManifest,
|
||||
baseManifest,
|
||||
remoteVersion,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,12 +55,14 @@ export async function get<T>(url: string): Promise<T | undefined> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> {
|
||||
export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> {
|
||||
try {
|
||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`))
|
||||
if (res.status === 404) return { exists: false }
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
return { exists: true, manifest: await res.json() }
|
||||
const data = await res.json()
|
||||
const { version, ...manifest } = data
|
||||
return { exists: true, manifest, version }
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ program
|
|||
.command('push')
|
||||
.helpGroup('Sync:')
|
||||
.description('Push local changes to server')
|
||||
.option('-f, --force', 'overwrite remote changes')
|
||||
.action(pushApp)
|
||||
|
||||
program
|
||||
|
|
|
|||
|
|
@ -3,9 +3,28 @@ export type { FileInfo, Manifest } from '@types'
|
|||
import type { FileInfo, Manifest } from '@types'
|
||||
import { loadGitignore } from '@gitignore'
|
||||
import { createHash } from 'crypto'
|
||||
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'
|
||||
import { join, relative } from 'path'
|
||||
|
||||
export interface SyncState {
|
||||
version: string
|
||||
manifest: Manifest
|
||||
}
|
||||
|
||||
export function readSyncState(appPath: string): SyncState | null {
|
||||
const filePath = join(appPath, '.toes')
|
||||
if (!existsSync(filePath)) return null
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSyncState(appPath: string, state: SyncState): void {
|
||||
writeFileSync(join(appPath, '.toes'), JSON.stringify(state, null, 2))
|
||||
}
|
||||
|
||||
export function computeHash(content: Buffer | string): string {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,8 +147,9 @@ router.get('/apps/:app/manifest', c => {
|
|||
if (!safeAppPath) return c.json({ error: 'Invalid path' }, 400)
|
||||
if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404)
|
||||
|
||||
const version = realpathSync(appPath).split('/').pop()
|
||||
const manifest = generateManifest(appPath, appName)
|
||||
return c.json(manifest)
|
||||
return c.json({ ...manifest, version })
|
||||
})
|
||||
|
||||
router.get('/apps/:app/files/:path{.+}', c => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const ALWAYS_EXCLUDE = [
|
|||
'node_modules',
|
||||
'.DS_Store',
|
||||
'.git',
|
||||
'.toes',
|
||||
'*~',
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user