Files
Verso/services/web/app/src/Features/GitSync/GitSyncHandler.mjs
T
claudeandClaude Sonnet 4.6 f22ee608a7
Build and Deploy Verso / deploy (push) Has been cancelled
feat(git-sync): auto-push on compile, PDF path, icon fix
- Icon: change rail tab from integration_instructions to merge
- Auto-push on compile: toggle stored in localStorage, watches compile
  context; when compiling goes true→false with no error, triggers a push
  automatically (including the latest build's PDF if configured)
- PDF destination path: new gitSyncPdfPath field; backend fetches the
  compiled PDF from CLSI (buildId + clsiServerId passed from frontend)
  and writes it at the configured path in the repo; silently skipped if
  no recent compile or field is blank
- Push now always sends current buildId/clsiServerId so PDF is included
  without needing a separate save step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 21:07:13 +00:00

122 lines
4.2 KiB
JavaScript

import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { createWriteStream } from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { spawn } from 'node:child_process'
import { pipeline } from 'node:stream/promises'
import logger from '@overleaf/logger'
import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
import HistoryManager from '../History/HistoryManager.mjs'
import ClsiManager from '../Compile/ClsiManager.mjs'
import { Project } from '../../models/Project.mjs'
import { ObjectId } from '../../infrastructure/mongodb.mjs'
async function spawnGit(args, cwd) {
return new Promise((resolve, reject) => {
const proc = spawn('git', args, {
cwd,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
})
let stderr = ''
proc.stderr?.on('data', d => (stderr += d.toString()))
proc.on('close', code => {
if (code !== 0) {
reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`))
} else {
resolve()
}
})
proc.on('error', reject)
})
}
async function setConfig(projectId, { remoteUrl, subPath, pdfPath }) {
await Project.updateOne(
{ _id: new ObjectId(projectId) },
{ $set: { gitRemote: remoteUrl, gitSyncPath: subPath, gitSyncPdfPath: pdfPath } }
)
}
async function getConfig(projectId) {
const project = await Project.findById(projectId, {
gitRemote: 1,
gitSyncPath: 1,
gitSyncPdfPath: 1,
}).lean()
return {
remoteUrl: project?.gitRemote ?? null,
subPath: project?.gitSyncPath ?? '',
pdfPath: project?.gitSyncPdfPath ?? '',
}
}
async function pushToRemote(
projectId,
remoteUrl,
subPath,
{ pdfPath, pdfBuildId, pdfClsiServerId, userId } = {}
) {
const tmpDir = await mkdtemp(join(tmpdir(), 'verso-git-'))
logger.debug({ projectId, tmpDir, subPath }, 'git sync: writing project to temp dir')
try {
const fileRoot = subPath ? join(tmpDir, subPath) : tmpDir
// Fetch all text documents
const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
for (const [path, doc] of Object.entries(docs)) {
const rel = path.startsWith('/') ? path.slice(1) : path
const dest = join(fileRoot, rel)
await mkdir(dirname(dest), { recursive: true })
await writeFile(dest, doc.lines.join('\n'))
}
// Fetch all binary files
const files = await ProjectEntityHandler.promises.getAllFiles(projectId)
for (const [path, file] of Object.entries(files)) {
const rel = path.startsWith('/') ? path.slice(1) : path
const dest = join(fileRoot, rel)
await mkdir(dirname(dest), { recursive: true })
const { stream } =
await HistoryManager.promises.requestBlobWithProjectId(
projectId,
file.hash
)
await pipeline(stream, createWriteStream(dest))
}
// Optionally include the compiled PDF at a configured path
if (pdfPath && pdfBuildId && pdfClsiServerId && userId) {
try {
const pdfStream = await ClsiManager.promises.getOutputFileStream(
projectId,
userId,
pdfClsiServerId,
pdfBuildId,
'output.pdf'
)
const pdfDest = join(fileRoot, pdfPath)
await mkdir(dirname(pdfDest), { recursive: true })
await pipeline(pdfStream, createWriteStream(pdfDest))
logger.debug({ projectId }, 'git sync: PDF included')
} catch (err) {
logger.warn({ err, projectId }, 'git sync: could not fetch PDF, skipping')
}
}
// Git operations — force-push a single commit so the remote always
// reflects the current Verso state exactly.
await spawnGit(['init', '-b', 'main'], tmpDir)
await spawnGit(['config', 'user.email', 'verso-sync@localhost'], tmpDir)
await spawnGit(['config', 'user.name', 'Verso Sync'], tmpDir)
await spawnGit(['add', '-A'], tmpDir)
await spawnGit(['commit', '--allow-empty', '-m', 'Verso sync'], tmpDir)
await spawnGit(['push', '--force', remoteUrl, 'HEAD:main'], tmpDir)
logger.debug({ projectId }, 'git sync: push complete')
} finally {
await rm(tmpDir, { recursive: true, force: true })
}
}
export default { setConfig, getConfig, pushToRemote }