UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

250 lines • 11 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 git from 'isomorphic-git'; import { visibleEntries, matched, patternRoots, repoRelative } from "./pathspec.js"; import { fsStrerror, isEacces } from "../../../../errors/fs.js"; import { IOResult } from "../../../../io/types.js"; import { FileType } from "../../../../types.js"; import { FlagView } from "../../../spec/flag_view.js"; import { IgnoredPathsError, NothingSpecifiedError, PathspecError, UnindexableFileError, UnknownPathspecError, } from "./errors.js"; import { loadIgnores } from "./ignore.js"; import { readIndex, updateIndex } from "./index_file.js"; import { entryBytes, statOf } from "./io.js"; import { repoArgs } from "./repo.js"; import { opened } from "./session.js"; import { EXECUTABLE, OWNER_EXECUTE, REGULAR, SYMLINK } from "./constants.js"; import { checkSwitches, startPoint, requireVfs } from "./util.js"; import { scan, UNTRACKED_ALL, UNTRACKED_NO } from "./worktree.js"; import { compareCodePoints } from "../../../../utils/sort.js"; const ENC = new TextEncoder(); /** Read the raw add flag kwargs into a frozen struct. */ function parseFlags(fl) { return { every: fl.asBool('all'), update: fl.asBool('update'), force: fl.asBool('force'), verbose: fl.asBool('verbose'), }; } /** The mode git would record for a working-tree file. */ export function entryMode(info) { if (info.type === FileType.SYMLINK) return SYMLINK; return info.mode !== null && (info.mode & OWNER_EXECUTE) !== 0 ? EXECUTABLE : REGULAR; } /** * An index entry for a file just written into the object database. * * The stat fields git caches to avoid re-hashing (device, inode, timestamps) are * recorded as zero, because a mount serves none of them meaningfully. That is * not a corrupt entry: it is exactly what git calls a smudged one, and the only * consequence is that git re-hashes the file next time instead of trusting the * cache. A wrong value there would be far worse, since git would trust it. */ function stagedEntry(oid, info, size) { return { oid, mode: entryMode(info), size }; } /** * Drop the paths an ignore rule covers, keeping tracked ones. * * Ignore rules govern untracked files only, so a file already in the index stays * stageable however the rules read. */ function keepAddable(paths, tracked, ignores) { return new Set([...paths].filter((path) => tracked.has(path) || !ignores.isIgnored(path))); } /** * Which tracked paths `-u` operands select. * * `-u` restages what the index already holds, so an operand narrows that set * rather than adding to it: an untracked file under one is still not staged. git * tells two misses apart and so does this. An operand naming nothing at all is a * fatal about the pathspec, and one naming something the working tree has but * the index does not is a fatal about git not knowing it. Pinned against git * 2.50.1. */ function updateScope(location, start, operands, tracked, present) { const selected = new Set(); for (const operand of operands) { const target = repoRelative(location, start, operand); const hits = matched(tracked, target); if (hits.size === 0 && matched(present, target).size === 0) { throw new PathspecError(operand); } if (hits.size === 0) throw new UnknownPathspecError(operand, true); for (const path of hits) selected.add(path); } return selected; } /** * Turn path operands into the paths to stage and to unstage. * * An operand that names nothing in either the working tree or the index is git's * fatal. Naming an ignored file outright is a different refusal, and only * applies when it is named outright: expanding a directory quietly leaves its * ignored files alone, because asking for a directory is not asking for the * things in it that were excluded. */ async function resolve(vfs, location, start, operands, found, tracked, ignores, force) { const present = new Set(found.files.keys()); const stage = new Set(); const remove = new Set(); const ignored = []; for (const operand of operands) { const target = repoRelative(location, start, operand); const gone = [...matched(tracked, target)].filter((path) => !present.has(path)); if (present.has(target)) { if (force || tracked.has(target) || !ignores.isIgnored(target)) stage.add(target); else ignored.push(target); continue; } const hits = matched(present, target); if (hits.size > 0 || gone.length > 0) { for (const path of force ? hits : keepAddable(hits, tracked, ignores)) stage.add(path); for (const path of gone) remove.add(path); continue; } const info = await statOf(vfs, location.worktree.join(target)); if (info === null || info.type === FileType.DIRECTORY) throw new PathspecError(operand); found.files.set(target, info); stage.add(target); } if (ignored.length > 0) throw new IgnoredPathsError(ignored); return [stage, remove]; } /** * Hash the staged paths into blobs, leaving the index to the caller. * * Returns the entries to write and what `-v` prints, in git's order: first the * paths the index already held whose content or mode changed, a removal among * them, then the new paths, each group sorted. A path restaged unchanged is not * named (pinned against git 2.50). */ export async function stageChanges(repo, vfs, entries, found, stage, remove) { const staged = new Map(); const changed = []; const added = []; for (const path of [...stage].sort(compareCodePoints)) { const info = found.files.get(path); if (info === undefined) continue; const before = entries.get(path); let data; try { data = await entryBytes(vfs, repo.location.worktree.join(path), info); } catch (err) { if (!isEacces(err)) throw err; throw new UnindexableFileError(path, fsStrerror(err) ?? 'Permission denied', before !== undefined); } const oid = await git.writeBlob({ ...repoArgs(repo), blob: data }); const entry = stagedEntry(oid, info, data.length); if (before === undefined) added.push(path); else if (before.oid !== entry.oid || before.mode !== entry.mode) changed.push([path, 'add']); staged.set(path, entry); } for (const path of remove) changed.push([path, 'remove']); changed.sort((a, b) => compareCodePoints(a[0], b[0])); return [ staged, [ ...changed.map(([path, verb]) => `${verb} '${path}'`), ...added.map((path) => `add '${path}'`), ], ]; } /** * Restage every path the index holds from the working tree. * * What `add -u` does with no pathspec and `commit -a` does first: a modified * file is hashed again, a deleted one leaves the index, and an untracked one * stays untracked. `state` is updated to match; the caller writes the returned * entries and removals with `updateIndex` once it means to keep them. */ export async function stageTracked(repo, vfs, state) { const tracked = new Set(visibleEntries(repo.location, state.entries).keys()); const found = await scan(vfs, repo.location, tracked, UNTRACKED_NO); const present = new Set(found.files.keys()); const kept = new Set([...tracked].filter((path) => present.has(path))); const removed = new Set([...tracked].filter((path) => !present.has(path))); const [staged] = await stageChanges(repo, vfs, state.entries, found, kept, removed); for (const path of removed) state.entries.delete(path); for (const [path, entry] of staged) state.entries.set(path, { path, ...entry, stage: 0 }); return [staged, removed]; } /** * Stage working-tree content into the index. * * Every path is hashed and written as a loose object, then recorded in the * index. Staging a path that is gone records the removal instead, which is what * makes `git add <deleted>` and `git add -A` stage a deletion without a separate * verb. * * `-A` and `-u` both narrow to the pathspecs when any are given, and differ in * what they will stage: `-A` takes untracked files too, `-u` only what the index * already holds. */ export async function add(_accessor, paths, texts, ctx) { const fl = new FlagView(ctx.flags); const vfs = requireVfs(ctx); checkSwitches(ctx, texts); const parsed = parseFlags(fl); if (texts.length === 0 && !parsed.every && !parsed.update) throw new NothingSpecifiedError(); const repo = await opened(ctx, fl, true); const state = await readIndex(repo, vfs); const tracked = new Set(visibleEntries(repo.location, state.entries).keys()); const start = startPoint(fl).virtual; const roots = patternRoots(texts.map((text) => repoRelative(repo.location, start, text)), true); const found = await scan(vfs, repo.location, tracked, UNTRACKED_ALL, false, roots); const ignores = await loadIgnores(vfs, repo.location.commondir, repo.location.worktree); const present = new Set(found.files.keys()); let stage; let remove; if (parsed.update) { const scope = texts.length > 0 ? updateScope(repo.location, startPoint(fl).virtual, texts, tracked, present) : tracked; stage = new Set([...scope].filter((path) => present.has(path))); remove = new Set([...scope].filter((path) => !present.has(path))); } else if (parsed.every && texts.length === 0) { stage = keepAddable(present, tracked, ignores); remove = new Set([...tracked].filter((path) => !present.has(path))); } else { ; [stage, remove] = await resolve(vfs, repo.location, startPoint(fl).virtual, texts, found, tracked, ignores, parsed.force); } const [staged, lines] = await stageChanges(repo, vfs, state.entries, found, stage, remove); await updateIndex(repo, staged, remove); if (!parsed.verbose || lines.length === 0) return [null, new IOResult()]; return [ENC.encode(lines.map((line) => `${line}\n`).join('')), new IOResult()]; } //# sourceMappingURL=add.js.map