feat: add pragmatic git sync — push project to external git remote

Adds a "Git Sync" section in the Integrations rail panel that lets
project owners configure an HTTPS remote URL (with embedded auth token)
and force-push all project files as a single commit.

Backend:
- GitSyncHandler: assembles project docs + binary files into a temp dir,
  runs git init/commit/push --force, then cleans up
- GitSyncController: GET/POST /project/:id/git-sync (configure),
  POST /project/:id/git-sync/push (trigger)
- Project model: gitRemote field
- Dockerfile: ensures git is present at runtime
- Env flag: OVERLEAF_ENABLE_GIT_SYNC=true (set in k8s manifest)

Frontend:
- GitSyncWidget: URL input + Save + Push Now buttons, success/error feedback
- Integrations panel: shows widget when gitSyncEnabled
- Rail: shows Integrations tab when gitSyncEnabled (was only gitBridgeEnabled)
- i18n: en + fr translations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-23 20:01:16 +00:00
parent 4f5dad383b
commit 8214ca6121
17 changed files with 351 additions and 1 deletions
+4
View File
@@ -75,6 +75,10 @@ RUN --mount=type=cache,target=/root/.cache \
echo "==== PACK LOGS (all attempts failed) ===="; \
find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \
exit 1
# Ensure git is available for the git sync feature
# -------------------------------------------------
RUN apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
# Copy runit service startup scripts to its location
# --------------------------------------------------
ADD server-ce/runit /etc/service
+2
View File
@@ -58,6 +58,8 @@ const settings = {
allowAnonymousReadAndWriteSharing:
process.env.OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING === 'true',
enableGitSync: process.env.OVERLEAF_ENABLE_GIT_SYNC === 'true',
// Databases
// ---------
+2
View File
@@ -77,6 +77,8 @@ spec:
# them). Public self-registration is also off (CE default).
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
value: "true"
- name: OVERLEAF_ENABLE_GIT_SYNC
value: "true"
# SMTP for password-reset / invite emails. All OVERLEAF_EMAIL_* vars
# are loaded from the optional 'verso-smtp' Secret — its keys must be
# named exactly like these env vars (see the kubectl create secret
@@ -0,0 +1,52 @@
import logger from '@overleaf/logger'
import GitSyncHandler from './GitSyncHandler.mjs'
import { expressify } from '@overleaf/promise-utils'
async function configureGitSync(req, res) {
const projectId = req.params.project_id
const { remoteUrl } = req.body
if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') {
return res.status(400).json({ error: 'remoteUrl is required' })
}
// Rudimentary safety check — allow https:// and git@… URLs only
const trimmed = remoteUrl.trim()
if (!trimmed.startsWith('https://') && !trimmed.startsWith('git@')) {
return res.status(400).json({ error: 'remoteUrl must start with https:// or git@' })
}
await GitSyncHandler.setRemote(projectId, trimmed)
logger.debug({ projectId }, 'git sync: remote URL saved')
res.sendStatus(204)
}
async function pushToGit(req, res) {
const projectId = req.params.project_id
const remoteUrl = await GitSyncHandler.getRemote(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)
res.sendStatus(204)
} catch (err) {
logger.warn({ err, projectId }, 'git sync: push failed')
res.status(500).json({ error: err.message })
}
}
async function getGitSyncConfig(req, res) {
const projectId = req.params.project_id
const remoteUrl = await GitSyncHandler.getRemote(projectId)
res.json({ remoteUrl: remoteUrl ?? '' })
}
export default {
configureGitSync: expressify(configureGitSync),
pushToGit: expressify(pushToGit),
getGitSyncConfig: expressify(getGitSyncConfig),
}
@@ -0,0 +1,89 @@
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { createWriteStream } from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { spawn } from 'node:child_process'
import { pipeline } from 'node:stream/promises'
import logger from '@overleaf/logger'
import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
import HistoryManager from '../History/HistoryManager.mjs'
import { Project } from '../../models/Project.mjs'
import { ObjectId } from '../../infrastructure/mongodb.mjs'
async function spawnGit(args, cwd) {
return new Promise((resolve, reject) => {
const proc = spawn('git', args, {
cwd,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
})
let stderr = ''
proc.stderr?.on('data', d => (stderr += d.toString()))
proc.on('close', code => {
if (code !== 0) {
reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`))
} else {
resolve()
}
})
proc.on('error', reject)
})
}
async function setRemote(projectId, remoteUrl) {
await Project.updateOne(
{ _id: new ObjectId(projectId) },
{ $set: { gitRemote: remoteUrl } }
)
}
async function getRemote(projectId) {
const project = await Project.findById(projectId, { gitRemote: 1 }).lean()
return project?.gitRemote ?? null
}
async function pushToRemote(projectId, remoteUrl) {
const tmpDir = await mkdtemp(join(tmpdir(), 'verso-git-'))
logger.debug({ projectId, tmpDir }, 'git sync: writing project to temp dir')
try {
// 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(tmpDir, 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(tmpDir, rel)
await mkdir(dirname(dest), { recursive: true })
const { stream } =
await HistoryManager.promises.requestBlobWithProjectId(
projectId,
file.hash
)
await pipeline(stream, createWriteStream(dest))
}
// Git operations — force-push a single commit so the remote always
// reflects the current Verso state exactly.
await spawnGit(['init', '-b', 'main'], tmpDir)
await spawnGit(['config', 'user.email', 'verso-sync@localhost'], tmpDir)
await spawnGit(['config', 'user.name', 'Verso Sync'], tmpDir)
await spawnGit(['add', '-A'], tmpDir)
await spawnGit(
['commit', '--allow-empty', '-m', 'Verso sync'],
tmpDir
)
await spawnGit(['push', '--force', remoteUrl, 'HEAD:main'], tmpDir)
logger.debug({ projectId }, 'git sync: push complete')
} finally {
await rm(tmpDir, { recursive: true, force: true })
}
}
export default { setRemote, getRemote, pushToRemote }
@@ -604,6 +604,7 @@ const _ProjectController = {
brandVariationId: 1,
overleaf: 1,
tokens: 1,
gitRemote: 1,
}),
userIsMemberOfGroupSubscription: sessionUser
? (async () =>
@@ -1022,6 +1023,8 @@ const _ProjectController = {
imageNames,
gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl,
gitBridgeEnabled: Features.hasFeature('git-bridge'),
gitSyncEnabled: Boolean(Settings.enableGitSync),
gitRemote: project.gitRemote ?? '',
wsUrl,
showSupport: Features.hasFeature('support'),
showTemplatesServerPro,
+1
View File
@@ -108,6 +108,7 @@ export const ProjectSchema = new Schema(
},
],
deferredTpdsFlushCounter: { type: Number },
gitRemote: { type: String },
},
{ minimize: false }
)
+20
View File
@@ -68,6 +68,7 @@ import { plainTextResponse } from './infrastructure/Response.mjs'
import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs'
import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs'
import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.mjs'
import GitSyncController from './Features/GitSync/GitSyncController.mjs'
const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } =
UnsupportedBrowserMiddleware
@@ -603,6 +604,25 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
ProjectController.updateProjectAdminSettings
)
webRouter.get(
'/project/:project_id/git-sync',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanAdminProject,
GitSyncController.getGitSyncConfig
)
webRouter.post(
'/project/:project_id/git-sync',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanAdminProject,
GitSyncController.configureGitSync
)
webRouter.post(
'/project/:project_id/git-sync/push',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
GitSyncController.pushToGit
)
webRouter.post(
'/project/:Project_id/compile',
RateLimiterMiddleware.rateLimit(rateLimiters.compileProjectHttp, {
@@ -14,6 +14,8 @@ meta(name="ol-wikiEnabled" data-type="boolean" content=settings.proxyLearn)
meta(name="ol-capabilities" data-type="json" content=capabilities)
meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl)
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-compilesUserContentDomain" content=settings.compilesUserContentDomain)
//- enable doc hash checking for all projects
//- used in public/js/libs/sharejs.js
@@ -794,6 +794,13 @@
"git_bridge_modal_git_clone_your_project": "",
"git_bridge_modal_learn_more_about_authentication_tokens": "",
"git_bridge_modal_read_only": "",
"git_sync": "",
"git_sync_description": "",
"git_sync_push_now": "",
"git_sync_pushing": "",
"git_sync_push_success": "",
"git_sync_remote_url": "",
"git_sync_saved": "",
"git_bridge_modal_review_access": "",
"git_bridge_modal_see_once": "",
"git_bridge_modal_use_previous_token": "",
@@ -71,6 +71,7 @@ export const RailLayout = () => {
const { features } = useProjectContext()
const { isRestrictedTokenMember } = useEditorContext()
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
const gitSyncEnabled = getMeta('ol-gitSyncEnabled')
const { isOverleaf } = getMeta('ol-ExposedSettings')
const { view, setLeftMenuShown } = useLayoutContext()
@@ -119,7 +120,7 @@ export const RailLayout = () => {
icon: 'integration_instructions',
title: t('integrations'),
component: <IntegrationsPanel />,
hide: !isOverleaf && !gitBridgeEnabled,
hide: !isOverleaf && !gitBridgeEnabled && !gitSyncEnabled,
},
{
key: 'review-panel',
@@ -148,6 +149,7 @@ export const RailLayout = () => {
isRestrictedTokenMember,
isOverleaf,
gitBridgeEnabled,
gitSyncEnabled,
]
)
@@ -0,0 +1,116 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { postJSON } from '@/infrastructure/fetch-json'
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 MaterialIcon from '@/shared/components/material-icon'
import OLNotification from '@/shared/components/ol/ol-notification'
type Action = 'save' | 'push'
type Status = 'idle' | 'busy' | 'success' | 'error'
export default function GitSyncWidget() {
const { t } = useTranslation()
const projectId = getMeta('ol-project_id')
const initialRemote = getMeta('ol-gitRemote')
const [remoteUrl, setRemoteUrl] = useState(initialRemote ?? '')
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')
setStatus('busy')
setErrorMsg('')
try {
await postJSON(`/project/${projectId}/git-sync`, {
body: { remoteUrl },
})
setStatus('success')
} catch (err: any) {
setStatus('error')
setErrorMsg(err?.data?.error ?? String(err))
}
}
async function handlePush() {
setLastAction('push')
setStatus('busy')
setErrorMsg('')
try {
await postJSON(`/project/${projectId}/git-sync/push`, { body: {} })
setStatus('success')
} catch (err: any) {
setStatus('error')
setErrorMsg(err?.data?.error ?? String(err))
}
}
const isBusy = status === 'busy'
return (
<div className="git-sync-widget">
<div className="git-sync-widget-header">
<MaterialIcon type="merge" />
<span className="git-sync-widget-title">{t('git_sync')}</span>
</div>
<p className="git-sync-widget-description">{t('git_sync_description')}</p>
<form onSubmit={handleSave}>
<OLFormGroup controlId="git-sync-remote-url">
<OLFormLabel>{t('git_sync_remote_url')}</OLFormLabel>
<OLFormControl
type="text"
value={remoteUrl}
onChange={e => setRemoteUrl(e.target.value)}
placeholder="https://user:token@github.com/org/repo.git"
disabled={isBusy}
/>
</OLFormGroup>
<div className="git-sync-widget-actions">
<OLButton
type="submit"
variant="secondary"
size="sm"
disabled={isBusy || !remoteUrl.trim()}
isLoading={isBusy && lastAction === 'save'}
loadingLabel={t('saving')}
>
{t('save')}
</OLButton>
<OLButton
type="button"
variant="primary"
size="sm"
onClick={handlePush}
disabled={isBusy || !remoteUrl.trim()}
isLoading={isBusy && lastAction === 'push'}
loadingLabel={t('git_sync_pushing')}
>
{t('git_sync_push_now')}
</OLButton>
</div>
</form>
{status === 'success' && (
<OLNotification
type="success"
content={
lastAction === 'push'
? t('git_sync_push_success')
: t('git_sync_saved')
}
/>
)}
{status === 'error' && (
<OLNotification type="error" content={errorMsg} />
)}
</div>
)
}
@@ -2,6 +2,8 @@ import { ElementType } from 'react'
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
import { useTranslation } from 'react-i18next'
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
import getMeta from '@/utils/meta'
import GitSyncWidget from './git-sync-widget'
const integrationPanelComponents = importOverleafModules(
'integrationPanelComponents'
@@ -9,10 +11,12 @@ const integrationPanelComponents = importOverleafModules(
export default function IntegrationsPanel() {
const { t } = useTranslation()
const gitSyncEnabled = getMeta('ol-gitSyncEnabled')
return (
<div className="integrations-panel">
<RailPanelHeader title={t('integrations')} />
{gitSyncEnabled && <GitSyncWidget />}
{integrationPanelComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
+2
View File
@@ -136,6 +136,8 @@ export interface Meta {
'ol-galleryTagName': string
'ol-gitBridgeEnabled': boolean
'ol-gitBridgePublicBaseUrl': string
'ol-gitSyncEnabled': boolean
'ol-gitRemote': string
'ol-github': { enabled: boolean; error: boolean }
'ol-groupAuditLogs': []
'ol-groupDomains': []
@@ -99,3 +99,33 @@
color: var(--integrations-panel-description-color);
margin-bottom: 0;
}
.git-sync-widget {
padding: var(--spacing-04);
border-bottom: 1px solid var(--integrations-panel-card-border);
.git-sync-widget-header {
display: flex;
align-items: center;
gap: var(--spacing-02);
margin-bottom: var(--spacing-02);
}
.git-sync-widget-title {
font-size: var(--font-size-02);
font-weight: 600;
color: var(--integrations-panel-color);
}
.git-sync-widget-description {
font-size: var(--font-size-01);
color: var(--integrations-panel-description-color);
margin-bottom: var(--spacing-04);
}
.git-sync-widget-actions {
display: flex;
gap: var(--spacing-02);
margin-top: var(--spacing-02);
}
}
+7
View File
@@ -1043,6 +1043,13 @@
"git_bridge_modal_git_clone_your_project": "Git clone your project by using the link below and a Git authentication token",
"git_bridge_modal_learn_more_about_authentication_tokens": "Learn more about Git integration authentication tokens.",
"git_bridge_modal_read_only": "<strong>You have read-only access to this project.</strong> This means you can pull from __appName__ but you cant push any changes you make back to this project.",
"git_sync": "Git Sync",
"git_sync_description": "Force-push your project files to an external git repository. Include your auth token in the URL (e.g. https://user:token@github.com/org/repo.git).",
"git_sync_push_now": "Push now",
"git_sync_pushing": "Pushing…",
"git_sync_push_success": "Project pushed to git remote successfully.",
"git_sync_remote_url": "Remote URL (with auth token)",
"git_sync_saved": "Remote URL saved.",
"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.",
"git_bridge_modal_see_once": "Youll only see this token once. To delete it or generate a new one, visit Account settings. For detailed instructions and troubleshooting, read our <0>help page</0>.",
"git_bridge_modal_use_previous_token": "If youre prompted for a password, you can use a previously generated Git authentication token. Or you can generate a new one in Account settings. For more support, read our <0>help page</0>.",
+7
View File
@@ -1046,6 +1046,13 @@
"git_bridge_modal_git_clone_your_project": "Git clonez votre projet en utilisant le lien ci-dessous et un jeton d'authentification Git",
"git_bridge_modal_learn_more_about_authentication_tokens": "Apprenez-en davantage sur les jetons dauthentification de lintégration Git.",
"git_bridge_modal_read_only": "<strong>Vous disposez d'un accès en lecture seule à ce projet.</strong> Cela signifie que vous pouvez extraire des données de __appName__, mais que vous ne pouvez pas transférer les modifications que vous apportez à ce projet.",
"git_sync": "Synchronisation Git",
"git_sync_description": "Pousse de force les fichiers du projet vers un dépôt git externe. Incluez votre jeton d'authentification dans l'URL (ex. https://user:token@github.com/org/repo.git).",
"git_sync_push_now": "Pousser maintenant",
"git_sync_pushing": "Envoi en cours…",
"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": "URL distante sauvegardée.",
"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.",
"git_bridge_modal_see_once": "Vous nallez voir ce jeton quune seule fois. Pour leffacer ou pour en générer un nouveau, allez dans Paramètres du compte. Pour des instructions détaillées ou pour résoudre des problèmes, lisez notre <0>page daide</0>.",
"git_bridge_modal_use_previous_token": "Si un mot de passe vous est demandé, vous pouvez utiliser un jeton Git précédent ou en générer un nouveau dans Paramètres du compte. Pour plus dinformations, lisez notre <0>page daide</0>.",