feat(git-sync): auto-push on compile, PDF path, icon fix
Build and Deploy Verso / deploy (push) Has been cancelled
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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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': []
|
||||
|
||||
@@ -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 can’t push any changes you make back to this project.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user