UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

351 lines • 15 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 { decodeText, encodeText } from "../../../../shell/bytes.js"; const UNCHANGED = ' '; const UNTRACKED = '?'; const UNMERGED_COLUMN = 'U'; // Width of the label column, which git fixes per section rather than measuring: // wide enough for the longest label the section can print ("typechange:" among // the changes, "deleted by them:" among the conflicts). const LABEL_WIDTH = 12; const CONFLICT_WIDTH = 17; const STAGED_LABELS = { M: 'modified:', A: 'new file:', D: 'deleted:', R: 'renamed:', C: 'copied:', T: 'typechange:', }; const WORK_LABELS = { M: 'modified:', D: 'deleted:', T: 'typechange:', }; const CONFLICT_LABELS = { DD: 'both deleted:', AU: 'added by us:', UD: 'deleted by them:', UA: 'added by them:', DU: 'deleted by us:', AA: 'both added:', UU: 'both modified:', }; // Every escape git spells with a letter rather than an octal triple. const ESCAPES = { 0x07: '\\a', 0x08: '\\b', 0x0c: '\\f', 0x0a: '\\n', 0x0d: '\\r', 0x09: '\\t', 0x0b: '\\v', 0x22: '\\"', 0x5c: '\\\\', }; const ON_BRANCH = 'On branch '; export const DETACHED_AT = 'HEAD detached at '; export const DETACHED_FROM = 'HEAD detached from '; export const NO_BRANCH = 'Not currently on any branch.'; const NO_COMMITS = 'No commits yet'; const BRANCH_MARK = '## '; const NO_COMMITS_BRANCH = 'No commits yet on '; const STAGED_HEADER = 'Changes to be committed:'; const UNMERGED_HEADER = 'Unmerged paths:'; const WORK_HEADER = 'Changes not staged for commit:'; const UNTRACKED_HEADER = 'Untracked files:'; const UNSTAGE_HINT = ' (use "git restore --staged <file>..." to unstage)'; const UNCACHE_HINT = ' (use "git rm --cached <file>..." to unstage)'; const RESOLVE_HINT = ' (use "git add <file>..." to mark resolution)'; // git widens the first hint to name `rm` as soon as the section holds a // deletion, because `git add` alone does stage one but reads as the wrong advice // for a file that is gone. const WORK_HINT = ' (use "git add <file>..." to update what will be committed)'; const WORK_HINT_DELETED = ' (use "git add/rm <file>..." to update what will be committed)'; const DISCARD_HINT = ' (use "git restore <file>..." to discard changes in working directory)'; const UNTRACKED_HINT = ' (use "git add <file>..." to include in what will be committed)'; const CONFLICT_HEADER = [ 'You have unmerged paths.', ' (fix conflicts and run "git commit")', ' (use "git merge --abort" to abort the merge)', ]; const RESOLVED_HEADER = [ 'All conflicts fixed but you are still merging.', ' (use "git commit" to conclude merge)', ]; const CLEAN = 'nothing to commit, working tree clean'; const CLEAN_INITIAL = 'nothing to commit (create/copy files and use "git add" to track)'; const UNSTAGED_ONLY = 'no changes added to commit (use "git add" and/or "git commit -a")'; const UNTRACKED_ONLY = 'nothing added to commit but untracked files present (use "git add" to track)'; // The two things `-uno` says instead, and they are not the same line: with // something staged git notes what it skipped, and with nothing at all it says // the tree is empty of changes but stops short of calling it clean, since it did // not look. const UNTRACKED_HIDDEN = 'Untracked files not listed (use -u option to show untracked files)'; const CLEAN_UNSCANNED = 'nothing to commit (use -u to show untracked files)'; const ENC = new TextEncoder(); /** * Spell a path the way git spells it, quoting only when it must. * * git C-quotes a path holding anything that would not survive being read back: a * quote, a backslash, a control character, or a byte outside ASCII. The * machine-readable formats also quote a path holding a space, and the * human-readable one does not, which is not an inconsistency: only the former is * parsed by splitting on whitespace. Verified both ways against git 2.47. * * `core.quotePath=false` turns `fully` off, and a byte outside ASCII then passes * through as itself while everything else is quoted as before (pinned against * git 2.50). A byte that is not UTF-8 arrives as a * surrogate escape and is quoted, or passed through, as that byte. * * @param path repository-relative path * @param porcelain whether a space alone forces quoting * @param fully whether a byte outside ASCII forces quoting */ export function quotePath(path, porcelain, fully = true) { const raw = encodeText(path); const special = raw.some((byte) => mustQuote(byte, fully)); if (!special && !(porcelain && path.includes(' '))) return path; if (!special) return `"${path}"`; const out = []; for (const byte of raw) { const escape = ESCAPES[byte]; if (escape !== undefined) for (const item of ENC.encode(escape)) out.push(item); else if (mustQuote(byte, fully)) for (const item of ENC.encode(`\\${byte.toString(8).padStart(3, '0')}`)) out.push(item); else out.push(byte); } return `"${decodeText(new Uint8Array(out))}"`; } /** Whether one byte of a path forces the whole path into quotes. */ function mustQuote(byte, fully) { return byte in ESCAPES || byte < 0x20 || byte === 0x7f || (fully && byte > 0x7f); } /** One row of `--short` / `--porcelain` output. */ function shortLine(entry, fully) { let path = quotePath(entry.path, true, fully); if (entry.original !== null) path = `${quotePath(entry.original, true, fully)} -> ${path}`; return `${entry.indexStatus}${entry.treeStatus} ${path}`; } /** * The `## ` header `--branch` prepends to the short formats. * * @param branch the branch HEAD names, null when detached * @param noCommits whether HEAD resolves to nothing yet */ export function branchLine(branch, noCommits, upstream = null) { if (branch === null) return `${BRANCH_MARK}HEAD (no branch)`; if (noCommits) return `${BRANCH_MARK}${NO_COMMITS_BRANCH}${branch}`; if (upstream === null) return `${BRANCH_MARK}${branch}`; const counts = upstream.gone ? ['gone'] : []; if (upstream.ahead) counts.push(`ahead ${String(upstream.ahead)}`); if (upstream.behind) counts.push(`behind ${String(upstream.behind)}`); const shown = counts.length ? ` [${counts.join(', ')}]` : ''; return `${BRANCH_MARK}${branch}...${upstream.label}${shown}`; } function commits(count) { return `${String(count)} commit${count === 1 ? '' : 's'}`; } /** * What the long status says about a branch and its upstream. * * Pinned against git 2.47.3 and 2.50.1. */ export function trackingLines(upstream) { const name = upstream.label; if (upstream.gone) return [ `Your branch is based on '${name}', but the upstream is gone.`, ' (use "git branch --unset-upstream" to fixup)', ]; if (!upstream.ahead && !upstream.behind) return [`Your branch is up to date with '${name}'.`]; if (!upstream.behind) return [ `Your branch is ahead of '${name}' by ${commits(upstream.ahead)}.`, ' (use "git push" to publish your local commits)', ]; if (!upstream.ahead) return [ `Your branch is behind '${name}' by ${commits(upstream.behind)}, and can be fast-forwarded.`, ' (use "git pull" to update your local branch)', ]; return [ `Your branch and '${name}' have diverged,`, `and have ${String(upstream.ahead)} and ${String(upstream.behind)} different commits each, respectively.`, ' (use "git pull" if you want to integrate the remote branch with yours)', ]; } /** The whole of `--short` / `--porcelain` output. */ export function shortFormat(rows, header, fully = true) { const lines = header === null ? [] : [header]; for (const item of rows.map((row) => shortLine(row, fully))) lines.push(item); return lines.map((line) => `${line}\n`).join(''); } /** One block of the long format, or nothing when it has no entries. */ function section(header, hints, entries) { if (entries.length === 0) return []; return [header, ...hints, ...entries, '']; } /** One entry line: a tab, a padded label, then the path. */ function labelled(label, path, width) { return `\t${label.padEnd(width)}${path}`; } /** The entry lines of the "Changes to be committed" section. */ function stagedEntries(rows, fully) { const lines = []; for (const row of rows) { if ([UNCHANGED, UNTRACKED, UNMERGED_COLUMN, '!'].includes(row.indexStatus)) continue; const label = STAGED_LABELS[row.indexStatus] ?? 'modified:'; let path = quotePath(row.path, false, fully); if (row.original !== null) path = `${quotePath(row.original, false, fully)} -> ${path}`; lines.push(labelled(label, path, LABEL_WIDTH)); } return lines; } /** The entry lines of the "Changes not staged for commit" section. */ function workEntries(rows, fully) { const lines = []; for (const row of rows) { if (row.indexStatus === UNMERGED_COLUMN) continue; if (row.treeStatus === UNCHANGED || row.treeStatus === UNTRACKED || row.treeStatus === '!') continue; const label = WORK_LABELS[row.treeStatus] ?? 'modified:'; lines.push(labelled(label, quotePath(row.path, false, fully), LABEL_WIDTH)); } return lines; } /** The entry lines of the "Unmerged paths" section. */ function unmergedEntries(rows, fully) { const lines = []; for (const row of rows) { const label = CONFLICT_LABELS[`${row.indexStatus}${row.treeStatus}`]; if (label === undefined) continue; lines.push(labelled(label, quotePath(row.path, false, fully), CONFLICT_WIDTH)); } return lines; } /** The entry lines of the "Untracked files" section. */ function untrackedEntries(rows, fully) { return rows .filter((row) => row.indexStatus === UNTRACKED) .map((row) => `\t${quotePath(row.path, false, fully)}`); } /** * git's closing line, which says what the sections above did not. * * Exactly one line, or none: the line exists to explain why `git commit` would * refuse, so with something already staged there is nothing to explain and git * says nothing. `-uno` does not add a line, it substitutes the two that mention * untracked files, which is why it is threaded through here rather than appended * by the caller. */ function trailer(staged, work, unmerged, untracked, noCommits, hideUntracked) { if (staged.length > 0) return hideUntracked ? [UNTRACKED_HIDDEN] : []; if (work.length > 0 || unmerged.length > 0) return [UNSTAGED_ONLY]; if (untracked.length > 0) return [UNTRACKED_ONLY]; if (noCommits) return [CLEAN_INITIAL]; return [hideUntracked ? CLEAN_UNSCANNED : CLEAN]; } /** * The default, human-readable status report. * * @param rows every row, already in git's order * @param branch the branch HEAD names, null when detached * @param commit the abbreviated commit HEAD holds, set only when detached * @param noCommits whether HEAD resolves to nothing yet * @param merging whether a merge is in progress * @param hideUntracked whether `-uno` suppressed the scan */ export function longFormat(rows, branch, detached, noCommits, merging, hideUntracked, fully = true, upstream = null) { const staged = stagedEntries(rows, fully); const unmerged = unmergedEntries(rows, fully); const work = workEntries(rows, fully); const untracked = untrackedEntries(rows, fully); const lines = [branch !== null ? `${ON_BRANCH}${branch}` : detached]; if (upstream !== null) lines.push(...trackingLines(upstream), ''); if (noCommits) lines.push('', NO_COMMITS, ''); if (merging) { lines.push(...(unmerged.length > 0 ? CONFLICT_HEADER : RESOLVED_HEADER), ''); } // A merge in progress removes the unstage hint, because unstaging is not what // resolving a conflict means and git stops offering it. const stagedHints = merging ? [] : [noCommits ? UNCACHE_HINT : UNSTAGE_HINT]; for (const item of section(STAGED_HEADER, stagedHints, staged)) lines.push(item); for (const item of section(UNMERGED_HEADER, [RESOLVE_HINT], unmerged)) lines.push(item); // Read off the rendered lines rather than the rows, so the hint can only ever // describe entries this section actually prints: an unmerged path can also // carry a D and is reported elsewhere. const deleted = work.some((line) => line.startsWith(`\t${WORK_LABELS.D ?? ''}`)); for (const item of section(WORK_HEADER, [deleted ? WORK_HINT_DELETED : WORK_HINT, DISCARD_HINT], work)) lines.push(item); for (const item of section(UNTRACKED_HEADER, [UNTRACKED_HINT], untracked)) lines.push(item); const ignored = rows .filter((row) => row.indexStatus === '!') .map((row) => `\t${quotePath(row.path, false, fully)}`); lines.push(...section('Ignored files:', [' (use "git add -f <file>..." to include in what will be committed)'], ignored)); for (const item of trailer(staged, work, unmerged, untracked, noCommits, hideUntracked)) lines.push(item); return lines.map((line) => `${line}\n`).join(''); } /** A repository-relative path spelled from a directory inside the work tree. */ export function relativePath(path, prefix) { if (prefix === '') return path; const base = prefix.split('/'); const parts = path.split('/').filter((part) => part !== ''); let shared = 0; while (shared < base.length && base[shared] === parts[shared]) shared++; const name = [...base.slice(shared).map(() => '..'), ...parts.slice(shared)].join('/') || '.'; return name + (path.endsWith('/') ? '/' : ''); } /** Render status paths relative to an invocation inside the work tree. */ export function relativeEntries(rows, prefix) { if (prefix === '') return rows; return rows.map((row) => ({ ...row, path: relativePath(row.path, prefix), original: row.original === null ? null : relativePath(row.original, prefix), })); } //# sourceMappingURL=render.js.map