@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
168 lines • 7.26 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 { IOResult } from "../../../../io/types.js";
import { FlagView } from "../../../spec/flag_view.js";
import { resolveCommit } from "./revparse.js";
import { opened } from "./session.js";
import { checkOperands, maybeBool } from "./util.js";
import { readOptional, writeFile } from "./io.js";
import { HEAD } from "./constants.js";
import { isBare } from "./discover.js";
import { configValues } from "./fs.js";
export const LOGS_DIR = 'logs';
const HEAD_LOG = 'logs/HEAD';
export const ZERO = '0'.repeat(40);
// What a move of HEAD or a new branch records in the reflog. There is no
// committer there, only a ref moving, so the stated identity commit uses is
// reused.
export const IDENTITY = 'mirage <mirage@localhost>';
const ENC = new TextEncoder();
/**
* One reflog line, in git's own format.
*
* `<old> <new> <identity> <epoch> <offset>\t<message>`, with the old id all
* zeroes when there was nothing there before. The tab is load-bearing: it is
* what separates the fixed fields from a message that may itself contain spaces.
* An empty message leaves the tab out, as git does.
*
* @param before the id the ref held, zeroes when it held none
* @param after the id it now holds
* @param who the identity, `Name <email>`
* @param when epoch seconds
* @param message what happened, e.g. `commit: add delta`
*/
export function entry(before, after, who, when, message) {
const tail = message === '' ? '' : `\t${message}`;
return ENC.encode(`${before} ${after} ${who} ${String(when)} +0000${tail}\n`);
}
/**
* Add one line to a reflog, creating it if it is not there.
*
* Read-modify-write rather than an append op, because not every backend offers
* one and a reflog is small. Losing the history here would only cost the `@{n}`
* syntax, but `git branch` reads it to say where a detached HEAD detached from,
* so an absent log makes a perfectly good checkout read as `(no branch)`.
*/
export async function append(vfs, gitdir, path, line) {
const target = gitdir.join(path);
const existing = (await readOptional(vfs, target)) ?? new Uint8Array(0);
const merged = new Uint8Array(existing.length + line.length);
merged.set(existing);
merged.set(line, existing.length);
await writeFile(vfs, target, merged);
}
const LOGGED_PREFIXES = ['refs/heads/', 'refs/remotes/', 'refs/notes/'];
/**
* Whether an update to a ref is logged: always where its log already exists,
* and otherwise as `core.logAllRefUpdates` says, which defaults to HEAD and the
* branch, remote and notes refs outside a bare repository, and to nothing in
* one.
*
* @param vfs the session's files
* @param location the discovered repository
* @param name the full ref name
* @param log the path of its log
*/
export async function logged(vfs, location, name, log) {
if ((await readOptional(vfs, log)) !== null)
return true;
const value = (await configValues(vfs, location, 'core.logAllRefUpdates')).at(-1);
if (value?.toLowerCase() === 'always')
return true;
const normal = value === undefined ? !(await isBare(vfs, location)) : (maybeBool(value) ?? false);
return normal && (name === HEAD || LOGGED_PREFIXES.some((prefix) => name.startsWith(prefix)));
}
/**
* Record one move of HEAD, and of the branch it is on.
*
* git writes both logs on every update: `logs/HEAD` always, and the branch's own
* log when HEAD is attached to one. Both carry the same line. HEAD's log belongs
* to the checkout and a branch's to the repository, so a linked worktree splits
* them the way git does.
*
* @param vfs the session's files
* @param gitdir this checkout's git directory, which owns HEAD's log
* @param commondir the shared git directory, which owns the branches' logs
* @param ref the branch ref that also moved, null when HEAD is detached
* @param before the id HEAD held, null when it held none
* @param after the id it now holds
* @param who the identity to record
* @param when epoch seconds
* @param message what happened
*/
export async function record(vfs, gitdir, commondir, ref, before, after, who, when, message) {
const line = entry(before ?? ZERO, after, who, when, message);
await append(vfs, gitdir, HEAD_LOG, line);
if (ref !== null)
await append(vfs, commondir, `${LOGS_DIR}/${ref}`, line);
}
/** A ref's reflog, from the git directory that owns it. */
async function logOf(vfs, location, ref) {
const root = ref === HEAD ? location.gitdir : location.commondir;
return readOptional(vfs, root.join(LOGS_DIR, ref));
}
/**
* The log a reflog walk reads, and the name its rows print.
*
* As git looks a reflog up: the name as typed, then under `refs/` and
* `refs/heads/`, keep the spelling; only a log found by the
* full rev-parse rules (a tag, a remote) is printed by its full name (git
* 2.47.3 and 2.50.1).
*/
async function namedLog(vfs, location, revision) {
for (const ref of [revision, `refs/${revision}`, `refs/heads/${revision}`]) {
const data = await logOf(vfs, location, ref);
if (data?.length)
return [revision, data];
}
for (const ref of [
`refs/tags/${revision}`,
`refs/remotes/${revision}`,
`refs/remotes/${revision}/HEAD`,
]) {
const data = await logOf(vfs, location, ref);
if (data?.length)
return [ref, data];
}
return [revision, null];
}
/** Read a ref's log newest first through the dispatcher. */
export async function reflog(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
texts = texts[0] === 'show' ? texts.slice(1) : texts;
checkOperands(ctx, texts);
const repo = await opened(ctx, fl);
const revision = texts[0] ?? HEAD;
await resolveCommit(repo, revision);
const [name, data] = await namedLog(repo.vfs, repo.location, revision);
let rows = new TextDecoder()
.decode(data ?? new Uint8Array())
.split('\n')
.filter(Boolean)
.reverse();
const limit = fl.asInt('max_count');
if (limit !== undefined && limit >= 0)
rows = rows.slice(0, limit);
const out = rows
.map((row, index) => {
const tab = row.indexOf('\t');
const fields = tab === -1 ? row : row.slice(0, tab);
const message = tab === -1 ? '' : row.slice(tab + 1);
const oid = fields.split(' ')[1] ?? '';
return `${oid.slice(0, repo.abbrev)} ${name}@{${String(index)}}: ${message}\n`;
})
.join('');
return [ENC.encode(out), new IOResult()];
}
//# sourceMappingURL=reflog.js.map