/** * git dispatcher backed by isomorphic-git (JavaScript implementation, not GNU git). * Requires Node-style fs from createGitFsFromVfs(ctx.vfs). */ import path from '#host-path' import * as git from 'isomorphic-git' import isomorphicGitHttpWeb from 'isomorphic-git/http/web' import { createGitFsFromVfs } from '../vfs/git-fs-adapter.js' import { ensureBareFetchGlobals, importResolvedOrBare, resolveBareOsFetchFn } from '../ctx/bare-os-ensure-bare-fetch.js' /** @type {import('isomorphic-git').HttpClient | null} */ let httpNode = null /** * isomorphic-git/http/web calls bare `fetch`. Node 18+ and browsers have it; * Pear/Bare does not unless we install holepunch's fetch (bare-http1/https). */ /** * @param {Record} ctx */ async function ensureFetchForIsomorphicGit(ctx) { await ensureBareFetchGlobals(ctx) } /** * @param {Record} ctx */ async function getHttp(ctx) { if (httpNode) return httpNode const mode = globalThis.process?.env?.BARE_OS_GIT_HTTP if (mode === 'web') { await ensureFetchForIsomorphicGit(ctx) httpNode = isomorphicGitHttpWeb return httpNode } try { const m = await importResolvedOrBare('isomorphic-git/http/node') httpNode = /** @type {import('isomorphic-git').HttpClient} */ (m.default) return httpNode } catch { await ensureFetchForIsomorphicGit(ctx) if (!resolveBareOsFetchFn(ctx)) { throw new Error( 'git over HTTP needs fetch (Node http client failed and no fetch: global, ctx.bare.fetch, or bare-fetch).' ) } httpNode = isomorphicGitHttpWeb return httpNode } } /** * @param {Record} ctx */ function gitAuthor(ctx) { const env = /** @type {Record} */ (ctx.env || {}) return { name: env.GIT_AUTHOR_NAME || env.USER || 'bare-os', email: env.GIT_AUTHOR_EMAIL || 'nobody@localhost', timestamp: Math.floor(Date.now() / 1000), timezoneOffset: new Date().getTimezoneOffset() } } function printHelp(sink) { sink.log(`bare-os git (isomorphic-git) — not full GNU git. Usage: git [--git-dir ] [-C ] [args] Commands: version, --version Show isomorphic-git version init [--bare] Create a repository clone [dir] Clone (needs network) status Working tree status add Stage files (use "." for all) rm Remove from index and working tree commit [-m msg] Create commit log [--oneline] [n] History branch [-a] [name] List or create branch checkout Switch branch or restore files fetch [remote] Fetch from remote pull [remote] Fetch + merge push [remote] [ref] Push to remote remote [-v] | add | rm | rename tag [list | -a name -m msg] merge [--ff-only] config [--get] key [value] show [ref] Show commit or object stash (push|pop|list|apply|drop) reset [--hard] [commit] cherry-pick clean -fd Remove untracked files (-f required); -d prunes empty dirs hash-object|rev-parse|ls-files|diff (limited) See https://isomorphic-git.org for API behavior.`) } /** * True if argv contains `-x` or a bundled short-flag group like `-fd` (git-style). * @param {string[]} argv * @param {string} letter */ function argvHasShortFlag(argv, letter) { for (const a of argv) { if (a === '-' + letter) return true if (a.startsWith('-') && !a.startsWith('--') && a.length > 1) { if (a.slice(1).includes(letter)) return true } } return false } /** * @param {string[]} args * @returns {{ rest: string[], dir: string, gitdir?: string }} */ function parseGlobalArgs(args) { let dir = null let gitdir const rest = [] for (let i = 0; i < args.length; i++) { const a = args[i] if (a === '-C' && args[i + 1]) { dir = args[++i] continue } if (a.startsWith('-C') && a.length > 2) { dir = a.slice(2) continue } if (a === '--git-dir' && args[i + 1]) { gitdir = args[++i] continue } rest.push(a) } return { rest, dir: dir || '', gitdir } } /** * @param {Record} ctx * @param {string[]} argv */ export async function runGitCli(ctx, argv) { const vfs = ctx.vfs if (!vfs) { ctx.console.error('git: no vfs') return } const fs = createGitFsFromVfs(vfs) const args = argv.slice(1) if (args.length === 0) { printHelp(ctx.console) return } const { rest, dir: cDir, gitdir: optGitdir } = parseGlobalArgs(args) const baseDir = cDir ? vfs.resolveLogical(cDir) : vfs.getcwd() const dir = baseDir const baseOpts = () => ({ fs, dir, ...(optGitdir ? { gitdir: optGitdir } : {}) }) const sub = rest[0] const tail = rest.slice(1) try { if (sub === '--version' || sub === '-v' || sub === 'version') { const v = await git.version() ctx.console.log(`isomorphic-git ${v}`) return } if (sub === 'help' || sub === '--help' || sub === '-h') { if (tail[0]) { ctx.console.log(`git help ${tail[0]}: see isomorphic-git docs`) } else { printHelp(ctx.console) } return } if (sub === 'init') { const bare = tail.includes('--bare') await git.init({ ...baseOpts(), bare, defaultBranch: 'main' }) ctx.console.log( bare ? 'Initialized empty Git repository' : 'Initialized empty Git repository in ' + dir ) return } if (sub === 'clone') { const gpol = String( vfs.env?.BARE_OS_BOOT_POLICY_GIT_PARTIAL_CLONE || '' ) .trim() .toLowerCase() if (gpol === 'deny') { const partial = tail.some((a) => /^--filter(?:=|$)/.test(String(a)) ) if (partial) { ctx.console.error( 'git: partial clone denied (boot.policy gitPartialClonePolicy=deny)' ) ctx.exitCode = 2 return } } const http = await getHttp(ctx) const url = tail[0] if (!url) { ctx.console.error('git clone: missing url') return } let outDir = tail[1] ? vfs.resolveLogical(tail[1]) : null if (!outDir) { try { const u = new URL(url.replace(/\.git\/?$/, '')) const seg = u.pathname.split('/').filter(Boolean).pop() || 'repo' outDir = path.posix.join(dir, seg.replace(/\.git$/, '')) } catch { const m = url.match(/[/:]([^/]+?)(?:\.git)?\/?$/) const seg = (m && m[1]) || 'repo' outDir = path.posix.join(dir, seg.replace(/\.git$/, '')) } } await git.clone({ fs, http, dir: outDir, url, singleBranch: true, depth: 1 }) ctx.console.log('Cloned into ' + outDir) return } if (sub === 'status') { const matrix = await git.statusMatrix({ ...baseOpts() }) if (matrix.length === 0) { ctx.console.log('nothing to show (empty repository)') return } for (const row of matrix) { const [filepath, head, workdir, stage] = row const h = head === 1 ? 'H' : ' ' const w = workdir === 2 ? 'M' : workdir === 1 ? ' ' : '?' const s = stage === 2 ? 'S' : stage === 1 ? ' ' : '?' ctx.console.log(`${h}${w}${s} ${filepath}`) } return } if (sub === 'add') { const paths = tail.filter((t) => !t.startsWith('-')) if (paths.length === 0) paths.push('.') await git.add({ ...baseOpts(), filepath: paths.length === 1 ? paths[0] : paths }) return } if (sub === 'rm') { const paths = tail.filter((t) => !t.startsWith('-')) for (const p of paths) { await git.remove({ ...baseOpts(), filepath: p }) } return } if (sub === 'commit') { let message = '' const t = [...tail] for (let i = 0; i < t.length; i++) { if (t[i] === '-m' && t[i + 1]) { message = t[++i] } else if (t[i].startsWith('-m') && t[i].length > 2) { message = t[i].slice(2) } } if (!message) message = 'commit' const sha = await git.commit({ ...baseOpts(), message, author: gitAuthor(ctx) }) ctx.console.log(`[${dir}] ${sha}`) return } if (sub === 'log') { const oneline = tail.includes('--oneline') let depth const num = tail.find((x) => /^\d+$/.test(x)) if (num) depth = Number.parseInt(num, 10) const commits = await git.log({ ...baseOpts(), depth: depth || undefined }) for (const c of commits) { if (oneline) ctx.console.log( `${c.oid.slice(0, 7)} ${c.commit.message.split('\n')[0]}` ) else { ctx.console.log(`commit ${c.oid}`) ctx.console.log(c.commit.message) } } return } if (sub === 'branch') { if (tail.includes('-a') || tail.includes('--all')) { const local = await git.listBranches({ ...baseOpts() }) for (const b of local) ctx.console.log(b) return } if (tail[0] && !tail[0].startsWith('-')) { await git.branch({ ...baseOpts(), ref: tail[0], checkout: false }) ctx.console.log('Branch ' + tail[0]) return } const branches = await git.listBranches({ ...baseOpts() }) const cur = await git.currentBranch({ ...baseOpts() }) for (const b of branches) { ctx.console.log((b === cur ? '* ' : ' ') + b) } return } if (sub === 'checkout') { const ref = tail.filter((x) => !x.startsWith('-'))[0] if (!ref) { ctx.console.error('git checkout: missing ref') return } await git.checkout({ ...baseOpts(), ref }) return } if (sub === 'fetch') { const http = await getHttp(ctx) const remote = tail[0] || 'origin' await git.fetch({ ...baseOpts(), http, remote }) ctx.console.log('fetch: done') return } if (sub === 'pull') { const http = await getHttp(ctx) const remote = tail[0] || 'origin' await git.pull({ ...baseOpts(), http, remote, author: gitAuthor(ctx) }) ctx.console.log('pull: done') return } if (sub === 'push') { const http = await getHttp(ctx) const remote = tail[0] || 'origin' const ref = tail[1] await git.push({ ...baseOpts(), http, remote, ref }) ctx.console.log('push: done') return } if (sub === 'remote') { if (tail[0] === 'add' && tail[1] && tail[2]) { await git.addRemote({ ...baseOpts(), remote: tail[1], url: tail[2] }) return } if (tail[0] === 'remove' || tail[0] === 'rm') { await git.deleteRemote({ ...baseOpts(), remote: tail[1] }) return } if (tail[0] === 'rename' && tail[1] && tail[2]) { ctx.console.error('git remote rename: use deleteRemote + addRemote') return } const list = await git.listRemotes({ ...baseOpts() }) const verbose = tail.includes('-v') || tail.includes('-vv') for (const r of list) { ctx.console.log(verbose ? `${r.remote}\t${r.url}` : r.remote) } return } if (sub === 'tag') { if (tail[0] === '-a' && tail[1]) { let msg = '' const i = tail.indexOf('-m') if (i !== -1 && tail[i + 1]) msg = tail[i + 1] await git.tag({ ...baseOpts(), ref: tail[1], message: msg }) return } const tags = await git.listTags({ ...baseOpts() }) for (const t of tags) ctx.console.log(t) return } if (sub === 'merge') { const ffOnly = tail.includes('--ff-only') const branch = tail.filter((x) => !x.startsWith('-'))[0] if (!branch) { ctx.console.error('git merge: missing branch/ref') return } const r = await git.merge({ ...baseOpts(), ours: 'HEAD', theirs: branch, fastForwardOnly: ffOnly, author: gitAuthor(ctx) }) ctx.console.log(JSON.stringify(r)) return } if (sub === 'config') { const positional = tail.filter((x) => !x.startsWith('-')) const cfgPath = positional[0] const val = positional[1] if (!cfgPath) { ctx.console.error('git config: missing key') return } if (val === undefined || tail.includes('--get')) { const v = await git.getConfig({ ...baseOpts(), path: cfgPath }) ctx.console.log(v != null ? String(v) : '') return } await git.setConfig({ ...baseOpts(), path: cfgPath, value: val }) return } if (sub === 'show') { const ref = tail[0] || 'HEAD' const oid = await git.resolveRef({ ...baseOpts(), ref }) const { commit } = await git.readCommit({ ...baseOpts(), oid }) ctx.console.log(commit.message) return } if (sub === 'stash') { const raw = tail[0] const op = raw === 'push' || raw === 'pop' || raw === 'list' || raw === 'drop' || raw === 'apply' ? raw : 'push' const msg = (raw === 'push' && tail[1] && !tail[1].startsWith('-') ? tail[1] : null) || (raw === 'save' && tail[1] && !tail[1].startsWith('-') ? tail[1] : null) || 'WIP' if (op === 'push' || raw === 'save') { await git.stash({ ...baseOpts(), op: 'push', message: msg }) return } if (op === 'pop') { await git.stash({ ...baseOpts(), op: 'pop' }) return } if (op === 'list') { const s = await git.stash({ ...baseOpts(), op: 'list' }) ctx.console.log(JSON.stringify(s)) return } if (op === 'drop' || op === 'apply') { await git.stash({ ...baseOpts(), op }) return } ctx.console.error('git stash: use push | pop | list | apply | drop') return } if (sub === 'reset') { const hard = tail.includes('--hard') const ref = tail.filter((x) => !x.startsWith('-')).pop() || 'HEAD' await git.resetIndex({ ...baseOpts(), filepath: '.', ref: hard ? ref : undefined }) ctx.console.log('reset (limited): see isomorphic-git resetIndex') return } if (sub === 'cherry-pick') { const c = tail.filter((x) => !x.startsWith('-'))[0] if (!c) { ctx.console.error('git cherry-pick: missing commit oid') return } const oid = await git.expandOid({ ...baseOpts(), oid: c }) await git.cherryPick({ ...baseOpts(), oid }) return } if (sub === 'clean') { const force = tail.includes('-f') || tail.includes('--force') || argvHasShortFlag(tail, 'f') const dirs = tail.includes('-d') || argvHasShortFlag(tail, 'd') if (!force) { ctx.console.error( 'git clean: refusing without -f (would remove untracked files)' ) return } const matrix = await git.statusMatrix({ ...baseOpts() }) /** Untracked `??` per isomorphic-git statusMatrix (HEAD absent, WORKDIR present, stage absent). */ const untracked = matrix.filter( ([_fp, head, workdir, stage]) => head === 0 && workdir === 2 && stage === 0 ) if (untracked.length === 0) { ctx.console.log('Nothing to clean') return } const rels = untracked .map(([fp]) => fp) .sort((a, b) => b.length - a.length) /** @type {Set} */ const parents = new Set() for (const rel of rels) { const full = path.posix.join(dir, rel) try { const st = await fs.promises.stat(full) if (st.isDirectory()) { if (dirs) { await fs.promises.rm(full, { recursive: true, force: true }) let parent = path.posix.dirname(rel) while (parent && parent !== '.' && parent !== '/') { parents.add(parent) parent = path.posix.dirname(parent) } } } else { await fs.promises.unlink(full) let parent = path.posix.dirname(rel) while (parent && parent !== '.' && parent !== '/') { parents.add(parent) parent = path.posix.dirname(parent) } } } catch (e) { ctx.console.error('git clean: ' + rel + ': ' + (e?.message || e)) } } if (dirs && parents.size) { const ordered = [...parents].sort( (a, b) => b.split('/').length - a.split('/').length ) for (const p of ordered) { const full = path.posix.join(dir, p) try { const names = await fs.promises.readdir(full) if (names && names.length === 0) await fs.promises.rmdir(full) } catch { /* not empty or gone */ } } } ctx.console.log(`Removed ${rels.length} untracked path(s)`) return } if (sub === 'rev-parse') { const ref = tail.filter((x) => !x.startsWith('-'))[0] || 'HEAD' const oid = await git.resolveRef({ ...baseOpts(), ref }) ctx.console.log(oid) return } if (sub === 'hash-object') { const w = tail.includes('-w') const file = tail.filter((x) => !x.startsWith('-')).pop() if (!file) { ctx.console.error('git hash-object: missing file') return } const full = path.posix.isAbsolute(file) ? file : path.posix.join(dir, file) const buf = await fs.promises.readFile(full) const u8 = buf instanceof Buffer ? new Uint8Array(buf) : buf const { oid } = await git.hashBlob({ object: u8 }) if (w) await git.writeBlob({ ...baseOpts(), blob: u8 }) ctx.console.log(oid) return } if (sub === 'ls-files') { const files = await git.listFiles({ ...baseOpts() }) for (const f of files) ctx.console.log(f) return } if (sub === 'diff') { ctx.console.error( 'git diff: use statusMatrix or external tools; full diff not wired in bare-os git' ) return } ctx.console.error( `git: '${sub}' is not supported by bare-os (isomorphic-git wrapper). Try: git help` ) } catch (e) { const msg = e?.message || String(e) ctx.console.error('git: ' + msg) } }