diff --git a/services/web/app/src/Features/GitSync/GitSyncController.mjs b/services/web/app/src/Features/GitSync/GitSyncController.mjs
index 2247546b87..a575b732d0 100644
--- a/services/web/app/src/Features/GitSync/GitSyncController.mjs
+++ b/services/web/app/src/Features/GitSync/GitSyncController.mjs
@@ -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 {
diff --git a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs
index 55fbf55c54..da78b26a91 100644
--- a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs
+++ b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs
@@ -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)
diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs
index 4c461ac771..8410e31870 100644
--- a/services/web/app/src/Features/Project/ProjectController.mjs
+++ b/services/web/app/src/Features/Project/ProjectController.mjs
@@ -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,
diff --git a/services/web/app/src/models/Project.mjs b/services/web/app/src/models/Project.mjs
index 49e448843e..d5d01536d2 100644
--- a/services/web/app/src/models/Project.mjs
+++ b/services/web/app/src/models/Project.mjs
@@ -110,6 +110,7 @@ export const ProjectSchema = new Schema(
deferredTpdsFlushCounter: { type: Number },
gitRemote: { type: String },
gitSyncPath: { type: String },
+ gitSyncPdfPath: { type: String },
},
{ minimize: false }
)
diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug
index de67be0243..8ac7125f0d 100644
--- a/services/web/app/views/project/editor/_meta.pug
+++ b/services/web/app/views/project/editor/_meta.pug
@@ -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
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index ae967dac9d..eaeba66584 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -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": "",
diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx
index c01949b18a..effd63e31a 100644
--- a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx
+++ b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx
@@ -117,7 +117,7 @@ export const RailLayout = () => {
},
{
key: 'integrations',
- icon: 'integration_instructions',
+ icon: 'merge',
title: t('integrations'),
component: ,
hide: !isOverleaf && !gitBridgeEnabled && !gitSyncEnabled,
diff --git a/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx b/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx
index ed4901e213..9e10f63369 100644
--- a/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx
+++ b/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx
@@ -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('save')
const [status, setStatus] = useState('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) {
+ 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() {
/>
+
+ {t('git_sync_pdf_path')}
+ setPdfPath(e.target.value)}
+ placeholder={t('git_sync_pdf_path_placeholder')}
+ disabled={isBusy}
+ />
+
+
+
+
+
+
You have review access to this project.0> This means you can pull from __appName__ but you can’t push any changes you make back to this project.",
diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json
index 55a81d4548..0a7064ee89 100644
--- a/services/web/locales/fr.json
+++ b/services/web/locales/fr.json
@@ -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.",