Follow a remote: DX_SYNC_REMOTE also takes a repo URL, and checks every 5 s by default

One switch makes a laptop hot-reload what someone pushes elsewhere (e.g. cloud agents): a URL needs no
`git remote add`. Credentials in a https://user:token@ URL are never shown in the log or /api/dev-sync.
This commit is contained in:
Doxa Maps 2026-09-27 23:34:58 +00:00
parent 7b49aef938
commit b919371b26
2 changed files with 20 additions and 13 deletions

View file

@ -5,10 +5,10 @@ VITE_API_BASE_URL=https://pray.doxa.life
# Follow a shared branch while `bun run dev` runs (internal/dev-sync-plugin.mjs): fetch every N seconds and
# fast-forward when this checkout is on that branch with no local edits; otherwise only report it at
# /api/dev-sync. Leave DX_SYNC_REMOTE unset to turn it off.
# /api/dev-sync. DX_SYNC_REMOTE is a remote name (origin) or a repo URL; leave it unset to turn this off.
# DX_SYNC_REMOTE=origin
# DX_SYNC_BRANCH=main
# DX_SYNC_INTERVAL=15
# DX_SYNC_INTERVAL=5
# PocketBase for plugins (internal/backend-dev-plugin.mjs): `bun run dev` downloads the pinned binary once
# and serves it at /api/pb, data in .pb/dev. DX_PB_URL uses a shared PocketBase instead of a local one.

View file

