fix: replace SHA comparison with file size for LFS compatibility + add NoChangesModal
Git LFS stores pointer files whose SHA differs from the actual blob SHA, causing false-positive diffs on every upload. Switching to file size comparison resolves this for LFS-enabled repos. Also replaces the inline error message with a dedicated NoChangesModal when no differences are detected, offering cancel (reset) or modify (close modal) actions.
This commit is contained in:
@@ -5,7 +5,7 @@ import { existsSync } from 'fs'
|
|||||||
import { validateUploadSecret } from '@/lib/auth'
|
import { validateUploadSecret } from '@/lib/auth'
|
||||||
import { parseMultiUpload } from '@/lib/parse-upload'
|
import { parseMultiUpload } from '@/lib/parse-upload'
|
||||||
import { compressWithBlender } from '@/lib/blender'
|
import { compressWithBlender } from '@/lib/blender'
|
||||||
import { computeGitBlobSha, getRemoteFolder, pushAllToGitHub } from '@/lib/github'
|
import { getRemoteFolder, pushAllToGitHub } from '@/lib/github'
|
||||||
import { buildCommitMessage } from '@/lib/commit-message'
|
import { buildCommitMessage } from '@/lib/commit-message'
|
||||||
import { TMP_DIR } from '@/lib/constants'
|
import { TMP_DIR } from '@/lib/constants'
|
||||||
import type { FileChange } from '@/lib/types'
|
import type { FileChange } from '@/lib/types'
|
||||||
@@ -84,13 +84,13 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Detect existing files and compare SHA to classify changes ---
|
// --- Detect existing files and compare size to classify changes (LFS-compatible) ---
|
||||||
const folderPath = `public/models/${destination}/${folderName}`
|
const folderPath = `public/models/${destination}/${folderName}`
|
||||||
let remoteFileMap: Map<string, string>
|
let remoteFileMap: Map<string, number>
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const remote = await getRemoteFolder(folderPath)
|
const remote = await getRemoteFolder(folderPath)
|
||||||
remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.sha]))
|
remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.size]))
|
||||||
} catch {
|
} catch {
|
||||||
remoteFileMap = new Map()
|
remoteFileMap = new Map()
|
||||||
}
|
}
|
||||||
@@ -103,13 +103,13 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
for (const f of filesToPush) {
|
for (const f of filesToPush) {
|
||||||
const filename = f.path.split('/').pop() ?? ''
|
const filename = f.path.split('/').pop() ?? ''
|
||||||
const localSha = computeGitBlobSha(Buffer.from(f.contentBase64, 'base64'))
|
const localSize = Buffer.from(f.contentBase64, 'base64').length
|
||||||
const remoteSha = remoteFileMap.get(filename.toLowerCase())
|
const remoteSize = remoteFileMap.get(filename.toLowerCase())
|
||||||
|
|
||||||
if (!remoteSha) {
|
if (remoteSize === undefined) {
|
||||||
fileChanges.set(filename.toLowerCase(), 'new')
|
fileChanges.set(filename.toLowerCase(), 'new')
|
||||||
changedFilesToPush.push(f)
|
changedFilesToPush.push(f)
|
||||||
} else if (remoteSha !== localSha) {
|
} else if (remoteSize !== localSize) {
|
||||||
fileChanges.set(filename.toLowerCase(), 'changed')
|
fileChanges.set(filename.toLowerCase(), 'changed')
|
||||||
changedFilesToPush.push(f)
|
changedFilesToPush.push(f)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+24
-25
@@ -13,22 +13,7 @@ import FolderDropzone from './upload/FolderDropzone'
|
|||||||
import FolderCard from './upload/FolderCard'
|
import FolderCard from './upload/FolderCard'
|
||||||
import ActionButtons from './upload/ActionButtons'
|
import ActionButtons from './upload/ActionButtons'
|
||||||
import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
|
import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
|
||||||
|
import NoChangesModal from './upload/NoChangesModal'
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Client-side SHA computation (same as `git hash-object`)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function computeGitBlobSha(file: File): Promise<string> {
|
|
||||||
const buffer = await file.arrayBuffer()
|
|
||||||
const content = new Uint8Array(buffer)
|
|
||||||
const header = new TextEncoder().encode(`blob ${content.length}\0`)
|
|
||||||
const store = new Uint8Array(header.length + content.length)
|
|
||||||
store.set(header)
|
|
||||||
store.set(content, header.length)
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-1', store)
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// API helpers
|
// API helpers
|
||||||
@@ -56,18 +41,19 @@ async function checkFolderDiffs(
|
|||||||
return { exists: false, diffs: [] }
|
return { exists: false, diffs: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteFiles: { name: string; sha: string }[] = data.files || []
|
const remoteFiles: { name: string; size: number }[] = data.files || []
|
||||||
const remoteMap = new Map(remoteFiles.map((f) => [f.name.toLowerCase(), f.sha]))
|
const remoteMap = new Map(remoteFiles.map((f) => [f.name.toLowerCase(), f.size]))
|
||||||
|
|
||||||
const localFiles: { name: string; sha: string }[] = []
|
// Build local file list with sizes
|
||||||
|
const localFiles: { name: string; size: number }[] = []
|
||||||
localFiles.push({
|
localFiles.push({
|
||||||
name: folder.modelFile.name,
|
name: folder.modelFile.name,
|
||||||
sha: await computeGitBlobSha(folder.modelFile),
|
size: folder.modelFile.size,
|
||||||
})
|
})
|
||||||
for (const tex of folder.textures) {
|
for (const tex of folder.textures) {
|
||||||
localFiles.push({
|
localFiles.push({
|
||||||
name: tex.name,
|
name: tex.name,
|
||||||
sha: await computeGitBlobSha(tex.file),
|
size: tex.file.size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +63,10 @@ async function checkFolderDiffs(
|
|||||||
for (const local of localFiles) {
|
for (const local of localFiles) {
|
||||||
const key = local.name.toLowerCase()
|
const key = local.name.toLowerCase()
|
||||||
localNames.add(key)
|
localNames.add(key)
|
||||||
const remoteSha = remoteMap.get(key)
|
const remoteSize = remoteMap.get(key)
|
||||||
if (!remoteSha) {
|
if (remoteSize === undefined) {
|
||||||
diffs.push({ name: local.name, status: 'new' })
|
diffs.push({ name: local.name, status: 'new' })
|
||||||
} else if (remoteSha !== local.sha) {
|
} else if (remoteSize !== local.size) {
|
||||||
diffs.push({ name: local.name, status: 'changed' })
|
diffs.push({ name: local.name, status: 'changed' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +163,7 @@ export default function UploadZone() {
|
|||||||
folderName: string
|
folderName: string
|
||||||
diffs: FileDiff[]
|
diffs: FileDiff[]
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const [noChangesFolder, setNoChangesFolder] = useState<string | null>(null)
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
// -- Handlers --
|
// -- Handlers --
|
||||||
@@ -207,7 +194,7 @@ export default function UploadZone() {
|
|||||||
const check = await checkFolderDiffs(folder, destination, secret, abortRef.current?.signal)
|
const check = await checkFolderDiffs(folder, destination, secret, abortRef.current?.signal)
|
||||||
if (check.exists) {
|
if (check.exists) {
|
||||||
if (check.diffs.length === 0) {
|
if (check.diffs.length === 0) {
|
||||||
setGlobalError('Aucun fichier modifie — le dossier distant est identique.')
|
setNoChangesFolder(folder.folderName)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
|
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
|
||||||
@@ -330,6 +317,18 @@ export default function UploadZone() {
|
|||||||
onConfirm={proceedUpload}
|
onConfirm={proceedUpload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{noChangesFolder && (
|
||||||
|
<NoChangesModal
|
||||||
|
destination={destination}
|
||||||
|
folderName={noChangesFolder}
|
||||||
|
onCancel={() => {
|
||||||
|
setNoChangesFolder(null)
|
||||||
|
handleReset()
|
||||||
|
}}
|
||||||
|
onModify={() => setNoChangesFolder(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
interface NoChangesModalProps {
|
||||||
|
destination: string
|
||||||
|
folderName: string
|
||||||
|
onCancel: () => void
|
||||||
|
onModify: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NoChangesModal({
|
||||||
|
destination,
|
||||||
|
folderName,
|
||||||
|
onCancel,
|
||||||
|
onModify,
|
||||||
|
}: NoChangesModalProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="no-changes-title"
|
||||||
|
>
|
||||||
|
<div className="bg-black-900 border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-700/50 flex items-center justify-center shrink-0">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 id="no-changes-title" className="text-sm font-semibold text-gray-100">
|
||||||
|
Aucun changement detecte
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Le dossier <span className="font-mono text-gray-300">{destination}/{folderName}</span> est identique au contenu distant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
|
||||||
|
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150
|
||||||
|
hover:bg-black-600"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onModify}
|
||||||
|
className="flex-1 bg-white text-[#000000] font-medium text-sm
|
||||||
|
py-2.5 px-4 rounded-xl transition-colors duration-150
|
||||||
|
hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -55,7 +55,7 @@ export async function getRemoteFolder(
|
|||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
return {
|
return {
|
||||||
exists: true,
|
exists: true,
|
||||||
files: data.map((f) => ({ name: f.name, sha: f.sha })),
|
files: data.map((f) => ({ name: f.name, size: f.size })),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ export interface FileDiff {
|
|||||||
|
|
||||||
export interface RemoteFile {
|
export interface RemoteFile {
|
||||||
name: string
|
name: string
|
||||||
sha: string
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UploadResponse =
|
export type UploadResponse =
|
||||||
|
|||||||
Reference in New Issue
Block a user