feat(git-sync): auto-push on compile, PDF path, icon fix
Build and Deploy Verso / deploy (push) Has been cancelled

- 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>
This commit is contained in:
claude
2026-06-23 21:07:13 +00:00
parent 9f1c9babf7
commit f22ee608a7
11 changed files with 152 additions and 28 deletions
@@ -4,7 +4,7 @@ import { expressify } from '@overleaf/promise-utils'
async function configureGitSync(req, res) {
const projectId = req.params.project_id
const { remoteUrl, subPath = '' } = req.body
const { remoteUrl, subPath = '', pdfPath = '' } = req.body
if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') {
return res.status(400).json({ error: 'remoteUrl is required' })
@@ -15,15 +15,20 @@ async function configureGitSync(req, res) {
return res.status(400).json({ error: 'remoteUrl must start with https:// or git@' })
}
// Normalise subPath: strip leading/trailing slashes, reject traversal
const trimmedPath = String(subPath).trim().replace(/^\/+|\/+$/g, '')
if (trimmedPath.includes('..')) {
const trimmedSubPath = String(subPath).trim().replace(/^\/+|\/+$/g, '')
if (trimmedSubPath.includes('..')) {
return res.status(400).json({ error: 'subPath must not contain ..' })
}
const trimmedPdfPath = String(pdfPath).trim().replace(/^\/+/, '')
if (trimmedPdfPath.includes('..')) {
return res.status(400).json({ error: 'pdfPath must not contain ..' })
}
await GitSyncHandler.setConfig(projectId, {
remoteUrl: trimmedUrl,
subPath: trimmedPath,
subPath: trimmedSubPath,
pdfPath: trimmedPdfPath,
})
logger.debug({ projectId }, 'git sync: config saved')
res.sendStatus(204)
@@ -31,15 +36,22 @@ async function configureGitSync(req, res) {
async function pushToGit(req, res) {
const projectId = req.params.project_id
const userId = req.session.user._id
const { buildId, clsiServerId } = req.body ?? {}
const { remoteUrl, subPath } = await GitSyncHandler.getConfig(projectId)
const { remoteUrl, subPath, pdfPath } = 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 push')
try {
await GitSyncHandler.pushToRemote(projectId, remoteUrl, subPath)
await GitSyncHandler.pushToRemote(projectId, remoteUrl, subPath, {
pdfPath,
pdfBuildId: buildId,
pdfClsiServerId: clsiServerId,
userId,
})
res.sendStatus(204)
} catch (err) {
logger.warn({ err, projectId }, 'git sync: push failed')
@@ -50,7 +62,11 @@ async function pushToGit(req, res) {
async function getGitSyncConfig(req, res) {
const projectId = req.params.project_id
const config = await GitSyncHandler.getConfig(projectId)
res.json({ remoteUrl: config.remoteUrl ?? '', subPath: config.subPath })
res.json({
remoteUrl: config.remoteUrl ?? '',
subPath: config.subPath,
pdfPath: config.pdfPath,
})
}
export default {
@@ -7,6 +7,7 @@ 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'
@@ -29,10 +30,10 @@ async function spawnGit(args, cwd) {
})
}
async function setConfig(projectId, { remoteUrl, subPath }) {
async function setConfig(projectId, { remoteUrl, subPath, pdfPath }) {
await Project.updateOne(
{ _id: new ObjectId(projectId) },
{ $set: { gitRemote: remoteUrl, gitSyncPath: subPath } }
{ $set: { gitRemote: remoteUrl, gitSyncPath: subPath, gitSyncPdfPath: pdfPath } }
)
}
@@ -40,19 +41,24 @@ 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) {
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 {
// Files go into tmpDir/subPath/ when a subdirectory is configured,
// or directly into tmpDir when subPath is empty (repo root).
const fileRoot = subPath ? join(tmpDir, subPath) : tmpDir
// Fetch all text documents
@@ -78,6 +84,25 @@ async function pushToRemote(projectId, remoteUrl, subPath) {
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)
@@ -606,6 +606,7 @@ const _ProjectController = {
tokens: 1,
gitRemote: 1,
gitSyncPath: 1,
gitSyncPdfPath: 1,
}),
userIsMemberOfGroupSubscription: sessionUser
? (async () =>
@@ -1027,6 +1028,7 @@ const _ProjectController = {
gitSyncEnabled: Boolean(Settings.enableGitSync),
gitRemote: project.gitRemote ?? '',
gitSyncPath: project.gitSyncPath ?? '',
gitSyncPdfPath: project.gitSyncPdfPath ?? '',
wsUrl,
showSupport: Features.hasFeature('support'),
showTemplatesServerPro,
+1
View File
@@ -110,6 +110,7 @@ export const ProjectSchema = new Schema(
deferredTpdsFlushCounter: { type: Number },
gitRemote: { type: String },
gitSyncPath: { type: String },
gitSyncPdfPath: { type: String },
},
{ minimize: false }
)
@@ -17,6 +17,7 @@ meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled)
meta(name="ol-gitSyncEnabled" data-type="boolean" content=gitSyncEnabled)
meta(name="ol-gitRemote" content=gitRemote)
meta(name="ol-gitSyncPath" content=gitSyncPath)
meta(name="ol-gitSyncPdfPath" content=gitSyncPdfPath)
meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain)
//- enable doc hash checking for all projects
//- used in public/js/libs/sharejs.js
@@ -801,6 +801,9 @@
"git_sync_push_success": "",
"git_sync_remote_url": "",
"git_sync_saved": "",
"git_sync_auto_push_on_compile": "",
"git_sync_pdf_path": "",
"git_sync_pdf_path_placeholder": "",
"git_sync_sub_path": "",
"git_sync_sub_path_placeholder": "",
"git_bridge_modal_review_access": "",
@@ -117,7 +117,7 @@ export const RailLayout = () => {
},
{
key: 'integrations',
icon: 'integration_instructions',
icon: 'merge',
title: t('integrations'),
component: <IntegrationsPanel />,
hide: !isOverleaf && !gitBridgeEnabled && !gitSyncEnabled,
@@ -1,35 +1,75 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { postJSON } from '@/infrastructure/fetch-json'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import OLButton from '@/shared/components/ol/ol-button'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import { FormCheck } from 'react-bootstrap'
import MaterialIcon from '@/shared/components/material-icon'
import OLNotification from '@/shared/components/ol/ol-notification'
type Action = 'save' | 'push'
type Status = 'idle' | 'busy' | 'success' | 'error'
const projectId = getMeta('ol-project_id')
const AUTO_PUSH_KEY = `gitSync.autoPushOnCompile.${projectId}`
export default function GitSyncWidget() {
const { t } = useTranslation()
const projectId = getMeta('ol-project_id')
const { compiling, error: compileError, pdfFile, clsiServerId } =
useCompileContext()
const [remoteUrl, setRemoteUrl] = useState(getMeta('ol-gitRemote') ?? '')
const [subPath, setSubPath] = useState(getMeta('ol-gitSyncPath') ?? '')
const [pdfPath, setPdfPath] = useState(getMeta('ol-gitSyncPdfPath') ?? '')
const [autoPush, setAutoPush] = useState(
() => localStorage.getItem(AUTO_PUSH_KEY) === 'true'
)
const [lastAction, setLastAction] = useState<Action>('save')
const [status, setStatus] = useState<Status>('idle')
const [errorMsg, setErrorMsg] = useState('')
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setLastAction('save')
const wasCompilingRef = useRef(false)
const isBusyRef = useRef(false)
// Keep isBusyRef in sync so the compile watcher can check it
useEffect(() => {
isBusyRef.current = status === 'busy'
}, [status])
// Auto-push when compile finishes successfully
useEffect(() => {
const wasCompiling = wasCompilingRef.current
wasCompilingRef.current = compiling
if (
wasCompiling &&
!compiling &&
!compileError &&
autoPush &&
!isBusyRef.current
) {
doPush(pdfFile?.build, clsiServerId)
}
}, [compiling]) // eslint-disable-line react-hooks/exhaustive-deps
async function saveConfig() {
await postJSON(`/project/${projectId}/git-sync`, {
body: { remoteUrl, subPath, pdfPath },
})
}
async function doPush(buildId?: string, serverId?: string) {
setLastAction('push')
setStatus('busy')
setErrorMsg('')
try {
await postJSON(`/project/${projectId}/git-sync`, {
body: { remoteUrl, subPath },
await saveConfig()
await postJSON(`/project/${projectId}/git-sync/push`, {
body: { buildId, clsiServerId: serverId },
})
setStatus('success')
} catch (err: any) {
@@ -38,16 +78,13 @@ export default function GitSyncWidget() {
}
}
async function handlePush() {
setLastAction('push')
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setLastAction('save')
setStatus('busy')
setErrorMsg('')
try {
// Always save config first so the push reads the current form values
await postJSON(`/project/${projectId}/git-sync`, {
body: { remoteUrl, subPath },
})
await postJSON(`/project/${projectId}/git-sync/push`, { body: {} })
await saveConfig()
setStatus('success')
} catch (err: any) {
setStatus('error')
@@ -55,6 +92,16 @@ export default function GitSyncWidget() {
}
}
function handlePush() {
doPush(pdfFile?.build, clsiServerId)
}
function handleAutoPushToggle(e: React.ChangeEvent<HTMLInputElement>) {
const checked = e.target.checked
setAutoPush(checked)
localStorage.setItem(AUTO_PUSH_KEY, String(checked))
}
const isBusy = status === 'busy'
return (
@@ -88,6 +135,28 @@ export default function GitSyncWidget() {
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-pdf-path">
<OLFormLabel>{t('git_sync_pdf_path')}</OLFormLabel>
<OLFormControl
type="text"
value={pdfPath}
onChange={e => setPdfPath(e.target.value)}
placeholder={t('git_sync_pdf_path_placeholder')}
disabled={isBusy}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-auto-push">
<FormCheck
type="switch"
id="git-sync-auto-push"
label={t('git_sync_auto_push_on_compile')}
checked={autoPush}
onChange={handleAutoPushToggle}
disabled={isBusy || !remoteUrl.trim()}
/>
</OLFormGroup>
<div className="git-sync-widget-actions">
<OLButton
type="submit"
+1
View File
@@ -139,6 +139,7 @@ export interface Meta {
'ol-gitSyncEnabled': boolean
'ol-gitRemote': string
'ol-gitSyncPath': string
'ol-gitSyncPdfPath': string
'ol-github': { enabled: boolean; error: boolean }
'ol-groupAuditLogs': []
'ol-groupDomains': []
+3
View File
@@ -1050,6 +1050,9 @@
"git_sync_push_success": "Project pushed to git remote successfully.",
"git_sync_remote_url": "Remote URL (with auth token)",
"git_sync_saved": "Settings saved.",
"git_sync_auto_push_on_compile": "Auto-push on successful compile",
"git_sync_pdf_path": "PDF destination path (optional)",
"git_sync_pdf_path_placeholder": "e.g. output/main.pdf (leave blank to skip PDF)",
"git_sync_sub_path": "Subdirectory (optional)",
"git_sync_sub_path_placeholder": "e.g. docs/my-project (leave blank for repo root)",
"git_bridge_modal_review_access": "<0>You have review access to this project.</0> This means you can pull from __appName__ but you cant push any changes you make back to this project.",
+3
View File
@@ -1053,6 +1053,9 @@
"git_sync_push_success": "Projet envoyé vers le dépôt git avec succès.",
"git_sync_remote_url": "URL distante (avec jeton d'auth)",
"git_sync_saved": "Paramètres sauvegardés.",
"git_sync_auto_push_on_compile": "Pousser automatiquement après compilation réussie",
"git_sync_pdf_path": "Chemin de destination du PDF (optionnel)",
"git_sync_pdf_path_placeholder": "ex. output/main.pdf (laisser vide pour ne pas inclure le PDF)",
"git_sync_sub_path": "Sous-dossier (optionnel)",
"git_sync_sub_path_placeholder": "ex. docs/mon-projet (laisser vide pour la racine)",
"git_bridge_modal_review_access": "<0>You have review access to this project.</0> Cela signifie que vous pouvez extraire de __appName__ mais vous ne pouvez pas repousser les modifications que vous apportez à ce projet.",