feat(git-sync): add optional subdirectory field
Build and Deploy Verso / deploy (push) Successful in 12m29s

Project files can now be pushed into a subfolder of the target repo
rather than always going to the root. The path is sanitised on the
backend (strips leading/trailing slashes, rejects traversal with ..).
An empty value (default) keeps the existing root behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-23 20:13:37 +00:00
parent a7a569303c
commit a17d21c0ca
10 changed files with 66 additions and 29 deletions
@@ -4,34 +4,42 @@ import { expressify } from '@overleaf/promise-utils'
async function configureGitSync(req, res) {
const projectId = req.params.project_id
const { remoteUrl } = req.body
const { remoteUrl, subPath = '' } = 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@')) {
const trimmedUrl = remoteUrl.trim()
if (!trimmedUrl.startsWith('https://') && !trimmedUrl.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')
// Normalise subPath: strip leading/trailing slashes, reject traversal
const trimmedPath = String(subPath).trim().replace(/^\/+|\/+$/g, '')
if (trimmedPath.includes('..')) {
return res.status(400).json({ error: 'subPath must not contain ..' })
}
await GitSyncHandler.setConfig(projectId, {
remoteUrl: trimmedUrl,
subPath: trimmedPath,
})
logger.debug({ projectId }, 'git sync: config saved')
res.sendStatus(204)
}
async function pushToGit(req, res) {
const projectId = req.params.project_id
const remoteUrl = await GitSyncHandler.getRemote(projectId)
const { remoteUrl, subPath } = 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)
await GitSyncHandler.pushToRemote(projectId, remoteUrl, subPath)
res.sendStatus(204)
} catch (err) {
logger.warn({ err, projectId }, 'git sync: push failed')
@@ -41,8 +49,8 @@ async function pushToGit(req, res) {
async function getGitSyncConfig(req, res) {
const projectId = req.params.project_id
const remoteUrl = await GitSyncHandler.getRemote(projectId)
res.json({ remoteUrl: remoteUrl ?? '' })
const config = await GitSyncHandler.getConfig(projectId)
res.json({ remoteUrl: config.remoteUrl ?? '', subPath: config.subPath })
}
export default {
@@ -29,27 +29,37 @@ async function spawnGit(args, cwd) {
})
}
async function setRemote(projectId, remoteUrl) {
async function setConfig(projectId, { remoteUrl, subPath }) {
await Project.updateOne(
{ _id: new ObjectId(projectId) },
{ $set: { gitRemote: remoteUrl } }
{ $set: { gitRemote: remoteUrl, gitSyncPath: subPath } }
)
}
async function getRemote(projectId) {
const project = await Project.findById(projectId, { gitRemote: 1 }).lean()
return project?.gitRemote ?? null
async function getConfig(projectId) {
const project = await Project.findById(projectId, {
gitRemote: 1,
gitSyncPath: 1,
}).lean()
return {
remoteUrl: project?.gitRemote ?? null,
subPath: project?.gitSyncPath ?? '',
}
}
async function pushToRemote(projectId, remoteUrl) {
async function pushToRemote(projectId, remoteUrl, subPath) {
const tmpDir = await mkdtemp(join(tmpdir(), 'verso-git-'))
logger.debug({ projectId, tmpDir }, 'git sync: writing project to temp dir')
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
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)
const dest = join(fileRoot, rel)
await mkdir(dirname(dest), { recursive: true })
await writeFile(dest, doc.lines.join('\n'))
}
@@ -58,7 +68,7 @@ async function pushToRemote(projectId, remoteUrl) {
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)
const dest = join(fileRoot, rel)
await mkdir(dirname(dest), { recursive: true })
const { stream } =
await HistoryManager.promises.requestBlobWithProjectId(
@@ -74,10 +84,7 @@ async function pushToRemote(projectId, remoteUrl) {
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(['commit', '--allow-empty', '-m', 'Verso sync'], tmpDir)
await spawnGit(['push', '--force', remoteUrl, 'HEAD:main'], tmpDir)
logger.debug({ projectId }, 'git sync: push complete')
@@ -86,4 +93,4 @@ async function pushToRemote(projectId, remoteUrl) {
}
}
export default { setRemote, getRemote, pushToRemote }
export default { setConfig, getConfig, pushToRemote }
@@ -605,6 +605,7 @@ const _ProjectController = {
overleaf: 1,
tokens: 1,
gitRemote: 1,
gitSyncPath: 1,
}),
userIsMemberOfGroupSubscription: sessionUser
? (async () =>
@@ -1025,6 +1026,7 @@ const _ProjectController = {
gitBridgeEnabled: Features.hasFeature('git-bridge'),
gitSyncEnabled: Boolean(Settings.enableGitSync),
gitRemote: project.gitRemote ?? '',
gitSyncPath: project.gitSyncPath ?? '',
wsUrl,
showSupport: Features.hasFeature('support'),
showTemplatesServerPro,
+1
View File
@@ -109,6 +109,7 @@ export const ProjectSchema = new Schema(
],
deferredTpdsFlushCounter: { type: Number },
gitRemote: { type: String },
gitSyncPath: { type: String },
},
{ minimize: false }
)
@@ -16,6 +16,7 @@ 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-gitSyncPath" content=gitSyncPath)
meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain)
//- enable doc hash checking for all projects
//- used in public/js/libs/sharejs.js
@@ -801,6 +801,8 @@
"git_sync_push_success": "",
"git_sync_remote_url": "",
"git_sync_saved": "",
"git_sync_sub_path": "",
"git_sync_sub_path_placeholder": "",
"git_bridge_modal_review_access": "",
"git_bridge_modal_see_once": "",
"git_bridge_modal_use_previous_token": "",
@@ -15,9 +15,9 @@ 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 [remoteUrl, setRemoteUrl] = useState(getMeta('ol-gitRemote') ?? '')
const [subPath, setSubPath] = useState(getMeta('ol-gitSyncPath') ?? '')
const [lastAction, setLastAction] = useState<Action>('save')
const [status, setStatus] = useState<Status>('idle')
const [errorMsg, setErrorMsg] = useState('')
@@ -29,7 +29,7 @@ export default function GitSyncWidget() {
setErrorMsg('')
try {
await postJSON(`/project/${projectId}/git-sync`, {
body: { remoteUrl },
body: { remoteUrl, subPath },
})
setStatus('success')
} catch (err: any) {
@@ -73,6 +73,17 @@ export default function GitSyncWidget() {
/>
</OLFormGroup>
<OLFormGroup controlId="git-sync-sub-path">
<OLFormLabel>{t('git_sync_sub_path')}</OLFormLabel>
<OLFormControl
type="text"
value={subPath}
onChange={e => setSubPath(e.target.value)}
placeholder={t('git_sync_sub_path_placeholder')}
disabled={isBusy}
/>
</OLFormGroup>
<div className="git-sync-widget-actions">
<OLButton
type="submit"
+1
View File
@@ -138,6 +138,7 @@ export interface Meta {
'ol-gitBridgePublicBaseUrl': string
'ol-gitSyncEnabled': boolean
'ol-gitRemote': string
'ol-gitSyncPath': string
'ol-github': { enabled: boolean; error: boolean }
'ol-groupAuditLogs': []
'ol-groupDomains': []
+3 -1
View File
@@ -1049,7 +1049,9 @@
"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_sync_saved": "Settings saved.",
"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 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>.",
+3 -1
View File
@@ -1052,7 +1052,9 @@
"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_sync_saved": "Paramètres sauvegardés.",
"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.",
"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>.",