feat(git-sync): independent toggles for project files and PDF push
Build and Deploy Verso / deploy (push) Successful in 12m48s

Two new boolean fields on the project (gitSyncPushFiles, gitSyncPushPdf,
both default true) let users control what gets pushed independently:
- "Push project files" switch — skip all docs/binary files when off
- "Push compiled PDF" switch — grayed out when no pdfPath is set

The push button and auto-push are disabled when both switches would
result in nothing being pushed. Config is stored in MongoDB so settings
persist per-project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-23 23:21:37 +00:00
parent 9a474f7790
commit be8aef44fe
10 changed files with 92 additions and 31 deletions
@@ -5,7 +5,7 @@ import { expressify } from '@overleaf/promise-utils'
async function configureGitSync(req, res) {
const projectId = req.params.project_id
const { remoteUrl, subPath = '', pdfPath = '' } = req.body
const { remoteUrl, subPath = '', pdfPath = '', pushFiles = true, pushPdf = true } = req.body
if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') {
return res.status(400).json({ error: 'remoteUrl is required' })
@@ -30,6 +30,8 @@ async function configureGitSync(req, res) {
remoteUrl: trimmedUrl,
subPath: trimmedSubPath,
pdfPath: trimmedPdfPath,
pushFiles: Boolean(pushFiles),
pushPdf: Boolean(pushPdf),
})
logger.debug({ projectId }, 'git sync: config saved')
res.sendStatus(204)
@@ -40,7 +42,8 @@ async function pushToGit(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const { buildId, clsiServerId } = req.body ?? {}
const { remoteUrl, subPath, pdfPath } = await GitSyncHandler.getConfig(projectId)
const { remoteUrl, subPath, pdfPath, pushFiles, pushPdf } =
await GitSyncHandler.getConfig(projectId)
if (!remoteUrl) {
return res.status(400).json({ error: 'No git remote configured for this project' })
}
@@ -50,6 +53,8 @@ async function pushToGit(req, res) {
pdfBuildId: buildId,
pdfClsiServerId: clsiServerId,
userId,
pushFiles,
pushPdf,
})
res.sendStatus(204)
} catch (err) {
@@ -82,6 +87,8 @@ async function getGitSyncConfig(req, res) {
remoteUrl: config.remoteUrl ?? '',
subPath: config.subPath,
pdfPath: config.pdfPath,
pushFiles: config.pushFiles,
pushPdf: config.pushPdf,
})
}
@@ -59,10 +59,18 @@ async function spawnGit(args, cwd) {
})
}
async function setConfig(projectId, { remoteUrl, subPath, pdfPath }) {
async function setConfig(projectId, { remoteUrl, subPath, pdfPath, pushFiles, pushPdf }) {
await Project.updateOne(
{ _id: new ObjectId(projectId) },
{ $set: { gitRemote: remoteUrl, gitSyncPath: subPath, gitSyncPdfPath: pdfPath } }
{
$set: {
gitRemote: remoteUrl,
gitSyncPath: subPath,
gitSyncPdfPath: pdfPath,
gitSyncPushFiles: pushFiles,
gitSyncPushPdf: pushPdf,
},
}
)
}
@@ -71,11 +79,15 @@ async function getConfig(projectId) {
gitRemote: 1,
gitSyncPath: 1,
gitSyncPdfPath: 1,
gitSyncPushFiles: 1,
gitSyncPushPdf: 1,
}).lean()
return {
remoteUrl: project?.gitRemote ?? null,
subPath: project?.gitSyncPath ?? '',
pdfPath: project?.gitSyncPdfPath ?? '',
pushFiles: project?.gitSyncPushFiles ?? true,
pushPdf: project?.gitSyncPushPdf ?? true,
}
}
@@ -83,39 +95,41 @@ async function pushToRemote(
projectId,
remoteUrl,
subPath,
{ pdfPath, pdfBuildId, pdfClsiServerId, userId } = {}
{ pdfPath, pdfBuildId, pdfClsiServerId, userId, pushFiles = true, pushPdf = true } = {}
) {
const tmpDir = await mkdtemp(join(tmpdir(), 'verso-git-'))
logger.debug({ projectId, tmpDir, subPath }, 'git sync: writing project to temp dir')
logger.debug({ projectId, tmpDir, subPath, pushFiles, pushPdf }, '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'))
}
if (pushFiles) {
// 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))
// 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
// clsiServerId is optional (absent in single-server CE deployments)
if (pdfPath && pdfBuildId) {
if (pushPdf && pdfPath && pdfBuildId) {
const pdfStream = await ClsiManager.promises.getOutputFileStream(
projectId,
userId,
@@ -607,6 +607,8 @@ const _ProjectController = {
gitRemote: 1,
gitSyncPath: 1,
gitSyncPdfPath: 1,
gitSyncPushFiles: 1,
gitSyncPushPdf: 1,
}),
userIsMemberOfGroupSubscription: sessionUser
? (async () =>
@@ -1029,6 +1031,8 @@ const _ProjectController = {
gitRemote: project.gitRemote ?? '',
gitSyncPath: project.gitSyncPath ?? '',
gitSyncPdfPath: project.gitSyncPdfPath ?? '',
gitSyncPushFiles: project.gitSyncPushFiles ?? true,
gitSyncPushPdf: project.gitSyncPushPdf ?? true,
wsUrl,
showSupport: Features.hasFeature('support'),
showTemplatesServerPro,
+2
View File
@@ -111,6 +111,8 @@ export const ProjectSchema = new Schema(
gitRemote: { type: String },
gitSyncPath: { type: String },
gitSyncPdfPath: { type: String },
gitSyncPushFiles: { type: Boolean },
gitSyncPushPdf: { type: Boolean },
},
{ minimize: false }
)
@@ -18,6 +18,8 @@ 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-gitSyncPushFiles" data-type="boolean" content=gitSyncPushFiles)
meta(name="ol-gitSyncPushPdf" data-type="boolean" content=gitSyncPushPdf)
meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain)
//- enable doc hash checking for all projects
//- used in public/js/libs/sharejs.js
@@ -805,6 +805,8 @@
"git_sync_remote_url": "",
"git_sync_saved": "",
"git_sync_auto_push_on_compile": "",
"git_sync_push_files": "",
"git_sync_push_pdf": "",
"git_sync_pdf_path": "",
"git_sync_pdf_path_placeholder": "",
"git_sync_sub_path": "",
@@ -25,6 +25,8 @@ export default function GitSyncWidget() {
const [remoteUrl, setRemoteUrl] = useState(getMeta('ol-gitRemote') ?? '')
const [subPath, setSubPath] = useState(getMeta('ol-gitSyncPath') ?? '')
const [pdfPath, setPdfPath] = useState(getMeta('ol-gitSyncPdfPath') ?? '')
const [pushFiles, setPushFiles] = useState(getMeta('ol-gitSyncPushFiles') ?? true)
const [pushPdf, setPushPdf] = useState(getMeta('ol-gitSyncPushPdf') ?? true)
const [autoPush, setAutoPush] = useState(
() => localStorage.getItem(AUTO_PUSH_KEY) === 'true'
)
@@ -35,7 +37,6 @@ export default function GitSyncWidget() {
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])
@@ -58,7 +59,7 @@ export default function GitSyncWidget() {
async function saveConfig() {
await postJSON(`/project/${projectId}/git-sync`, {
body: { remoteUrl, subPath, pdfPath },
body: { remoteUrl, subPath, pdfPath, pushFiles, pushPdf },
})
}
@@ -121,6 +122,7 @@ export default function GitSyncWidget() {
}
const isBusy = status === 'busy'
const nothingToPush = !pushFiles && (!pushPdf || !pdfPath.trim())
return (
<div className="git-sync-widget">
@@ -153,6 +155,17 @@ export default function GitSyncWidget() {
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-push-files">
<FormCheck
type="switch"
id="git-sync-push-files"
label={t('git_sync_push_files')}
checked={pushFiles}
onChange={e => setPushFiles(e.target.checked)}
disabled={isBusy}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-pdf-path">
<OLFormLabel>{t('git_sync_pdf_path')}</OLFormLabel>
<OLFormControl
@@ -164,6 +177,17 @@ export default function GitSyncWidget() {
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-push-pdf">
<FormCheck
type="switch"
id="git-sync-push-pdf"
label={t('git_sync_push_pdf')}
checked={pushPdf}
onChange={e => setPushPdf(e.target.checked)}
disabled={isBusy || !pdfPath.trim()}
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-auto-push">
<FormCheck
type="switch"
@@ -171,7 +195,7 @@ export default function GitSyncWidget() {
label={t('git_sync_auto_push_on_compile')}
checked={autoPush}
onChange={handleAutoPushToggle}
disabled={isBusy || !remoteUrl.trim()}
disabled={isBusy || !remoteUrl.trim() || nothingToPush}
/>
</OLFormGroup>
@@ -191,7 +215,7 @@ export default function GitSyncWidget() {
variant="primary"
size="sm"
onClick={handlePush}
disabled={isBusy || !remoteUrl.trim()}
disabled={isBusy || !remoteUrl.trim() || nothingToPush}
isLoading={isBusy && lastAction === 'push'}
loadingLabel={t('git_sync_pushing')}
>
+2
View File
@@ -140,6 +140,8 @@ export interface Meta {
'ol-gitRemote': string
'ol-gitSyncPath': string
'ol-gitSyncPdfPath': string
'ol-gitSyncPushFiles': boolean
'ol-gitSyncPushPdf': boolean
'ol-github': { enabled: boolean; error: boolean }
'ol-groupAuditLogs': []
'ol-groupDomains': []
+2
View File
@@ -1054,6 +1054,8 @@
"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_push_files": "Push project files",
"git_sync_push_pdf": "Push compiled PDF",
"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)",
+2
View File
@@ -1057,6 +1057,8 @@
"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_push_files": "Pousser les fichiers du projet",
"git_sync_push_pdf": "Pousser le PDF compilé",
"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)",