From a17d21c0caaf8b29516db59ef1d714a6cdd35d94 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 23 Jun 2026 20:13:37 +0000 Subject: [PATCH] feat(git-sync): add optional subdirectory field 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 --- .../Features/GitSync/GitSyncController.mjs | 28 +++++++++------ .../src/Features/GitSync/GitSyncHandler.mjs | 35 +++++++++++-------- .../Features/Project/ProjectController.mjs | 2 ++ services/web/app/src/models/Project.mjs | 1 + .../web/app/views/project/editor/_meta.pug | 1 + .../web/frontend/extracted-translations.json | 2 ++ .../integrations-panel/git-sync-widget.tsx | 17 +++++++-- services/web/frontend/js/utils/meta.ts | 1 + services/web/locales/en.json | 4 ++- services/web/locales/fr.json | 4 ++- 10 files changed, 66 insertions(+), 29 deletions(-) diff --git a/services/web/app/src/Features/GitSync/GitSyncController.mjs b/services/web/app/src/Features/GitSync/GitSyncController.mjs index cc810702ae..2247546b87 100644 --- a/services/web/app/src/Features/GitSync/GitSyncController.mjs +++ b/services/web/app/src/Features/GitSync/GitSyncController.mjs @@ -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 { diff --git a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs index faa9a6e71d..55fbf55c54 100644 --- a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs +++ b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs @@ -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 } diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 9dfe5f60be..4c461ac771 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -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, diff --git a/services/web/app/src/models/Project.mjs b/services/web/app/src/models/Project.mjs index 8350079c91..49e448843e 100644 --- a/services/web/app/src/models/Project.mjs +++ b/services/web/app/src/models/Project.mjs @@ -109,6 +109,7 @@ export const ProjectSchema = new Schema( ], deferredTpdsFlushCounter: { type: Number }, gitRemote: { type: String }, + gitSyncPath: { type: String }, }, { minimize: false } ) diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index 117319ef30..de67be0243 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 48dd1a55e4..ae967dac9d 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", 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 aa06b4d5db..7e3bc9e2bd 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 @@ -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('save') const [status, setStatus] = useState('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() { /> + + {t('git_sync_sub_path')} + setSubPath(e.target.value)} + placeholder={t('git_sync_sub_path_placeholder')} + disabled={isBusy} + /> + +
You have review access to this project. This means you can pull from __appName__ but you can’t push any changes you make back to this project.", "git_bridge_modal_see_once": "You’ll 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.", "git_bridge_modal_use_previous_token": "If you’re 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.", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index f0a34e9eed..55a81d4548 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -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. 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 n’allez voir ce jeton qu’une seule fois. Pour l’effacer 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 d’aide.", "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 d’informations, lisez notre <0>page d’aide.",