perf(git-sync): sparse partial-clone fetch for pull and push
Build and Deploy Verso / deploy (push) Successful in 11m27s

Pull now reuses the persistent repo cache with --filter=blob:none and
checks out only the configured subPath, so only relevant blobs are
downloaded. Push likewise fetches trees-only and uses git read-tree to
update the index without touching the working tree, preserving files
outside the managed area without downloading their content.
Upserts on pull are now parallelised (p-limit 5) to speed up import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-24 09:08:46 +00:00
co-authored by Claude Sonnet 4.6
parent e693aa6019
commit 34d96c257f
2 changed files with 80 additions and 58 deletions
@@ -81,12 +81,12 @@ async function pullFromGit(req, res) {
const projectId = req.params.project_id
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const { remoteUrl, subPath } = await GitSyncHandler.getConfig(projectId)
const { remoteUrl, subPath, branch } = await GitSyncHandler.getConfig(projectId)
if (!remoteUrl) {
return res.status(400).json({ error: 'No git remote configured for this project' })
}
logger.debug({ projectId }, 'git sync: starting pull')
await GitSyncHandler.pullFromRemote(projectId, remoteUrl, subPath, userId)
await GitSyncHandler.pullFromRemote(projectId, remoteUrl, subPath, branch, userId)
res.sendStatus(204)
} catch (err) {
logger.warn({ err, projectId }, 'git sync: pull failed')
@@ -1,4 +1,5 @@
import { mkdir, writeFile, rm, readdir, readFile, access } from 'node:fs/promises'
import pLimit from 'p-limit'
import { createWriteStream } from 'node:fs'
import { join, dirname, extname, basename } from 'node:path'
import { spawn } from 'node:child_process'
@@ -143,9 +144,13 @@ async function ensureRepo(projectId, remoteUrl, branch) {
await spawnGit(['config', 'user.email', 'verso-sync@localhost'], repoDir)
await spawnGit(['config', 'user.name', 'Verso Sync'], repoDir)
await spawnGit(['remote', 'add', 'origin', remoteUrl], repoDir)
await spawnGit(['config', 'remote.origin.partialclonefilter', 'blob:none'], repoDir)
await spawnGit(['config', 'remote.origin.promisor', 'true'], repoDir)
} else {
await spawnGit(['config', 'user.email', 'verso-sync@localhost'], repoDir)
await spawnGit(['config', 'user.name', 'Verso Sync'], repoDir)
await spawnGit(['config', 'remote.origin.partialclonefilter', 'blob:none'], repoDir)
await spawnGit(['config', 'remote.origin.promisor', 'true'], repoDir)
}
return repoDir
@@ -164,13 +169,13 @@ async function pushToRemote(
// objects are sent — git handles the diff automatically.
const repoDir = await ensureRepo(projectId, remoteUrl, branch)
// Fetch current remote state. This is a delta fetch — fast after the
// first push because git only downloads new commits/objects.
// Fetch remote state (trees only, no blobs) to preserve files outside the managed area.
// read-tree updates the index without checking out any files, so no blobs are downloaded.
try {
await spawnGit(['fetch', 'origin', branch], repoDir)
await spawnGit(['reset', '--hard', `origin/${branch}`], repoDir)
await spawnGit(['fetch', '--filter=blob:none', 'origin', branch], repoDir)
await spawnGit(['read-tree', 'FETCH_HEAD'], repoDir)
} catch {
// Remote branch doesn't exist yet (first push to empty repo) — proceed
// Empty remote or first push — proceed with empty index
}
const fileRoot = subPath ? join(repoDir, subPath) : repoDir
@@ -226,7 +231,17 @@ async function pushToRemote(
logger.debug({ projectId }, 'git sync: PDF included')
}
await spawnGit(['add', '-A'], repoDir)
// Stage only what we changed; files outside the managed area are preserved in the index from read-tree
if (pushFiles) {
if (subPath) {
await spawnGit(['add', '-A', '--', subPath], repoDir)
} else {
await spawnGit(['add', '-A'], repoDir)
}
}
if (pushPdf && pdfPath && pdfBuildId) {
await spawnGit(['add', '--', pdfPath], repoDir)
}
await spawnGit(['commit', '--allow-empty', '-m', 'Verso sync'], repoDir)
await spawnGit(['push', '--force', 'origin', `HEAD:${branch}`], repoDir)
@@ -235,59 +250,66 @@ async function pushToRemote(
// for the next push so git can compute a proper delta.
}
async function pullFromRemote(projectId, remoteUrl, subPath, userId) {
const tmpDir = join(GIT_CACHE_ROOT, `pull-${projectId}`)
await mkdir(tmpDir, { recursive: true })
logger.debug({ projectId, subPath }, 'git sync: cloning remote for pull')
try {
await rm(join(tmpDir, 'repo'), { recursive: true, force: true })
await spawnGit(['clone', '--depth', '1', '--', remoteUrl, 'repo'], tmpDir)
async function pullFromRemote(projectId, remoteUrl, subPath, branch, userId) {
logger.debug({ projectId, subPath }, 'git sync: fetching remote for pull')
const repoDir = join(tmpDir, 'repo')
const fileRoot = subPath ? join(repoDir, subPath) : repoDir
const repoDir = await ensureRepo(projectId, remoteUrl, branch)
// Fetch with blob filter — only trees/commits downloaded upfront
await spawnGit(['fetch', '--filter=blob:none', 'origin', branch], repoDir)
try {
await access(fileRoot)
} catch {
throw new Error(
subPath
? `Subdirectory "${subPath}" not found in the repository`
: 'Repository is empty'
)
}
const allFiles = await walkDir(fileRoot)
logger.debug({ projectId, count: allFiles.length }, 'git sync: importing files from remote')
for (const absPath of allFiles) {
const rel = absPath.slice(fileRoot.length)
const projectPath = rel.startsWith('/') ? rel : `/${rel}`
if (isTextFile(basename(absPath))) {
const content = await readFile(absPath, 'utf8')
await ProjectEntityUpdateHandler.promises.upsertDocWithPath(
projectId,
projectPath,
content.split('\n'),
'git-sync',
userId
)
} else {
await ProjectEntityUpdateHandler.promises.upsertFileWithPath(
projectId,
projectPath,
absPath,
null,
userId,
'git-sync'
)
}
}
logger.debug({ projectId }, 'git sync: pull complete')
} finally {
await rm(tmpDir, { recursive: true, force: true })
if (subPath) {
// Checkout only the subPath directory — triggers lazy blob fetch for those files only
await spawnGit(['checkout', 'FETCH_HEAD', '--', subPath], repoDir)
} else {
// Full reset — all blobs downloaded (unavoidable when no subPath)
await spawnGit(['reset', '--hard', 'FETCH_HEAD'], repoDir)
}
const fileRoot = subPath ? join(repoDir, subPath) : repoDir
try {
await access(fileRoot)
} catch {
throw new Error(
subPath
? `Subdirectory "${subPath}" not found in the repository`
: 'Repository is empty'
)
}
const allFiles = await walkDir(fileRoot)
logger.debug({ projectId, count: allFiles.length }, 'git sync: importing files from remote')
const limit = pLimit(5)
await Promise.all(
allFiles.map(absPath =>
limit(async () => {
const rel = absPath.slice(fileRoot.length)
const projectPath = rel.startsWith('/') ? rel : `/${rel}`
if (isTextFile(basename(absPath))) {
const content = await readFile(absPath, 'utf8')
await ProjectEntityUpdateHandler.promises.upsertDocWithPath(
projectId,
projectPath,
content.split('\n'),
'git-sync',
userId
)
} else {
await ProjectEntityUpdateHandler.promises.upsertFileWithPath(
projectId,
projectPath,
absPath,
null,
userId,
'git-sync'
)
}
})
)
)
logger.debug({ projectId }, 'git sync: pull complete')
}
export default { setConfig, getConfig, pushToRemote, pullFromRemote }