@ -6,8 +6,10 @@
* form of the cloud container's pull loop (deployment/cloud/entry.sh), MINUS the `reset --hard`: a laptop
* holds someone's unsaved work, so this NEVER merges over local edits, never stashes, never resets.
*
* OPT-IN, in .env.local: DX_SYNC_REMOTE=origin DX_SYNC_BRANCH=main DX_SYNC_INTERVAL=15
* (DX_SYNC_REMOTE unset = off). Every interval it fetches <remote> <branch> and fast-forwards ONLY when
* OPT-IN, ONE switch in .env.local: DX_SYNC_REMOTE = a remote name (origin) OR a repo URL (https://…/x.git),
* e.g. to watch what cloud agents push. DX_SYNC_BRANCH defaults to main, DX_SYNC_INTERVAL to 5 seconds.
* Unset = off: plain hot reload of local edits, as always. Every interval it fetches <remote> <branch>
* (a URL needs no `git remote add`; private repos use your normal git credentials) and fast-forwards ONLY when
* this checkout is on that branch, no tracked file is modified, and `git merge --ff-only` agrees.
* Otherwise it logs once per new upstream commit ("main is N commits ahead, pull when ready") and reports
* the state at /api/dev-sync for a Studio badge.
@ -20,6 +22,10 @@ const git = (root, args) => new Promise((ok) => execFile('git', args,
{ cwd: root, timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } },
(e, out, err) => ok({ ok: !e, out: String(out || '').trim(), err: String(err || e?.message || '').trim().split('\n')[0] })))
// What the log and /api/dev-sync show: never the credentials of a https://user:token@host/… URL.
const shown = (remote) => remote.replace(/\/\/[^/@]*@/, '//')
const label = (remote, branch) => (remote.includes('/') ? `${branch} of ${shown(remote)}` : `${remote}/${branch}`)
// One timer per `bun run dev`, kept across Vite's own restarts (the config module is re-imported then).
const REG = (globalThis.__dxDevSync ||= { timer: null, running: false, status: { enabled: false } })
@ -27,9 +33,10 @@ export async function syncOnce(root, remote, branch, log = { info() {}, warn() {
if (REG.running) return REG.status
REG.running = true
try {
const s = { enabled: true, remote, branch, checkedAt: new Date().toISOString() }
const src = label(remote, branch)
const s = { enabled: true, remote: shown(remote), branch, checkedAt: new Date().toISOString() }
const f = await git(root, ['fetch', '-q', remote, branch])
if (!f.ok) return (REG.status = { ...s, state: 'fetch-failed', message: `could not fetch ${remote}/${branch}: ${f.err}` })
if (!f.ok) return (REG.status = { ...s, state: 'fetch-failed', message: `could not fetch ${src}: ${shown(f.err)}` })
const up = (await git(root, ['rev-parse', 'FETCH_HEAD'])).out
const head = (await git(root, ['rev-parse', 'HEAD'])).out
const cur = (await git(root, ['symbolic-ref', '-q', '--short', 'HEAD'])).out
@ -37,18 +44,18 @@ export async function syncOnce(root, remote, branch, log = { info() {}, warn() {
const ahead = Number((await git(root, ['rev-list', '--count', `${up}..HEAD`])).out || 0)
const dirty = (await git(root, ['status', '--porcelain', '--untracked-files=no'])).out !== ''
Object.assign(s, { upstream: up, behind, ahead, dirty, currentBranch: cur || '(detached)' })
if (behind === 0) return (REG.status = { ...s, state: 'up-to-date', message: `up to date with ${remote}/${branch}` })
if (behind === 0) return (REG.status = { ...s, state: 'up-to-date', message: `up to date with ${src}` })
const why = cur !== branch ? `this checkout is on ${cur || 'a detached HEAD'}, not ${branch}`
: dirty ? 'you have local edits'
: ahead ? `you have ${ahead} local commit(s) that are not on ${remote}/${branch}` : ''
: ahead ? `you have ${ahead} local commit(s) that are not on ${src}` : ''
const m = why ? null : await git(root, ['merge', '--ff-only', '-q', up])
if (why || !m.ok) {
const message = `${branch} is ${behind} commit(s) ahead; ${why || `fast-forward refused (${m.err})`}, so nothing was merged. Pull when ready.`
if (REG.status.upstream !== up || REG.status.state !== 'behind') log.warn(`[dev-sync] ${message}`)
return (REG.status = { ...s, state: 'behind', message })
}
log.info(`[dev-sync] fast-forwarded ${head.slice(0, 8)} → ${up.slice(0, 8)} (${behind} commit(s) from ${remote}/${branch})`)
return (REG.status = { ...s, behind: 0, state: 'up-to-date', lastMerge: s.checkedAt, message: `fast-forwarded ${behind} commit(s) from ${remote}/${branch}` })
log.info(`[dev-sync] fast-forwarded ${head.slice(0, 8)} → ${up.slice(0, 8)} (${behind} commit(s) from ${src})`)
return (REG.status = { ...s, behind: 0, state: 'up-to-date', lastMerge: s.checkedAt, message: `fast-forwarded ${behind} commit(s) from ${src}` })
} finally {
REG.running = false
}
@ -57,7 +64,7 @@ export async function syncOnce(root, remote, branch, log = { info() {}, warn() {
export function devSyncPlugin(root, env = process.env) {
const remote = env.DX_SYNC_REMOTE || ''
const branch = env.DX_SYNC_BRANCH || 'main'
const every = Math.max(5, Number(env.DX_SYNC_INTERVAL) || 15)
const every = Math.max(2, Number(env.DX_SYNC_INTERVAL) || 5)
return {
name: 'dx-dev-sync',
apply: 'serve',
@ -68,11 +75,11 @@ export function devSyncPlugin(root, env = process.env) {
})
clearInterval(REG.timer)
if (!remote) {
REG.status = { enabled: false, message: 'off: set DX_SYNC_REMOTE (e.g. origin) in .env.local to follow a shared branch' }
REG.status = { enabled: false, message: 'off: set DX_SYNC_REMOTE (a remote name like origin, or a repo URL) in .env.local to follow a shared branch' }
return
}
const log = server.config.logger
log.info(`[dev-sync] following ${remote}/${branch} every ${every}s (fast-forward only, never over local edits)`)
log.info(`[dev-sync] following ${label(remote, branch)} every ${every}s (fast-forward only, never over local edits)`)
const tick = () => syncOnce(root, remote, branch, log)
REG.timer = setInterval(tick, every * 1000)
REG.timer.unref?.()