From be8aef44fe336846fdbe8f0403ef9832872b63fe Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 23 Jun 2026 23:21:37 +0000 Subject: [PATCH] feat(git-sync): independent toggles for project files and PDF push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Features/GitSync/GitSyncController.mjs | 11 +++- .../src/Features/GitSync/GitSyncHandler.mjs | 64 +++++++++++-------- .../Features/Project/ProjectController.mjs | 4 ++ services/web/app/src/models/Project.mjs | 2 + .../web/app/views/project/editor/_meta.pug | 2 + .../web/frontend/extracted-translations.json | 2 + .../integrations-panel/git-sync-widget.tsx | 32 ++++++++-- services/web/frontend/js/utils/meta.ts | 2 + services/web/locales/en.json | 2 + services/web/locales/fr.json | 2 + 10 files changed, 92 insertions(+), 31 deletions(-) diff --git a/services/web/app/src/Features/GitSync/GitSyncController.mjs b/services/web/app/src/Features/GitSync/GitSyncController.mjs index bc32f65d24..4c238c6f17 100644 --- a/services/web/app/src/Features/GitSync/GitSyncController.mjs +++ b/services/web/app/src/Features/GitSync/GitSyncController.mjs @@ -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, }) } diff --git a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs index 7ce536ded2..ff6d344b4c 100644 --- a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs +++ b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs @@ -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, diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 8410e31870..db63adb77d 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -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, diff --git a/services/web/app/src/models/Project.mjs b/services/web/app/src/models/Project.mjs index d5d01536d2..6f1dd49bc9 100644 --- a/services/web/app/src/models/Project.mjs +++ b/services/web/app/src/models/Project.mjs @@ -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 } ) diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index 8ac7125f0d..febcb4f057 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4667a32cda..02d6785198 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", 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 02ff4f30ef..4c526b8954 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 @@ -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 (
@@ -153,6 +155,17 @@ export default function GitSyncWidget() { /> + + setPushFiles(e.target.checked)} + disabled={isBusy} + /> + + {t('git_sync_pdf_path')} + + setPushPdf(e.target.checked)} + disabled={isBusy || !pdfPath.trim()} + /> + + @@ -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')} > diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 6066178e76..46de00e982 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -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': [] diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 58554abd2e..46dd7e0b18 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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)", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 27137ce0c9..77a1adab0b 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -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)",