@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
1,150 lines • 45.8 kB
JavaScript
// ========= 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 git from 'isomorphic-git';
import { RegexError, SearchLimit } from "../../../../rs/errors.js";
import { byteView, encodeText, utf8Locale } from "../../../../shell/bytes.js";
import { LineRegex, posixRegex } from "../../../../rs/regex.js";
import { AmbiguousArgumentError, BadRevisionError, GitError, NoSuchPathError, PatternCompileError, RevisionAndFilenameError, } from "./errors.js";
import { HEAD, TEMPLATE_KINDS } from "./constants.js";
import { isoTimestamp } from "../../../../utils/dates.js";
import { BadDateError, IncompatibleLogOptionsError, InvalidDecorateError, UnrecognizedArgumentError, UsageError, } from "./errors.js";
import { MEDIUM, needsDecorations, parsePretty, } from "./format.js";
import { entries, shows } from "./diff_output.js";
import { HUNK_CONTEXT } from "./patch.js";
import { BRANCH_PREFIX, loadRefs, REMOTE_PREFIX, SYMREF_PREFIX, TAG_PREFIX } from "./refs.js";
import { commitFacts, configBool, repoArgs } from "./repo.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { sha1Hex } from "../../../../utils/hash.js";
import { loadMailmap, mappedIdentity, useMailmap } from "./mailmap.js";
import { dateClock, parseDateMode } from "./dates.js";
import { configValues } from "./fs.js";
import { MARKER, maybeBool, splitMarked, startPoint } from "./util.js";
import { absoluteOperand, pathspecPatterns } from "./pathspec.js";
import { exists } from "./io.js";
import { splitRevisions } from "./revparse.js";
import { PathSpec } from "../../../../types.js";
import { Decoration } from "./types.js";
// How many hidden commits a limited walk takes past the point where only
// hidden ones are queued, git's SLOP.
const SLOP = 5;
// The diff a walk's pickaxe searches when the caller read no diff options: no
// renames paired and merges not diffed.
const PLUMBING_DIFF = {
nameOnly: false,
nameStatus: false,
stat: false,
numstat: false,
shortstat: false,
summary: false,
patch: false,
noPatch: false,
renames: null,
merge: 'off',
raw: false,
abbrev: false,
functionContext: false,
forceText: false,
context: HUNK_CONTEXT,
quotePathFully: true,
pathspecs: [],
};
const BASIC_REGEXP = 'basic_regexp';
const EXTENDED_REGEXP = 'extended_regexp';
const FIXED_STRINGS = 'fixed_strings';
const PERL_REGEXP = 'perl_regexp';
// The pattern syntax switches; the last one on the line wins.
const PATTERN_SYNTAXES = [BASIC_REGEXP, EXTENDED_REGEXP, FIXED_STRINGS, PERL_REGEXP];
const COMMAND_LINE_ORIGIN = 'command line';
const HEADER_ORIGIN = 'header';
/**
* Read a date flag as an epoch second, refusing what it cannot read.
*
* Accepts an ISO-8601 date or a bare epoch second. git accepts far more
* (`2 weeks ago`, `yesterday`); anything else is refused here rather than
* silently ignored, which would quietly widen the window. An empty value is
* now, as git reads a date that says nothing (git 2.47: `--since=` lists no
* past commit).
*/
function timestamp(value, flag, now) {
if (value === null)
return null;
if (value === '')
return now;
const parsed = isoTimestamp(value);
if (parsed !== null)
return parsed;
const asNumber = Number(value);
if (value.trim() !== '' && Number.isFinite(asNumber))
return asNumber;
throw new BadDateError(flag, value);
}
/**
* One end of the date window, from the last of its two spellings: `--after`
* and `--since` (`--before` and `--until`) set one limit, and each occurrence
* overwrites it, so the one typed last wins. Mirrors Python's `_window_end`.
*/
function windowEnd(fl, names, flag, now) {
const last = fl.typedOrder(...names).at(-1);
return timestamp(last === undefined ? null : (fl.asStr(last) ?? null), flag, now);
}
/**
* Read display formats in command-line order, validating every occurrence.
* --oneline sets the format; its abbreviation side effect is read separately.
* Bare --pretty resets to medium, while bare --format is always an error
* (Git 2.50.1).
*/
export function prettyFormat(fl) {
let pretty = MEDIUM;
for (const [key, raw] of fl.occurrences('oneline', 'pretty', 'format')) {
if (key === 'oneline') {
if (raw === true)
pretty = { kind: 'oneline', template: null };
}
else if (typeof raw === 'string') {
pretty = parsePretty(raw);
}
else if (raw === true) {
if (key === 'format')
throw new UnrecognizedArgumentError('--format');
pretty = MEDIUM;
}
}
return pretty;
}
/**
* One `--grep`, `--author` or `--committer` pattern, compiled.
*
* Every syntax uses mirage-rs. Basic and extended expressions keep regcomp's
* dialect; fixed strings and Perl patterns use its line matcher. A refusal
* names the pattern's origin as git does.
*/
function pattern(value, syntax, ignoreCase, origin, utf8) {
try {
if (syntax === PERL_REGEXP || syntax === FIXED_STRINGS) {
return new LineRegex([byteView(value, utf8)], syntax === PERL_REGEXP ? 'perl' : 'fixed', ignoreCase, utf8);
}
return posixRegex(byteView(value, utf8), syntax === EXTENDED_REGEXP, 'posix', ignoreCase, utf8);
}
catch (err) {
if (err instanceof RegexError)
throw new PatternCompileError(origin, value, err.message);
throw err;
}
}
/**
* Whether a `--grep`, `--author` or `--committer` pattern matches `text`. A -P
* search that gives up at PCRE2's match limit is git's fatal, in its words.
*/
async function hits(pattern, text, signal) {
const view = byteView(text, pattern.utf8);
if (!(pattern instanceof LineRegex))
return (await pattern.search(view, 0, signal)) !== null;
try {
return await pattern.matches(view, signal);
}
catch (err) {
if (err instanceof SearchLimit)
throw new GitError('pcre2_jit_match failed with error code -47: match limit exceeded');
throw err;
}
}
/**
* A `-G` or `--pickaxe-regex` pattern, compiled as git's diffcore-pickaxe
* compiles it: POSIX extended whatever -E, -F or -P say, `-i` folding case,
* and matched one line at a time (REG_NEWLINE). Linux regcomp keeps dot off
* NUL, unlike Darwin.
*/
function pickaxePattern(value, ignoreCase, utf8) {
try {
return posixRegex(byteView(value, utf8), true, 'posix', ignoreCase, utf8, true);
}
catch (err) {
if (err instanceof RegexError)
throw new GitError(`invalid regex: ${err.message}`);
throw err;
}
}
export function parseFlags(fl, env = null, signal) {
const oneline = fl.asBool('oneline');
const pretty = prettyFormat(fl);
const graph = fl.asBool('graph');
if (graph && fl.asBool('reverse'))
throw new IncompatibleLogOptionsError('--graph', '--reverse');
let order = graph ? 'topo' : 'default';
for (const name of fl.typedOrder('topo_order', 'date_order')) {
if (fl.asBool(name))
order = name === 'topo_order' ? 'topo' : 'date';
}
const ignoreCase = fl.asBool('regexp_ignore_case');
const utf8 = utf8Locale(env ?? undefined);
let syntax = BASIC_REGEXP;
for (const [key] of fl.occurrences(...PATTERN_SYNTAXES))
syntax = key;
const committers = fl
.asList('committer')
.map((value) => pattern(value, syntax, ignoreCase, HEADER_ORIGIN, utf8));
const authors = fl
.asList('author')
.map((value) => pattern(value, syntax, ignoreCase, HEADER_ORIGIN, utf8));
const greps = fl
.asList('grep')
.flatMap((values) => values
.split('\n')
.map((value) => pattern(value, syntax, ignoreCase, COMMAND_LINE_ORIGIN, utf8)));
const search = fl.asStr('S') ?? null;
const changed = fl.asStr('G') ?? null;
for (const [option, value] of [
['-S', search],
['-G', changed],
]) {
if (value === '')
throw new UsageError('', `error: ${option} requires a non-empty argument\n`);
}
if (search !== null && changed !== null) {
throw new IncompatibleLogOptionsError('-G', '-S', '--find-object');
}
let pickaxe = null;
if (search !== null || changed !== null) {
pickaxe = {
needle: search !== null && fl.asBool('pickaxe_regex')
? pickaxePattern(search, ignoreCase, utf8)
: search,
changed: changed === null ? null : pickaxePattern(changed, ignoreCase, utf8),
ignoreCase,
forceText: fl.asBool('text'),
every: fl.asBool('pickaxe_all'),
...(signal === undefined ? {} : { signal }),
};
}
const maxCount = fl.asInt('max_count') ?? null;
const clock = dateClock(env);
return {
authors,
committers,
mailmap: [],
useMailmap: true,
greps,
ignoreCase,
date: parseDateMode(fl.asStr('date', 'default'), clock),
decorate: Decoration.NONE,
// git reads a negative count as no limit at all.
maxCount: maxCount !== null && maxCount < 0 ? null : maxCount,
minParents: fl.asBool('merges') ? 2 : (fl.asInt('min_parents') ?? null),
maxParents: fl.asBool('no_merges') ? 1 : (fl.asInt('max_parents') ?? null),
firstParent: fl.asBool('first_parent'),
oneline,
reverse: fl.asBool('reverse'),
pickaxe,
since: windowEnd(fl, ['after', 'since'], '--since', clock.now),
until: windowEnd(fl, ['before', 'until'], '--until', clock.now),
allRefs: fl.asBool('all'),
pretty,
abbrevCommit: oneline,
graph,
order,
boundary: fl.asBool('boundary'),
mergeDiffs: fl.asBool('m') || PARENT_DIFFS.has(fl.asStr('diff_merges', 'off')),
pathspecs: [],
...(signal === undefined ? {} : { signal }),
};
}
// The `--diff-merges` values that diff a merge against its parents, one by one
// or the first alone.
const PARENT_DIFFS = new Set(['on', '1', 'm', 'separate', 'first-parent']);
// What makes an operand a pattern git reads as a path unseen.
const WILDCARDS = ['*', '?', '[', '\\'];
/**
* A walk's operands as revisions and the patterns of the paths it is limited
* to, and whether `--` was on the line; see `operands`.
*
* @param ctx the line's context, carrying its argv
* @param fl the verb's flags, for `-C`
* @param repo the opened repository
* @param texts the operands
*/
export async function walkOperands(ctx, fl, repo, texts) {
const argv = ctx.argv ?? [];
const start = startPoint(fl).virtual;
const [leading, marked] = splitMarked(texts, argv);
const dashed = argv.includes(MARKER);
const present = dashed ? new Set() : await presentOperands(repo, start, leading);
const [revisions, paths] = await operands(repo, leading, dashed, present);
return [revisions, pathspecPatterns(repo.location, start, [...paths, ...marked]), dashed];
}
/**
* The commits a walk starts from and hides, as `splitRevisions` reads them,
* except that with `--` on the line a revision that resolves to nothing is a
* bad revision (git 2.47.3).
*
* @param repo the opened repository
* @param revisions the revision operands
* @param dashed whether `--` is on the line
*/
export async function walkEnds(repo, revisions, dashed) {
try {
return await splitRevisions(repo, revisions);
}
catch (err) {
if (!dashed || !(err instanceof AmbiguousArgumentError))
throw err;
throw new BadRevisionError(err.revision);
}
}
/**
* The operands that name something in the working tree, as git looks before it
* reads an operand as a path.
*/
async function presentOperands(repo, start, texts) {
const found = new Set();
for (const text of texts) {
if (text && (await exists(repo.vfs, PathSpec.fromStrPath(absoluteOperand(start, text)))))
found.add(text);
}
return found;
}
/** Whether an operand is a pattern, which git takes as a path without looking for it. */
function wildcard(text) {
return WILDCARDS.some((char) => text.includes(char));
}
/**
* Split the operands before any `--` into revisions and paths.
*
* With `--` on the line they are all revisions. Without it each is a revision
* while it resolves as one, and one that also names a file is refused as
* ambiguous; the first that does not resolve starts the paths, which then have
* to name something in the working tree, as every later operand does, unless it
* is a wildcard pattern (git 2.47.3).
*/
async function operands(repo, leading, dashed, present) {
if (dashed)
return [[...leading], []];
for (const [index, text] of leading.entries()) {
if (!(await resolves(repo, text))) {
if (!present.has(text) && !wildcard(text))
throw new AmbiguousArgumentError(text);
for (const later of leading.slice(index + 1)) {
if (!present.has(later) && !wildcard(later))
throw new NoSuchPathError(later);
}
return [leading.slice(0, index), leading.slice(index)];
}
if (present.has(text))
throw new RevisionAndFilenameError(text);
}
return [[...leading], []];
}
/**
* Whether an operand resolves as a revision, the warnings resolving it gives
* left to the walk, which resolves it again.
*/
async function resolves(repo, text) {
const kept = repo.ambiguous?.length ?? 0;
try {
await splitRevisions(repo, [text]);
return true;
}
catch (err) {
if (err instanceof AmbiguousArgumentError)
return false;
throw err;
}
finally {
repo.ambiguous?.splice(kept);
}
}
/** Whether an isomorphic-git error means "not that object type". */
function isWrongType(err) {
return (typeof err === 'object' && err !== null && err.code === 'ObjectTypeError');
}
/** Follow tag objects down to the commit a ref ultimately names. */
export async function peelToCommit(repo, oid) {
let cursor = oid;
for (;;) {
try {
const { tag } = await git.readTag({ ...repoArgs(repo), oid: cursor });
cursor = tag.object;
}
catch (err) {
// Not a tag object: read it as a commit instead.
if (isWrongType(err))
break;
throw err;
}
}
try {
return await commitFacts(repo, cursor);
}
catch (err) {
// A ref may name a tree or blob, which no log walks from.
if (isWrongType(err))
return null;
throw err;
}
}
/** A ref table with symrefs resolved to the ids they name. */
export async function resolvedRefs(repo) {
const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir);
const out = new Map();
for (const [name, value] of refs) {
const target = value.startsWith(SYMREF_PREFIX)
? refs.get(value.slice(SYMREF_PREFIX.length).trim())
: value;
// A symref to an unborn branch names nothing yet.
if (target !== undefined && !target.startsWith(SYMREF_PREFIX))
out.set(name, target);
}
return out;
}
/** Every commit a ref points at, tags peeled, for `--all`. */
export async function refCommits(repo) {
const refs = await resolvedRefs(repo);
const commits = [];
for (const name of [...refs.keys()].sort(compareCodePoints)) {
const oid = refs.get(name);
if (oid === undefined)
continue;
const commit = await peelToCommit(repo, oid);
if (commit !== null)
commits.push(commit);
}
return commits;
}
/**
* The decoration style a value names: a boolean word or number, `short`, `full`
* or `auto`, which decorates only a terminal and so never here; null for
* anything else.
*
* @param value the `--decorate=` or `log.decorate` value
*/
export function decorationStyle(value) {
const flag = maybeBool(value);
if (flag !== null)
return flag ? Decoration.SHORT : Decoration.NONE;
if (value === 'short')
return Decoration.SHORT;
if (value === 'full')
return Decoration.FULL;
if (value === 'auto')
return Decoration.NONE;
return null;
}
/**
* How a `log` or `show` line labels its commits, as git settles it (pinned
* against git 2.47.3).
*
* `log.decorate` sets the style, a value it cannot read meaning none, and the
* line's `--decorate[=<style>]` and `--no-decorate` override it, the last one
* typed winning; `--pretty=raw` ignores the config. A template that prints
* `%d` or `%D` is decorated even when nothing asked, by short names unless a
* style says full, and one that prints neither loads no labels at all.
*
* @param repo the opened repository
* @param fl the line's flags
* @param pretty the line's format
* @throws InvalidDecorateError a `--decorate` value that names no style
*/
export async function decorationFor(repo, fl, pretty) {
let style = null;
for (const [key, value] of fl.occurrences('decorate', 'no_decorate')) {
if (key === 'no_decorate')
style = Decoration.NONE;
else if (typeof value !== 'string')
style = Decoration.SHORT;
else {
style = decorationStyle(value);
if (style === null)
throw new InvalidDecorateError(value);
}
}
if (style === null && pretty.kind !== 'raw') {
const configured = (await configValues(repo.vfs, repo.location, 'log.decorate')).at(-1);
if (configured !== undefined)
style = decorationStyle(configured);
}
style ??= Decoration.NONE;
if (!TEMPLATE_KINDS.includes(pretty.kind))
return style;
if (!needsDecorations(pretty))
return Decoration.NONE;
return style === Decoration.NONE ? Decoration.SHORT : style;
}
/**
* How `log` and `show` present commits, once the repository's config can be
* read: the worktree `.mailmap`, whether the header identities map through it
* (`log.mailmap` unless the line's `--[no-]mailmap` says otherwise), and the
* decoration style.
*
* @param repo the opened repository
* @param fl the line's flags
* @param pretty the line's format
* @throws BadConfigValueError a `log.mailmap` git cannot read as a boolean
* @throws InvalidDecorateError a `--decorate` value that names no style
*/
export async function displayed(repo, fl, pretty) {
return [
await loadMailmap(repo.vfs, repo.location),
useMailmap(fl, await configBool(repo, 'log.mailmap', true)),
await decorationFor(repo, fl, pretty),
];
}
/**
* Ref labels per commit, in the order git prints them; null for a line that
* prints none.
*
* git walks refs alphabetically and prepends each label, so a commit's labels
* read in reverse ref order; HEAD is pulled to the front, spelled
* `HEAD -> branch` when attached (the branch's own label is absorbed) and
* `HEAD` alone when detached. Pinned against git 2.50.
*
* @param repo the opened repository
* @param style `FULL` keeps each ref's whole name, `NONE` loads nothing;
* anything else shortens it
*/
export async function decorations(repo, style = Decoration.SHORT) {
if (style === Decoration.NONE)
return null;
const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir);
const resolved = await resolvedRefs(repo);
const full = style === Decoration.FULL;
const labels = new Map();
for (const name of [...resolved.keys()].sort(compareCodePoints)) {
if (name === HEAD)
continue;
const oid = resolved.get(name);
if (oid === undefined)
continue;
const commit = await peelToCommit(repo, oid);
if (commit === null)
continue;
const list = labels.get(commit.oid) ?? [];
list.unshift(refLabel(name, full));
labels.set(commit.oid, list);
}
await decorateHead(repo, refs, resolved, labels, full);
return labels;
}
/** One ref's decoration label, in git's spelling, by its short or its full name. */
function refLabel(name, full) {
if (name.startsWith(TAG_PREFIX))
return `tag: ${full ? name : name.slice(TAG_PREFIX.length)}`;
if (full)
return name;
if (name.startsWith(BRANCH_PREFIX))
return name.slice(BRANCH_PREFIX.length);
if (name.startsWith(REMOTE_PREFIX))
return name.slice(REMOTE_PREFIX.length);
return name;
}
/** Prepend the HEAD label, absorbing the attached branch's own. */
async function decorateHead(repo, refs, resolved, labels, full) {
const oid = resolved.get(HEAD);
if (oid === undefined)
return;
const commit = await peelToCommit(repo, oid);
if (commit === null)
return;
const list = labels.get(commit.oid) ?? [];
const raw = refs.get(HEAD) ?? '';
if (raw.startsWith(SYMREF_PREFIX)) {
const branch = refLabel(raw.slice(SYMREF_PREFIX.length).trim(), full);
const at = list.indexOf(branch);
if (at !== -1)
list.splice(at, 1);
list.unshift(`HEAD -> ${branch}`);
}
else {
list.unshift(HEAD);
}
labels.set(commit.oid, list);
}
/**
* git's default history simplification, for a walk limited to paths.
*
* A commit is shown when it is not TREESAME to its parents, its tree and
* theirs holding the same entries at the paths: a root when the paths hold
* anything, a commit with one parent when it changed them, and a merge only
* when no parent is TREESAME to it. A merge TREESAME to a parent leads on to
* the first such parent alone, so history the paths came by some other way
* drops out. A parent the walk hid on its way down does not count, while one it
* was told to hide (`^A`, a range's left end, the merge base of `A...B`) does
* (git 2.47.3). When merges are diffed against each parent (`-m`,
* `--diff-merges=first-parent`) git keeps their whole history instead: a merge
* leads on to every parent and is shown when it differs from any of them, or
* always where `--graph` draws the history. Mirrors _Simplifier in history.py.
*/
class Simplifier {
repo;
patterns;
full;
graph;
bottoms;
views = new Map();
nearest = new Map();
hidden = new Set();
followed = new Map();
shown = new Map();
constructor(repo, patterns, full = false, graph = false, bottoms = new Set()) {
this.repo = repo;
this.patterns = patterns;
this.full = full;
this.graph = graph;
this.bottoms = bottoms;
}
/**
* A digest of the entries one commit holds at the paths, null when they hold
* nothing.
*
* The walk only asks whether two commits hold the same entries, so only the
* digest is kept, and a path log over a long history does not hold every
* file at every commit.
*/
async view(oid) {
let found = this.views.get(oid);
if (found === undefined) {
const tree = (await commitFacts(this.repo, oid)).tree;
const held = await entries(this.repo, tree, true, this.patterns);
const lines = [...held]
.sort(([a], [b]) => compareCodePoints(a, b))
.map(([path, entry]) => `${entry.mode} ${path}\0${entry.oid}`);
found = held.size ? await sha1Hex(encodeText(lines.join(''))) : null;
this.views.set(oid, found);
}
return found;
}
/** Whether two commits hold the same entries at the paths. */
async same(a, b) {
return (await this.view(a)) === (await this.view(b));
}
/**
* Settle whether a walked commit is shown, and the parents it leads on to.
*
* @param commit the commit, just taken off the queue
* @param parents the parents the walk would follow
* @param hidden the commits hidden so far
*/
async follow(commit, parents, hidden) {
this.hidden = hidden;
let followed = parents;
let shown;
const [first] = commit.parents;
if (first === undefined)
shown = (await this.view(commit.oid)) !== null;
else if (commit.parents.length === 1)
shown = !(await this.same(commit.oid, first));
else if (this.full) {
shown = this.graph;
for (const parent of parents)
if (!(await this.same(commit.oid, parent)))
shown = true;
}
else {
let same = null;
for (const parent of parents) {
if (hidden.has(parent) && !this.bottoms.has(parent))
continue;
if (await this.same(commit.oid, parent)) {
same = parent;
break;
}
}
shown = same === null;
if (same !== null)
followed = [same];
}
this.shown.set(commit.oid, shown);
this.followed.set(commit.oid, followed);
return followed;
}
/**
* A shown commit's parents as `--graph` sees them: each followed parent, or
* the nearest shown commits it leads on to when it is not shown itself, a
* hidden one standing for itself.
*/
rewritten(commit) {
const out = new Set();
for (const parent of this.followed.get(commit.oid) ?? []) {
for (const oid of this.shownBelow(parent))
out.add(oid);
}
return [...out];
}
/** The nearest shown commits at or below one, null while not worked out. */
known(oid) {
if (this.shown.get(oid) === true || this.hidden.has(oid))
return [oid];
if (!this.followed.has(oid))
return [];
return this.nearest.get(oid) ?? null;
}
/**
* The nearest shown commits at or below one the walk followed, itself when
* it is shown. Worked out from the bottom on a stack of its own, since a path
* can go unchanged for longer than the engine can recurse.
*/
shownBelow(oid) {
const stack = [oid];
for (let top = stack.at(-1); top !== undefined; top = stack.at(-1)) {
if (this.known(top) !== null) {
stack.pop();
continue;
}
const followed = this.followed.get(top) ?? [];
const waiting = followed.filter((parent) => this.known(parent) === null);
if (waiting.length) {
for (const item of waiting.reverse())
stack.push(item);
continue;
}
const out = new Set();
for (const parent of followed)
for (const below of this.known(parent) ?? [])
out.add(below);
this.nearest.set(top, [...out]);
stack.pop();
}
return this.known(oid) ?? [];
}
}
/**
* Walk history from a set of commits, newest first, along every parent.
*
* Ordered by committer time with ties broken by insertion, which is what a
* git log without `--topo-order` prints. Each commit is visited once however
* many branches reach it.
*
* A hidden commit takes its whole ancestry out of the walk, through every
* parent even under `--first-parent`, which is how git carries a range's
* exclusion. With anything hidden the walk is git's limited one: it holds what
* it finds, so a commit that a later hidden one turns out to reach still drops
* out, and it runs past the point where every queued commit is hidden by
* git's slop of five hidden commits, restarted whenever one is dated no older
* than the last shown one. That slack is what keeps a history whose dates run
* backwards from leaking commits the hidden side reaches late.
*/
async function* walkHistory(repo, starts, firstParent, hidden = [], trace = null, simplify = null) {
const seen = new Set();
const excluded = trace?.hidden ?? new Set();
const visited = new Map();
const queue = [];
const held = [];
const hide = async (oids) => {
const stack = [...oids];
for (let oid = stack.pop(); oid !== undefined; oid = stack.pop()) {
if (excluded.has(oid))
continue;
excluded.add(oid);
const known = visited.get(oid);
if (known !== undefined)
for (const item of known.parents)
stack.push(item);
else if (!seen.has(oid)) {
seen.add(oid);
queue.push(await commitFacts(repo, oid));
}
}
};
for (const commit of hidden) {
if (seen.has(commit.oid))
continue;
seen.add(commit.oid);
excluded.add(commit.oid);
queue.push(commit);
}
for (const start of starts) {
if (seen.has(start.oid))
continue;
seen.add(start.oid);
queue.push(start);
}
const limited = hidden.length > 0;
let slop = SLOP;
let date = Infinity;
while (queue.length > 0) {
queue.sort((a, b) => b.committerTime - a.committerTime);
const next = queue.shift();
if (next === undefined)
break;
visited.set(next.oid, next);
trace?.popped.push(next.oid);
if (excluded.has(next.oid)) {
await hide(next.parents);
if (queue.length === 0)
break;
const newest = queue.reduce((most, commit) => Math.max(most, commit.committerTime), -Infinity);
if (date <= newest || !queue.every((commit) => excluded.has(commit.oid)))
slop = SLOP;
else
slop -= 1;
if (slop === 0)
break;
continue;
}
date = next.committerTime;
let parents = firstParent ? next.parents.slice(0, 1) : next.parents;
if (simplify !== null)
parents = await simplify.follow(next, parents, excluded);
if (limited)
held.push(next);
else
yield next;
for (const parent of parents) {
if (seen.has(parent))
continue;
seen.add(parent);
queue.push(await commitFacts(repo, parent));
}
}
for (const commit of held)
if (!excluded.has(commit.oid))
yield commit;
}
/**
* Order a walk's commits so no parent comes before any of its children.
*
* A commit is ready once every child of it in the list is printed; children
* outside the list do not hold it back, and every parent counts,
* `--first-parent` or not. `topo` prints the commit a printed child made
* ready last before the next tip in walk order, so a merge's last parent's
* line runs to its end before an earlier parent's: git 2.50.1 prints
* `M P2b P2a P1b P1a base` for a merge `M` of `P1b` and `P2b` whatever their
* dates. `date` prints the newest ready commit, ties in the order they became
* ready, the tips first in walk order.
*
* @param list the walk, newest first
* @param order `--topo-order` or `--date-order`
* @param parents the parents to order by, each commit's own when null
*/
function sortCommits(list, order, parents = null) {
const parentsOf = (commit) => parents === null ? commit.parents : (parents.get(commit.oid) ?? []);
const byOid = new Map(list.map((commit) => [commit.oid, commit]));
const unprintedChildren = new Map(list.map((commit) => [commit.oid, 0]));
for (const commit of list) {
for (const parent of parentsOf(commit)) {
const count = unprintedChildren.get(parent);
if (count !== undefined)
unprintedChildren.set(parent, count + 1);
}
}
const tips = list.filter((commit) => unprintedChildren.get(commit.oid) === 0);
const printed = [];
const printCommit = (commit) => {
printed.push(commit);
const freed = [];
for (const parent of parentsOf(commit)) {
const count = unprintedChildren.get(parent);
if (count === undefined)
continue;
unprintedChildren.set(parent, count - 1);
const parentCommit = byOid.get(parent);
if (count === 1 && parentCommit !== undefined)
freed.push(parentCommit);
}
return freed;
};
if (order === 'topo') {
const freed = [];
let upcoming = 0;
for (;;) {
const commit = freed.length > 0 ? freed.pop() : tips[upcoming++];
if (commit === undefined)
return printed;
for (const item of printCommit(commit))
freed.push(item);
}
}
// The ready commits on a heap keyed newest first and then earliest readied,
// so the next one to print is at its top.
const ready = [];
let arrivals = 0;
const before = (a, b) => {
const x = ready[a];
const y = ready[b];
if (x === undefined || y === undefined)
return false;
if (x.commit.committerTime !== y.commit.committerTime) {
return x.commit.committerTime > y.commit.committerTime;
}
return x.arrival < y.arrival;
};
const swap = (a, b) => {
const x = ready[a];
const y = ready[b];
if (x === undefined || y === undefined)
return;
ready[a] = y;
ready[b] = x;
};
const push = (commit) => {
ready.push({ commit, arrival: arrivals++ });
for (let at = ready.length - 1; at > 0 && before(at, (at - 1) >> 1); at = (at - 1) >> 1) {
swap(at, (at - 1) >> 1);
}
};
const pop = () => {
swap(0, ready.length - 1);
const top = ready.pop();
for (let at = 0;;) {
let next = at;
for (const child of [2 * at + 1, 2 * at + 2]) {
if (child < ready.length && before(child, next))
next = child;
}
if (next === at)
break;
swap(at, next);
at = next;
}
return top?.commit;
};
for (const tip of tips)
push(tip);
for (let next = pop(); next !== undefined; next = pop()) {
for (const parent of printCommit(next))
push(parent);
}
return printed;
}
/** Whether a commit's date is inside `--since`/`--until`. */
function inWindow(commit, flags) {
if (flags.since !== null && commit.committerTime < flags.since)
return false;
return flags.until === null || commit.committerTime <= flags.until;
}
/**
* Whether a `--grep` pattern matches the message. git searches the message a
* line at a time, so `^` and `$` anchor to a line, and never the author or
* committer header.
*/
async function messageMatches(message, greps, signal) {
const lines = message.split('\n');
for (const pattern of greps)
for (const line of lines)
if (await hits(pattern, line, signal))
return true;
return false;
}
/**
* Whether an `--author` or `--committer` pattern matches `ident`, the recorded
* `Name <email>`, or `exact`, the same identity as its own bytes when they were
* not UTF-8, once the mailmap (empty under `--no-mailmap`) has mapped it.
*/
async function identMatches(ident, exact, patterns, mailmap, signal) {
// The map is read as text, so the identity is looked up the same way; one it
// leaves alone is matched as its own bytes.
const mapped = mappedIdentity(ident, mailmap);
const subject = mapped === ident ? (exact ?? ident) : mapped;
for (const pattern of patterns)
if (await hits(pattern, subject, signal))
return true;
return false;
}
/**
* Whether a commit passes `--author`, `--grep`, `--merges`, `--no-merges` and
* kin. Several `--author`s or several `--grep`s are alternatives, while an
* `--author` and a `--grep` must both match.
*/
async function filtersPass(commit, flags) {
const mailmap = flags.useMailmap ? flags.mailmap : [];
// A commit whose text was not UTF-8 is matched as its bytes.
const bytes = commit.bytes;
const idents = [
[`${commit.authorName} <${commit.authorEmail}>`, bytes?.author, flags.authors],
[`${commit.committerName} <${commit.committerEmail}>`, bytes?.committer, flags.committers],
];
for (const [ident, exact, patterns] of idents) {
if (patterns.length && !(await identMatches(ident, exact, patterns, mailmap, flags.signal)))
return false;
}
if (flags.greps.length &&
!(await messageMatches(bytes?.message ?? commit.message, flags.greps, flags.signal)))
return false;
if (flags.minParents !== null && commit.parents.length < flags.minParents)
return false;
return !(flags.maxParents !== null &&
flags.maxParents >= 0 &&
commit.parents.length > flags.maxParents);
}
/**
* The commits `--boundary` prints after the walk, in git's order.
*
* A boundary commit is a parent, any parent, of a walked commit that the walk
* did not take itself: a hidden one, one a filter left out, or one past `-n`. A
* pickaxe does not leave a commit out of the walk, so the parents of a commit
* it passed by count too; the paths do.
*
* Under `--date-order` they print newest first. Otherwise they follow the walk:
* each one leaving the queue with no boundary child listed goes to the front,
* and each listed one puts its boundary parents right after itself, in parent
* order, so a run of commits git reaches through one another prints together
* and later runs print first. The ones `-n` cut off print before them all, and
* the ones the walk never reached after, newest first (git 2.47.3; the order
* differs at times where git reaches one only through another's merge).
*
* @param repo repository holding the commits
* @param steps the walked commits
* @param trace the walk's queue order and hidden set
* @param byDate `--date-order`
* @param stopped whether `-n` cut the walk short
* @param parents the parents the walk prints its commits with, each commit's
* own when absent
*/
async function boundaryOf(repo, steps, trace, byDate, stopped, parents) {
const walkedIds = new Set(steps.map((step) => step.commit.oid));
const found = new Map();
for (const step of steps) {
for (const parent of parents.get(step.commit.oid) ?? step.commit.parents) {
if (!walkedIds.has(parent) && !found.has(parent))
found.set(parent, await commitFacts(repo, parent));
}
}
const time = (oid) => found.get(oid)?.committerTime ?? 0;
const newest = (oids) => [...oids].sort((a, b) => time(b) - time(a));
const facts = (oids) => oids.flatMap((oid) => {
const commit = found.get(oid);
return commit === undefined ? [] : [commit];
});
if (byDate)
return facts(newest(found.keys()));
const lastStep = steps[steps.length - 1];
const last = stopped && lastStep !== undefined
? trace.popped.indexOf(lastStep.commit.oid)
: trace.popped.length;
const passed = trace.popped.slice(0, last + 1);
const reached = new Set(passed);
const cut = newest([...found.keys()].filter((oid) => !trace.hidden.has(oid) && !reached.has(oid)));
const cutSet = new Set(cut);
const listed = [];
for (const oid of passed) {
if (found.has(oid) && !cutSet.has(oid) && !listed.includes(oid))
listed.unshift(oid);
const at = listed.indexOf(oid);
if (at === -1)
continue;
for (const parent of found.get(oid)?.parents ?? []) {
if (found.has(parent) && !cutSet.has(parent) && !listed.includes(parent))
listed.splice(at + 1, 0, parent);
}
}
const rest = newest([...found.keys()].filter((oid) => !cutSet.has(oid) && !listed.includes(oid)));
return facts([...cut, ...listed, ...rest]);
}
/**
* The commits a log walks, in the order it walks them.
*
* Order of operations is git's: walk history, drop what the filters reject,
* and cut at `-n` printed commits. A topological or date order needs the whole
* walk first (git's limited walk), and is taken over the commits inside the
* date window before the other filters run. The pickaxe is the one filter that
* leaves a commit in the walk: git still draws it into the graph and only
* declines to print it, which is why `--graph -S` shows `...` rows.
*
* @param repo repository to walk
* @param starts the commits to walk back from; more than one when `--all`
* seeds every ref
* @param flags the parsed invocation
* @param hidden commits whose whole history is left out, the `A` of `A..B`
* @param diff the diff the pickaxe searches: its renames, its merge diff and its paths
*/
export async function walked(repo, starts, flags, hidden = [], diff = PLUMBING_DIFF) {
const steps = [];
const interesting = new Set();
// The parents a path-limited walk prints its commits with: the ones `--graph`
// draws, and for a merge the paths cut down to the parent it follows, which
// only `--boundary` ever prints, that one parent.
const parents = new Map();
if (flags.maxCount === 0)
return { steps, interesting, boundary: [], parents };
const searched = { ...diff, pickaxe: flags.pickaxe };
const trace = flags.boundary ? { popped: [], hidden: new Set() } : null;
const simplify = flags.pathspecs.length
? new Simplifier(repo, flags.pathspecs, flags.mergeDiffs && (diff.merge === 'separate' || diff.merge === 'first-parent'), flags.graph, new Set(hidden.map((commit) => commit.oid)))
: null;
const kept = (commit) => simplify === null || simplify.shown.get(commit.oid) === true;
let source = walkHistory(repo, starts, flags.firstParent, hidden, trace, simplify);
if (flags.order !== 'default') {
// The commits the paths leave out still take their place in the order,
// merges leading only to the parent they follow; they drop out after,
// which is where a log draws its graph from.
const window = [];
for await (const commit of source)
if (inWindow(commit, flags))
window.push(commit);
for (const commit of window) {
if (simplify !== null && flags.graph && kept(commit))
parents.set(commit.oid, simplify.rewritten(commit));
if ((await filtersPass(commit, flags)) && kept(commit))
interesting.add(commit.oid);
}
source = sortCommits(window, flags.order, simplify?.followed ?? null);
}
let printed = 0;
for await (const commit of source) {
if (!inWindow(commit, flags) || !(await filtersPass(commit, flags)) || !kept(commit))
continue;
const shown = await shows(repo, commit, searched);
steps.push({ commit, shown });
if (shown)
printed += 1;
if (flags.maxCount !== null && printed >= flags.maxCount)
break;
}
const stopped = flags.maxCount !== null && printed >= flags.maxCount;
if (trace === null)
return { steps, interesting, boundary: [], parents };
const boundary = [];
const date = flags.order === 'date';
for (const commit of await boundaryOf(repo, steps, trace, date, stopped, parents)) {
if (await shows(repo, commit, searched))
boundary.push(commit);
}
for (const commit of boundary) {
interesting.add(commit.oid);
const followed = simplify?.followed.get(commit.oid);
if (simplify?.shown.get(commit.oid) === false && followed !== undefined)
parents.set(commit.oid, followed);
}
return { steps, interesting, boundary, parents };
}
/**
* The commits a log invocation prints, in the order it prints them.
*
* The walk's printed commits, reversed last when asked: reversing after the
* cut is what makes `-S <name> --reverse` name the commit that introduced a
* string rather than the most recent one to touch it.
*
* @param repo repository to walk
* @param starts the commits to walk back from
* @param flags the parsed invocation
* @param hidden commits whose whole history is left out, the `A` of `A..B`
* @param diff the diff the pickaxe searches
*/
export async function select(repo, starts, flags, hidden = [], diff = PLUMBING_DIFF) {
return inPrintOrder(await walked(repo, starts, flags, hidden, diff), flags);
}
/**
* The commits a walk prints, in the order it prints them: the shown ones, then
* the `--boundary` ones, all of them reversed under `--reverse`.
*
* @param walk the walk
* @param flags the parsed invocation
*/
export function inPrintOrder(walk, flags) {
const selected = walk.steps.filter((step) => step.shown).map((step) => step.commit);
for (const item of walk.boundary)
selected.push(item);
if (flags.reverse)
selected.reverse();
return selected;
}
//# sourceMappingURL=history.js.map