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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ---------
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -108,6 +108,7 @@ export const ProjectSchema = new Schema(
|
||||
},
|
||||
],
|
||||
deferredTpdsFlushCounter: { type: Number },
|
||||
gitRemote: { type: String },
|
||||
},
|
||||
{ minimize: false }
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 can’t 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 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>.",
|
||||
|
||||
@@ -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 d’authentification de l’inté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 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