bun run dev can follow a shared branch: fast-forward only, never over local edits
Opt-in with DX_SYNC_REMOTE (plus DX_SYNC_BRANCH, default main, and DX_SYNC_INTERVAL, default 15 s) in .env.local. Every interval the dev server fetches the branch and fast-forwards when this checkout is on it with no modified tracked files and no local commits, so Vite hot-reloads what teammates landed. Otherwise it never merges, stashes or resets: it logs once and reports "main is N commits ahead, pull when ready" at /api/dev-sync for a Studio badge.
This commit is contained in:
parent
a9e742697d
commit
de004b3703
3 changed files with 92 additions and 0 deletions
|
|
@ -2,3 +2,11 @@
|
|||
# on the host page; never bake into the IIFE bundle.
|
||||
VITE_MAPBOX_TOKEN=pk.your_dev_token_here
|
||||
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.
|
||||
# DX_SYNC_REMOTE=origin
|
||||
# DX_SYNC_BRANCH=main
|
||||
# DX_SYNC_INTERVAL=15
|
||||
|
||||
|
|
|
|||
82
internal/dev-sync-plugin.mjs
Normal file
82
internal/dev-sync-plugin.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* internal/dev-sync-plugin.mjs — `bun run dev` follows a shared branch, so a laptop sees teammates' landings.
|
||||
*
|
||||
* WHY: ten people each running `bun run dev` should see what the others landed on main without anyone
|
||||
* typing `git pull`: the dev server fast-forwards and Vite hot-reloads the changed files. It is the local
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* DEV ONLY (`apply: 'serve'`).
|
||||
*/
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
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] })))
|
||||
|
||||
// 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 } })
|
||||
|
||||
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 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}` })
|
||||
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
|
||||
const behind = Number((await git(root, ['rev-list', '--count', `HEAD..${up}`])).out || 0)
|
||||
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}` })
|
||||
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}` : ''
|
||||
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}` })
|
||||
} finally {
|
||||
REG.running = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return {
|
||||
name: 'dx-dev-sync',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/dev-sync', (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
||||
res.end(JSON.stringify(REG.status))
|
||||
})
|
||||
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' }
|
||||
return
|
||||
}
|
||||
const log = server.config.logger
|
||||
log.info(`[dev-sync] following ${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?.()
|
||||
tick()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
|||
import { autoSpaDevPlugin } from './internal/plugin-gallery/dev-plugin.mjs'
|
||||
import { stripShadowCssCommentsPlugin } from './internal/vite-plugin-strip-shadow-css-comments.mjs'
|
||||
import { backendDevPlugin, devBackendProxy } from './internal/backend-dev-plugin.mjs'
|
||||
import { devSyncPlugin } from './internal/dev-sync-plugin.mjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { readdirSync, statSync, existsSync } from 'node:fs'
|
||||
|
|
@ -163,6 +164,7 @@ export default defineConfig(({ command, mode }) => {
|
|||
cssInjectedByJsPlugin(),
|
||||
autoSpaDevPlugin(env.VITE_MAPBOX_TOKEN || process.env.VITE_MAPBOX_TOKEN || ''),
|
||||
backendDevPlugin(__dirname),
|
||||
devSyncPlugin(__dirname, env),
|
||||
],
|
||||
resolve: {
|
||||
alias: { '@map': resolve(__dirname, 'library') },
|
||||
|
|
|
|||
Loading…
Reference in a new issue