640 lines
18 KiB
JavaScript
640 lines
18 KiB
JavaScript
/**
|
|
* 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<string, unknown>} ctx
|
|
*/
|
|
async function ensureFetchForIsomorphicGit(ctx) {
|
|
await ensureBareFetchGlobals(ctx)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} 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<string, unknown>} ctx
|
|
*/
|
|
function gitAuthor(ctx) {
|
|
const env = /** @type {Record<string, string>} */ (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 <path>] [-C <path>] <command> [args]
|
|
|
|
Commands:
|
|
version, --version Show isomorphic-git version
|
|
init [--bare] Create a repository
|
|
clone <url> [dir] Clone (needs network)
|
|
status Working tree status
|
|
add <pathspec...> Stage files (use "." for all)
|
|
rm <pathspec...> Remove from index and working tree
|
|
commit [-m msg] Create commit
|
|
log [--oneline] [n] History
|
|
branch [-a] [name] List or create branch
|
|
checkout <ref> 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] <branch>
|
|
config [--get] key [value]
|
|
show [ref] Show commit or object
|
|
stash (push|pop|list|apply|drop)
|
|
reset [--hard] [commit]
|
|
cherry-pick <commit>
|
|
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<string, unknown>} 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<string>} */
|
|
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)
|
|
}
|
|
}
|