feat(git-sync): add optional subdirectory field
Build and Deploy Verso / deploy (push) Successful in 12m29s
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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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': []
|
||||
|
||||
@@ -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 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</0>.",
|
||||
"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</0>.",
|
||||
|
||||
@@ -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 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</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 d’informations, lisez notre <0>page d’aide</0>.",
|
||||
|
||||
Reference in New Issue
Block a user