UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

762 lines • 34 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { PathSpec } from "../../../../types.js"; import git from 'isomorphic-git'; import { HEAD } from "./constants.js"; import { dateClock } from "./dates.js"; import { configValues } from "./fs.js"; import { compareCodePoints } from "../../../../utils/sort.js"; import { IOResult } from "../../../../io/types.js"; import { FlagView } from "../../../spec/flag_view.js"; import { AmbiguousArgumentError, AmbiguousObjectNameError, BranchExistsError, BranchNameRequiredError, BranchPointError, CheckedOutBranchError, GitError, InvalidBranchNameError, InvalidObjectNameError, NoBranchError, RefDeleteReadOnlyError, RefLockError, RefReadOnlyError, UnmergedBranchError, UsageError, } from "./errors.js"; import { short } from "./format.js"; import { readOptional, writeFile } from "./io.js"; import { BRANCH_PREFIX, blockingRef, deleteRef, loadRefs, mapped, parseRefspec, readHead, REMOTE_PREFIX, SYMREF_PREFIX, validBranchName, writeRef, } from "./refs.js"; import { filterWords, refFilter, withoutFilterValues } from "./ref_filter.js"; import { parseField } from "./ref_fields.js"; import { displayWidth, formatRefs, listingFormat, sortRefs, usedFields, ValueTable, } from "./ref_format.js"; import { configuredSort, headDescription, knownNames, listingResult, matchAsPath, matchShort, reached, refListing, resolveRef, rootNames, sortKeys, trackingRef, worktreeHeads, } from "./ref_list.js"; import { configBool, repoArgs } from "./repo.js"; import { opened } from "./session.js"; import { COMMIT, noteAmbiguity, refsNamed, resolveCommit, resolveObject, unwrapped, } from "./revparse.js"; import { append, entry, IDENTITY, logged, ZERO } from "./reflog.js"; import { RefKind, Track, } from "./types.js"; import { checkSwitches, configSection, gitBool, verbUsage, withoutSection, requireVfs, } from "./util.js"; import { systemNow } from "../../../../utils/clock.js"; const ENC = new TextEncoder(); const DEC = new TextDecoder(); const NEWLINE = 0x0a; const REMOTE = 'remotes/'; const AUTO_SETUP_MERGE = 'branch.autosetupmerge'; const TRACK_WORDS = new Map([ ['always', Track.ALWAYS], ['simple', Track.SIMPLE], ['inherit', Track.INHERIT], ]); const LOCAL_REMOTE = '.'; const GLOB_CHARS = ['*', '?', '[']; const UPSTREAM_KEYS = new Set(['remote', 'merge']); // The order git 2.56.0's --delete-merged deletes in, as pinned on up to 60 // branches: names fall in FNV-1 buckets by their hash, 64 at first and four // times as many once more than 80% are taken, the latest at each one's head. const FNV_BASIS = 0x811c9dc5; const FNV_PRIME = 0x01000193; const HASH_BUCKETS = 64; const HASH_GROWTH = 4; const HASH_LOAD = 80; /** * Refuse a start point two refs answer to, as git's branch creation does while * `core.warnAmbiguousRefs` is on, after warning that it is ambiguous. With no * start point git starts from the current branch by its short name, so that * name is the one checked. * * @param repo the opened repository * @param name the start point as typed, or the current branch's short name * @param warned whether reading it already put the warning on the list */ export async function refuseAmbiguous(repo, name, warned) { if (!(await configBool(repo, 'core.warnAmbiguousRefs', true))) return; const table = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir); if (refsNamed(table, name).length <= 1) return; if (!warned) await noteAmbiguity(repo, name); throw new AmbiguousObjectNameError(name); } /** Point a new branch at a commit, refusing to move an existing one. */ async function create(vfs, repo, refs, name, start, mode, head) { // Before the start point resolves, which is git's order here and the // opposite of switch's. A ref is a path below .git, so an unchecked name // reaches writeRef as one. if (!validBranchName(name)) throw new InvalidBranchNameError(name); const ref = `${BRANCH_PREFIX}${name}`; if (refs.has(ref)) throw new BranchExistsError(name); // With no start point git starts from the current branch by its short name, // or from HEAD when it is detached, and it is that name the refusals and the // new branch's log speak of. const from = start ?? head.branch ?? HEAD; let found; try { found = await resolveObject(repo, from); } catch (err) { if (err instanceof AmbiguousArgumentError) throw new InvalidObjectNameError(from); throw err; } await refuseAmbiguous(repo, from, true); const commit = await unwrapped(repo, found, from); if (commit.type !== COMMIT) throw new BranchPointError(commit.oid, commit.type, from); const oid = commit.oid; // Last, as it is for git: a ref whose path another ref already holds fails // when the lock is taken, so a bad start point is reported first. const held = blockingRef(new Set(refs.keys()), ref); if (held !== null) throw new RefLockError(ref, held); await writeRef(vfs, repo.location.commondir, ref, oid); const log = repo.location.commondir.join('logs', ref); if (await logged(vfs, repo.location, ref, log)) { const when = Math.floor(systemNow()); const line = entry(ZERO, oid, IDENTITY, when, `branch: Created from ${from}`); await append(vfs, repo.location.commondir, `logs/${ref}`, line); } return setUpTracking(repo, name, start ?? null, mode, head); } /** * The one remote-tracking branch a missing branch name guesses at: * `checkout <name>` and `switch <name>` with no such branch create it from * `<remote>/<name>` when exactly one remote has one. */ export async function remoteBranch(repo, name) { const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir); const depth = 3 + name.split('/').length - 1; const found = [...refs.keys()].filter((ref) => ref.startsWith(REMOTE_PREFIX) && ref.endsWith(`/${name}`) && ref.split('/').length - 1 === depth); return found.length === 1 ? (found[0] ?? null) : null; } /** * `branch.autoSetupMerge` from the repository's config. * * The words are case-sensitive and anything else reads as a boolean, so * `never` or `Always` is a bad boolean. git reads the variable before any * command runs, so a verb that can create a branch reads it first and fails * with nothing written (pinned against git 2.50.1). */ export async function trackMode(repo) { let mode = Track.REMOTE; for (const value of await configValues(repo.vfs, repo.location, AUTO_SETUP_MERGE)) { mode = TRACK_WORDS.get(value) ?? (gitBool([value], AUTO_SETUP_MERGE, true) ? Track.REMOTE : Track.OFF); } return mode; } /** * The upstream a new branch takes from its start ref, as git picks it. * * Returns the remote (`.` for a local branch, null for no upstream), the merge * refs, what the note puts before each one, and a warning. git names a local * upstream bare when it picked the branch itself but `./<branch>` when it * copied the remote from another branch. */ async function tracked(repo, ref, branch, mode) { const values = (path) => configValues(repo.vfs, repo.location, path); if (ref.startsWith(BRANCH_PREFIX)) { const source = ref.slice(BRANCH_PREFIX.length); if (mode === Track.ALWAYS) return ['.', [ref], '', '']; if (mode !== Track.INHERIT) return [null, [], '', '']; const remotes = await values(`branch.${source}.remote`); const merges = await values(`branch.${source}.merge`); const missing = !remotes.length ? 'no remote is set' : !merges.length ? 'no merge configuration is set' : ''; if (missing) return [null, [], '', `warning: asked to inherit tracking from '${source}', but ${missing}\n`]; const remote = remotes.at(-1) ?? '.'; return [remote, merges, `${remote}/`, '']; } if (!ref.startsWith(REMOTE_PREFIX)) return [null, [], '', '']; const rest = ref.slice(REMOTE_PREFIX.length); const slash = rest.indexOf('/'); const remote = rest.slice(0, slash); const name = rest.slice(slash + 1); if (slash < 0 || !name || name === HEAD) return [null, [], '', '']; if (!(await values(`remote.${remote}.url`)).length) return [null, [], '', '']; if (mode === Track.INHERIT) return [ null, [], '', `warning: asked to inherit tracking from '${ref}', but no remote is set\n`, ]; if (mode === Track.SIMPLE && name !== branch) return [null, [], '', '']; return [remote, [`${BRANCH_PREFIX}${name}`], `${remote}/`, '']; } /** * Record a new branch's upstream as `branch.autoSetupMerge` asks. * * A start point naming `<remote>/<branch>` of a configured remote writes * `branch.<new>.remote` and `.merge` unless the mode is `false`; `always` also * tracks a local branch through remote `.`, and no start point means the * branch HEAD was on. Returns the note git prints on stdout and any warning for * stderr (pinned against git 2.47.3 and 2.50.1). * * @param head HEAD before the branch was made, which a missing start point * stands for */ export async function setUpTracking(repo, branch, start, mode, head) { if (mode === Track.OFF) return ['', '']; const typed = start ?? HEAD; let ref; if (typed === HEAD) ref = head.ref; else { const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir); ref = refsNamed(refs, typed)[0]; } if (ref === null || ref === undefined) return ['', '']; const [remote, merges, prefix, warning] = await tracked(repo, ref, branch, mode); if (remote === null) return ['', warning]; const path = repo.location.commondir.join('config'); const data = (await readOptional(repo.vfs, path)) ?? new Uint8Array(); const section = configSection('branch', branch, [ ['remote', remote], ...merges.map((merge) => ['merge', merge]), ]); const tail = data.length && data[data.length - 1] !== NEWLINE ? '\n' : ''; await writeFile(repo.vfs, path, new Uint8Array([...data, ...ENC.encode(tail + section)])); const labels = merges.map((merge) => `${prefix}${merge.replace(/^refs\/heads\//, '')}`); if (labels.length === 1) return [`branch '${branch}' set up to track '${labels[0] ?? ''}'.\n`, '']; return [ `branch '${branch}' set up to track:\n${labels.map((label) => ` ${label}\n`).join('')}`, '', ]; } /** * The commit HEAD resolves to, null on an unborn branch. * * HEAD carries an object id only when detached; attached it names a ref, which * is unset until the first commit. */ export function headCommit(refs, head) { if (head.commit !== null) return head.commit; if (head.ref === null) return null; return refs.get(head.ref) ?? null; } /** * Whether HEAD already holds every commit a branch points at. * * An unborn HEAD holds nothing, which is git's answer too: on an orphan branch * every other branch reads as unmerged. The equality case is handled here * because isomorphic-git's `isDescendent` answers false for a commit compared * with itself, by its own documented choice. * * Only HEAD is consulted. git also accepts a branch contained in its own * upstream, and there are no remotes here to have one. */ async function merged(repo, tip, head) { if (head === null) return false; if (head === tip) return true; return await git.isDescendent({ ...repoArgs(repo), oid: head, ancestor: tip, depth: -1 }); } /** Remove a branch, refusing when the removal would lose commits. */ async function remove(vfs, repo, refs, head, name, force) { const ref = `${BRANCH_PREFIX}${name}`; const sha = refs.get(ref); if (sha === undefined) throw new NoBranchError(name); if (name === head.branch) throw new CheckedOutBranchError(name, repo.location.worktree.virtual); if (!force && !(await merged(repo, sha, headCommit(refs, head)))) { throw new UnmergedBranchError(name); } await drop(repo, name); return `Deleted branch ${name} (was ${short(sha, repo.abbrev)}).\n`; } /** Remove a branch's ref and its settings. */ async function drop(repo, name) { await deleteRef(repo.vfs, repo.location.commondir, `${BRANCH_PREFIX}${name}`); await rewriteConfig(repo, name, null); } /** * Take a branch's settings out of the config, all of them or the variables * `keys` names, lowercase. */ async function rewriteConfig(repo, name, keys) { const path = repo.location.commondir.join('config'); const data = await readOptional(repo.vfs, path); if (data === null) return; const text = DEC.decode(data); const dropped = withoutSection(text, 'branch', name, keys); if (dropped !== text) await writeFile(repo.vfs, path, ENC.encode(dropped)); } /** * What `--forked` and `--delete-merged` patterns select, resolved: each * pattern, a glob or the full ref it names, and whether it is a glob. * * A pattern holding a glob character stays a glob, matched as a path (`*` * stops at a `/`) against an upstream's short name, case and `--ignore-case` * notwithstanding; any other names a branch by git's rev-parse rules, its * symbolic refs followed, so `origin` reads `refs/remotes/origin/HEAD` and * `HEAD` the current branch. Pinned against git 2.56.0. * * @throws GitError a pattern names no branch */ export function upstreamPatterns(table, known, patterns) { return patterns.map((pattern) => { if (GLOB_CHARS.some((ch) => pattern.includes(ch))) return [pattern, true]; let named = refsNamed(known, pattern === '@' ? HEAD : pattern)[0]; const seen = new Set(); while (named !== undefined && !seen.has(named)) { seen.add(named); const raw = table.get(named) ?? ''; if (!raw.startsWith(SYMREF_PREFIX)) break; named = raw.slice(SYMREF_PREFIX.length).trim(); } if (named === undefined || !(named.startsWith(BRANCH_PREFIX) || named.startsWith(REMOTE_PREFIX))) throw new GitError(`'${pattern}' is not a valid branch or pattern`); return [named, false]; }); } /** Whether resolved patterns select an upstream ref, given in full. */ export function selects(selected, upstream) { const prefix = [BRANCH_PREFIX, REMOTE_PREFIX].find((p) => upstream.startsWith(p)); const name = prefix === undefined ? upstream : upstream.slice(prefix.length); return selected.some(([text, glob]) => (glob ? matchAsPath(name, [text]) : text === upstream)); } /** * Whether pushing a branch to its remote would move its upstream. * * The first of the remote's push refspecs naming the branch in full with a * destination decides where it lands, the branch's own name when the remote * has none, and nowhere when none matches; the fetch refspecs then map that to * a local ref (git 2.56.0). */ export async function pushesUpstream(repo, branch, upstream) { const values = (key) => configValues(repo.vfs, repo.location, `remote.${upstream.remote}.${key}`); const source = `${BRANCH_PREFIX}${branch}`; const pushed = (await values('push')).map((text) => parseRefspec(text)); let target = source; if (pushed.length) { target = null; for (const spec of pushed) { if (!spec.dst || spec.src.startsWith('^')) continue; const found = mapped(spec, source); if (found) { target = found; break; } } } if (target === null) return false; for (const text of await values('fetch')) { const spec = parseRefspec(text); if (spec.src.startsWith('^')) continue; const landed = mapped(spec, target); if (landed) return landed === upstream.ref; } return false; } /** The order --delete-merged deletes the names in, given in turn. */ export function hashOrder(names) { let size = HASH_BUCKETS; let buckets = Array.from({ length: size }, () => []); names.forEach((name, index) => { buckets[fnv1(name) & (size - 1)]?.unshift(name); if (index + 1 > Math.floor((size * HASH_LOAD) / 100)) { size *= HASH_GROWTH; const grown = Array.from({ length: size }, () => []); for (const each of buckets.flat()) grown[fnv1(each) & (size - 1)]?.unshift(each); buckets = grown; } }); return buckets.flat(); } function fnv1(name) { let value = FNV_BASIS; for (const byte of ENC.encode(name)) value = (Math.imul(value, FNV_PRIME) ^ byte) >>> 0; return value; } /** * Delete the branches already merged into the upstream they track. * * A local branch goes when its upstream matches a pattern, exists and holds the * branch's tip, unless it is checked out in a worktree, pushing it would move * that very upstream, or `branch.<name>.deleteMerged` is false, which is said * on stderr. A branch another one not going tracks through `.` stays, judged * against the first choice, and a branch that tracked one that went loses its * upstream. Operands narrow the branches looked at, by their short names and * case. Pinned against git 2.56.0. */ async function deleteMerged(repo, patterns, operands, dryRun, quiet) { const table = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir); const known = knownNames(table, await rootNames(repo)); const selected = upstreamPatterns(table, known, patterns); const checked = await worktreeHeads(repo); const branches = [...table.keys()] .filter((name) => name.startsWith(BRANCH_PREFIX)) .sort(compareCodePoints); const ups = new Map(); for (const ref of branches) ups.set(ref, await trackingRef(repo, ref.slice(BRANCH_PREFIX.length), known)); let skipped = ''; const chosen = []; for (const ref of branches) { const name = ref.slice(BRANCH_PREFIX.length); const up = ups.get(ref) ?? null; const [tip] = resolveRef(table, ref); if (tip === null || up === null || checked.has(ref) || !matchShort(ref, operands) || !selects(selected, up.ref)) continue; const [there] = resolveRef(table, up.ref); if (there === null || (await pushesUpstream(repo, name, up))) continue; if (!(await merged(repo, tip, there))) continue; const values = await configValues(repo.vfs, repo.location, `branch.${name}.deletemerged`); if (!gitBool(values, `branch.${name}.deletemerged`, true)) { skipped += `Skipping '${name}' (branch.${name}.deleteMerged is false)\n`; continue; } chosen.push(ref); } const held = new Set(); for (const [ref, up] of ups) if (up !== null && up.remote === LOCAL_REMOTE && !chosen.includes(ref)) held.add(up.ref); const going = chosen.filter((ref) => !held.has(ref)); let out = ''; for (const name of hashOrder(going.map((ref) => ref.slice(BRANCH_PREFIX.length)))) { const [tip, target] = resolveRef(table, `${BRANCH_PREFIX}${name}`); const was = target ?? short(tip ?? '', repo.abbrev); if (dryRun) { out += `Would delete branch ${name} (was ${was}).\n`; continue; } await drop(repo, name); out += `Deleted branch ${name} (was ${was}).\n`; } if (!dryRun) { for (const [ref, up] of ups) if (up !== null && up.remote === LOCAL_REMOTE && going.includes(up.ref) && !going.includes(ref)) await rewriteConfig(repo, ref.slice(BRANCH_PREFIX.length), UPSTREAM_KEYS); } if (quiet) return [null, new IOResult()]; return [ out === '' ? null : ENC.encode(out), new IOResult({ stderr: skipped === '' ? null : ENC.encode(skipped) }), ]; } // What a listing row reads when the line gives no --format. const ROW_ID = parseField('objectname:short'); const ROW_SUBJECT = parseField('contents:subject'); const ROW_TRACK = parseField('upstream:track'); const ROW_UPSTREAM = parseField('upstream:short'); const ROW_WORKTREE = parseField('worktreepath'); const ROW_TARGET = parseField('symref:short'); /** * The fields `branch` rows read with `verbose` `-v`. * * @param verbose how many `-v` */ export function rowFields(verbose) { if (!verbose) return [ROW_WORKTREE, ROW_TARGET]; return [ ROW_WORKTREE, ROW_TARGET, ROW_ID, ROW_SUBJECT, ROW_TRACK, ...(verbose > 1 ? [ROW_UPSTREAM] : []), ]; } /** * A row's name: `main`, `remotes/origin/main` (`origin/main` under `-r`), or * the detached HEAD's description. * * @param item the listed ref * @param ctx carries the detached HEAD's description * @param prefix what a remote-tracking branch is labelled with */ function rowLabel(item, ctx, prefix) { if (item.kind === RefKind.DETACHED) return ctx.headDescription; if (item.kind === RefKind.REMOTE) return prefix + item.name.slice(REMOTE_PREFIX.length); return item.name.startsWith(BRANCH_PREFIX) ? item.name.slice(BRANCH_PREFIX.length) : item.name; } /** * The upstream bracket of a verbose row, with its trailing space. * * git 2.50.1 prints `[ahead 1, behind 2]` or `[gone]` under `-v` and nothing * for a branch level with its upstream; `-vv` names the upstream first, * `[origin/main: ahead 1]`, `[origin/main]`. * * @param item the listed ref * @param table the listing's field values * @param verbose how many `-v` */ function tracking(item, table, verbose) { const upstream = item.upstream; if (item.kind !== RefKind.BRANCH || upstream === null) return ''; const state = upstream.gone ? 'gone' : [ ['ahead', upstream.ahead], ['behind', upstream.behind], ] .filter(([, count]) => count > 0) .map(([word, count]) => `${word} ${String(count)}`) .join(', '); if (verbose === 1) return state ? `[${state}] ` : ''; const name = table.value(item, ROW_UPSTREAM).text; return state ? `[${name}: ${state}] ` : `[${name}] `; } /** * A branch listing as git prints it when the line gives no `--format`. * * Each row is two columns of marker, `*` for the branch HEAD is on (or a * detached HEAD) and `+` for one another worktree has out, then the name; a * symbolic ref adds ` -> ` and its target. `-v` pads every name to the widest, * by display width, and follows it with the abbreviated id, the upstream * bracket and the subject, so an empty subject leaves the id's space behind. A * remote-tracking symbolic ref keeps only its ` -> ` target there, while a * local one reads as the branch it names. `-vv` also names the upstream, and * puts `(<path>)` after the id of a branch another worktree has out. * * @param items the listed refs, in name order * @param ctx the listing's facts * @param keys the sort keys, null for `--no-sort` * @param verbose how many `-v` * @param prefix what a remote-tracking branch is labelled with, `remotes/` * unless only those are listed * @param icase `--ignore-case` */ export function branchRows(items, ctx, keys, verbose, prefix, icase) { const table = new ValueTable([...rowFields(verbose), ...(keys ?? []).map((key) => key.field)], ctx); const rows = []; try { const ordered = keys ? sortRefs(items, keys, table, ctx, icase, true) : [...items]; const labels = ordered.map((item) => rowLabel(item, ctx, prefix)); const width = Math.max(0, ...labels.map(displayWidth)); ordered.forEach((item, at) => { const label = labels[at] ?? ''; const current = item.kind === RefKind.DETACHED || item.name === ctx.head; const elsewhere = item.kind === RefKind.BRANCH && item.worktree !== ''; const marker = current ? '* ' : elsewhere ? '+ ' : ' '; const target = item.symref !== null ? table.value(item, ROW_TARGET).text : ''; if (!verbose) { rows.push(`${marker}${label}${target ? ` -> ${target}` : ''}\n`); return; } const padded = label + ' '.repeat(width - displayWidth(label)); if (target && item.kind === RefKind.REMOTE) { rows.push(`${marker}${padded} -> ${target}\n`); return; } const where = verbose > 1 && elsewhere && !current ? `(${item.worktree}) ` : ''; rows.push(`${marker}${padded} ${table.value(item, ROW_ID).text} ${where}` + `${tracking(item, table, verbose)}${table.value(item, ROW_SUBJECT).text}\n`); }); } catch (err) { if (err instanceof GitError) return [rows.join(''), err]; throw err; } return [rows.join(''), null]; } /** * A branch listing, through git's ref-filter as git prints one: the local * branches (`-r` the remote-tracking ones instead, `-a` both) and a detached * HEAD, which leads whatever the sort keys say, each printed through `--format` * or as `branchRows` lays rows out. `--forked` keeps the local branches whose * upstream it selects (see `upstreamPatterns`). */ async function listBranches(ctx, fl, repo, head, patterns, filter, remotesOnly, includeRemotes, forked) { let followers = null; if (forked.length) { const table = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir); const known = knownNames(table, await rootNames(repo)); const selected = upstreamPatterns(table, known, forked); const kept = new Set(); for (const ref of table.keys()) { if (!ref.startsWith(BRANCH_PREFIX)) continue; const up = await trackingRef(repo, ref.slice(BRANCH_PREFIX.length), known); if (up !== null && selects(selected, up.ref)) kept.add(ref); } followers = kept; } const keys = sortKeys(fl, await configuredSort(repo, 'branch')); const icase = fl.asBool('ignore_case'); const verbose = fl.asInt('verbose') ?? 0; const prefix = remotesOnly ? '' : REMOTE; const template = fl.asStr('format'); const detached = head.commit !== null && !remotesOnly; const wanted = (name) => ((name === HEAD && detached) || (!remotesOnly && name.startsWith(BRANCH_PREFIX)) || (includeRemotes && name.startsWith(REMOTE_PREFIX))) && matchShort(name, patterns, icase) && (followers === null || followers.has(name)); const fmt = template !== undefined ? listingFormat(template) : null; const [items, found, errors] = await refListing(repo, fmt !== null ? usedFields(fmt, keys ?? []) : [...rowFields(verbose), ...(keys ?? []).map((key) => key.field)], wanted, filter, dateClock(ctx.env)); let refCtx = found; if (items.some((item) => item.kind === RefKind.DETACHED)) refCtx = { ...refCtx, headDescription: await headDescription(repo, head) }; const [out, stopped] = fmt === null ? branchRows(items, refCtx, keys, verbose, prefix, icase) : formatRefs(fmt, items, refCtx, keys, { omitEmpty: fl.asBool('omit_empty'), icase, detachedFirst: true, stream: false, }); return listingResult(out, errors, stopped); } /** * List, create or delete branches. * * A name operand creates a branch, `-d` deletes one, and neither lists them with * the checked-out one marked. `-d` deletes only a branch HEAD already contains, * and `-D` deletes one regardless, which is git's own split and the reason both * are here: without `-D` there is nothing `-d` can refuse to do. `-r` lists * remote-tracking branches instead of local ones and `-a` lists both; local * names sort together and remotes follow. * * `-l` and the ref filters (`--contains`, `--merged`, `--points-at` and their * negations) make the line a listing whose operands are name patterns, so * `git branch --contains side topic` lists `topic` if it holds `side` rather * than creating anything, and a line that also deletes names two modes, which * git answers with its usage. `--forked` lists too, and `--delete-merged` is a * mode of its own whose operands narrow the branches it looks at; `--dry-run` * belongs to it alone. */ export async function branch(_accessor, paths, texts, ctx) { const words = filterWords(ctx); const operands = withoutFilterValues(texts, words); const fl = new FlagView(ctx.flags); const remotesOnly = fl.asBool('r'); const includeRemotes = remotesOnly || fl.asBool('a'); const forked = fl.asList('forked'); const cleanup = fl.asList('delete_merged'); const dryRun = fl.typedOrder('dry_run', 'no_dry_run').at(-1) === 'dry_run'; const listing = words.length > 0 || fl.asBool('list') || forked.length > 0; const vfs = requireVfs(ctx); checkSwitches(ctx, operands); const repo = await opened(ctx, fl); if (dryRun && !cleanup.length) throw new GitError('--dry-run requires --delete-merged'); if (cleanup.length) { if (listing || fl.asBool('delete') || fl.asBool('D') || fl.asBool('show_current')) throw new UsageError('', verbUsage(ctx)); return await deleteMerged(repo, cleanup, operands, dryRun, fl.asBool('quiet')); } const mode = await trackMode(repo); const filter = await refFilter(repo, words); const refs = await loadRefs(vfs, repo.location.gitdir, repo.location.commondir); const head = await readHead(vfs, repo.location.gitdir); const force = fl.asBool('D'); if (fl.asBool('show_current')) { if (listing || force || fl.asBool('delete')) throw new UsageError('', verbUsage(ctx)); return [ENC.encode(head.branch ? head.branch + '\n' : ''), new IOResult()]; } if (fl.asBool('delete') || force) { if (listing) throw new UsageError('', verbUsage(ctx)); if (operands.length === 0) throw new BranchNameRequiredError(); const parts = []; for (const name of operands) parts.push(await remove(vfs, repo, refs, head, name, force)); if (fl.asBool('quiet')) return [null, new IOResult()]; return [ENC.encode(parts.join('')), new IOResult()]; } const first = operands[0]; if (first !== undefined && !listing) { const [tracking, warning] = await create(vfs, repo, refs, first, operands[1], mode, head); return [ tracking && !fl.asBool('quiet') ? ENC.encode(tracking) : null, new IOResult({ stderr: ENC.encode(warning) }), ]; } return await listBranches(ctx, fl, repo, head, listing ? operands : [], filter, remotesOnly, includeRemotes, forked); } /** A branch's upstream from `branch.<name>.remote` and `.merge`. */ export async function upstreamOf(repo, branch, tip) { const remote = (await configValues(repo.vfs, repo.location, `branch.${branch}.remote`)).at(-1); const merge = (await configValues(repo.vfs, repo.location, `branch.${branch}.merge`)).at(-1); if (remote === undefined || merge === undefined) return null; const tracked = remote === '.' ? merge : `${REMOTE_PREFIX}${remote}/${merge.replace(/^refs\/heads\//, '')}`; const label = tracked.replace(/^refs\/(heads|remotes)\//, ''); const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir); if (!refs.has(tracked)) return { label, ahead: 0, behind: 0, gone: true }; const ours = await reached(repo, tip); const theirs = await reached(repo, await resolveCommit(repo, tracked)); return { label, ahead: [...ours].filter((id) => !theirs.has(id)).length, behind: [...theirs].filter((id) => !ours.has(id)).length, gone: false, }; } /** The current branch's upstream, null when it has none or no commits. */ export async function branchUpstream(repo, head, noCommits) { if (head.branch === null || noCommits) return null; const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir); if (!refs.has(`${BRANCH_PREFIX}${head.branch}`)) return null; const tip = await resolveCommit(repo, `${BRANCH_PREFIX}${head.branch}`); return upstreamOf(repo, head.branch, tip); } /** branch's refusal by a read-only mount: the lock on the ref it creates or deletes. */ export const branchReadOnly = (texts, ctx, location) => { const fl = new FlagView(ctx.flags); const ref = `${BRANCH_PREFIX}${texts[0] ?? ''}`; const path = (location?.commondir ?? PathSpec.fromStrPath('/.git')).join(ref); if (fl.asBool('delete') || fl.asBool('D')) return new RefDeleteReadOnlyError(ref, path.virtual); return new RefReadOnlyError(ref, path.virtual); }; //# sourceMappingURL=branch.js.map