git is an independent implementation that follows the
command-line interface of upstream Git. It reads
and changes a repository on any mount. Unlike the account CLIs, git needs no
credentials and takes no config: it is a program tree with nothing to
authenticate to.
Licenses: The upstream Git source uses
GPLv2. Mirage does not
distribute that source; its independent implementation uses
Apache-2.0.
Install
clis: section; see the
CLI overview.
The repository is read through the mount
-C names a directory inside a mount, and everything under it is read
with the same ops any command uses. The repository is never opened from
the host filesystem, so a repository on a RAM mount, a disk mount or an
object store all read the same way, and packfiles, loose objects and the
index are all read through the mount.
A write the mount refuses as read-only is answered in git’s words,
naming the lock git would have taken first: Unable to create '/repo/.git/index.lock': Read-only file system for a verb that writes
the index, cannot lock ref 'refs/heads/topic' for one that writes a
ref, and could not create work tree dir for clone. commit takes
the index’s lock before it looks for anything to commit, as git does,
so an empty one is refused too, and init names the directory it could
not make, or the config’s lock when it reinitializes.
-C defaults to the working directory, and the repository is found by
walking up from there, so a path inside the tree works:
--git-dir and --work-tree name the two halves directly, as do
GIT_DIR and GIT_WORK_TREE, which the options override. Both resolve
against the -C directory. With --git-dir alone nothing is
discovered and the working directory is the work tree. A repository’s
own core.worktree and core.bare apply as they do in git, and a
linked worktree ignores both. core.bare reads as git reads any
boolean, its name and value in either letter case or the value as an
integer such as 1, 0x10 or 2k, and a value git cannot read fails
every verb, even under --work-tree or in a linked worktree. A verb
that reads or writes working files
(status, add, commit, checkout, switch, restore, mv,
reset, and rm without --cached) refuses a bare repository or a
work tree that is not a directory, in git’s words:
Initialization and repository checks
git init [-q] [-b <branch>] [--bare] [<directory>] creates a repository
through the mount dispatcher. Reinitializing preserves existing objects,
configuration and HEAD. The default initial branch is master; use -b
to choose another. Host templates, hooks and the default-branch advisory
are omitted. Reinitializing takes the config’s lock, as git does, so a
read-only mount refuses it.
git help [<command>] renders the same declared grammar as --help.
git stash list reads the stash reflog, and git stash show [-p | --stat | --name-only] [<stash>] compares the saved working tree with its original
base. Numeric selectors and stash@{n} work with stashes made by native Git.
Stash creation, application and deletion are not implemented.
git fsck [--full] [--no-dangling] checks loose and packed object hashes,
decoding and connectivity, using refs, the index and reflogs as roots.
Missing and corrupt objects cause a nonzero exit. Corruption diagnostics
retain the object ID and cause; their wording can differ from native Git.
Verbs
An option a verb does not take is refused in git’s words and followed by the verb’s usage block: git’s synopsis lines, cut down to the forms this build implements, then a row per option in git’s layout.-h
prints the same block on stdout (on stderr for diff) and exits 129.
The rows carry mirage’s own help for each option, so only
symbolic-ref, which has every option git’s has, prints git’s block
byte for byte. log, show and reflog refuse with unrecognized argument, diff with invalid option unless the line names a
revision or --cached, and rev-list, diff-tree and such a diff
print the block alone, as git does.
rev-parse refuses an option it does not know where git would print it
back, because it cannot tell one git does not know from one this build
lacks. remote lists remotes and refuses any subcommand as git refuses
one it does not know.
Inspect
log and show take --pretty/--format with git’s grammar: the
oneline, short, medium, full and fuller presets, plus
format:/tformat: placeholder strings (a bare % string is
tformat:). Placeholders cover ids (%H %h %T %t %P %p), author and
committer fields (%an %ae %ad %at %cn %ce %cd %ct), the message
(%s %b %B), decorations (%d %D), and %n %% %xHH; an unknown
placeholder stays verbatim, exactly as git prints it. log --all walks
every ref, tags peeled. show takes --stat (git’s scaled diffstat
table), --name-only, and -s/--no-patch, which suppresses every
diff section just as it does in git.
log -G keeps the commits whose diff adds or removes a line matching a
POSIX extended regex, and -S with --pickaxe-regex counts regex
matches. cat-file answers -t, -s, -e, -p, <type> <object> and
--batch/--batch-check with git’s format atoms. hash-object hashes
files or stdin (-t, --stdin-paths, --literally) and -w writes the
object; a malformed one is refused with git’s last line only, not the
fsck detail before it.
log and show label each commit with the refs that point at it under
--decorate (short, full or no), and log.decorate sets the same
style from the repository’s config; --no-decorate turns it off and the
last one on the line wins. A %d/%D placeholder decorates by short
names unless a style says full. symbolic-ref reads a symbolic ref
(--short, --no-recurse, -q), points one at another (-m gives the
reflog its reason) and deletes one with -d, never HEAD.
show takes several objects and prints each in turn: a commit as its
entry, an annotated tag as tag <name>, its tagger and its message
ahead of the object it points at, a tree as its listing and a blob as
its bytes; a commit named twice prints once. A name a branch and a tag
both answer to reads as git reads it, the tag first, and every read of it
warns refname '<name>' is ambiguous. on stderr, as git does each time;
rev-parse -q and core.warnAmbiguousRefs=false leave the warning out.
checkout and switch take such a name as the branch, and branch,
switch -c and checkout -b refuse it as a start point.
rev-parse --abbrev-ref prints error: refname '<name>' is ambiguous
for such a name, right after its warning, and takes strict or
loose; a missing path
in <rev>:<path> or :<path> is refused in git’s words (does not exist in 'HEAD', exists on disk, but not in 'HEAD~1', is in the index, but not at stage 2).
log and rev-list read revisions the way git does: A..B walks B and
hides what A reaches, A...B walks both and hides what they share, ^A
hides A, and several revisions walk together. diff takes A..B and
A...B as one operand, the second comparing B with the merge base.
Paths are quoted the way git quotes them, and core.quotePath=false in
the repository’s config leaves bytes outside ASCII as they are.
status honors .gitignore at every level, including negated rules, and
collapses an untracked directory to one entry the way git does (-uall
descends, -uno hides untracked files entirely).
Change
commit records mirage <mirage@localhost> unless --author says
otherwise: git reads user.name from config files that a mount does not
serve, and inventing a name would put an unreviewed one into history.
Deliberate limits
- No
config.worktree. The per-worktree config thatextensions.worktreeConfigenables is not read, so a linked worktree never takes acore.worktreeorcore.bareof its own. - No warning for
core.barebesidecore.worktree. git warns that the pair does not make sense and refuses a work-tree verb as invalid config; mirage keeps the repository bare, sologruns silently andstatusgives the bare refusal. - No
pullorpush.cloneandfetchread a repository inside the workspace or over smart HTTP; nothing writes to a remote. - A pathspec that begins with a dash needs
--.git rm -draftis a refused switch andgit rm -- -draftremoves the file, which is what every synopsis ending[--] [<pathspec>...]promises. The marker itself is consumed by the parser, so a verb reading a revision still resolves an escaped word as one rather than narrowing by it. - No
reset --hard. It destroys uncommitted work with no reflog here to recover it from.rmandrestoreare the way to discard a file’s changes on purpose, andrmrefuses unless-fsays so. - No
switch -. The previous branch lives in the reflog, which this build writes but does not read back. committakes no paths. Real git commits only the paths named; this build commits the whole index, so it refuses the operand rather than record changes nobody named.commit -arestages tracked files as git does, but git’s-aalso resolves conflicts into a merge commit, which this build does not write, so an unmerged index is refused either way.- An unmerged index stops a branch move. Every collision check reads
stage 0, so a path held only as conflict stages would be carried past
all of them and then cleared.
switchandcheckoutname each such path and refuse first. Creating a branch where HEAD already is (switch -c topic, no start point) moves nothing and is allowed, which is git’s own reading of the line. - A branch with no commit is a name and nothing else.
switch -c topicbefore the first commit repoints HEAD and writes neither a ref nor a reflog line, which is how git leaves an unborn branch too. Naming a start point there is a different line and stays unresolvable. - A revision is read left to right.
~n,^nand^{<type>}are operators applied in the order they are written, soHEAD^{commit}~1is HEAD’s parent,HEAD~1^{tree}is that parent’s tree, and the two chain further (HEAD^{commit}~1^{tree}). A peel was read as a trailing thing until this round, so every chain that went on after one was refused although git takes it. A step off something with no parents is still refused, which is whatHEAD^{tree}~1is. ^{tag}stops above the commit. Every other peel type sits at or below it, so an annotated tag is unwrapped on the way;v1^{tag}is the one spelling that names the tag object itself, and a lightweight tag has none to stop at.^{object}peels nothing. It is an existence check rather than a type, so it hands back whatever the name resolved to and an annotated tag stays a tag where^{}would unwrap it. Every object reports a concrete type, so readingobjectas one refused every expression that spelled it.- A name or id stands for the object it names.
rev-parse v1,show v1andtag nested v1read an annotated tag as the tag object itself, as git does; a verb that wants a commit or a tree (log,restore --source=v1, a~or^step) unwraps it on the way. That is howgit tag nested v1records the nested tag git itself warns about rather than quietly flattening it. - A path is only a way through while every parent is a directory.
Anything else standing on one is not, whatever kind it is: a symlink,
a regular file, tracked or not. The two directions differ and both are
git’s. Writing an entry replaces what stands above it with the
directory the entry needs, so
restoreandswitchboth land the blob where the branch says and leave a link’s target tree, a path no branch named, untouched. Removing an entry does nothing at all, because the path never led there. The checks are one helper, so a third caller inherits both halves rather than re-deriving one. - A switch replaces an ignored parent and refuses an uncommitted one. The untracked case git refuses by name, and that is unchanged. An ignored file or link is in neither tree and on no collision list, so it reaches the write and is replaced in silence, which is git’s own split. The deliberate divergence is the third: where an index entry stands in the way of a directory the target records, git allows the switch and discards the staged addition with nothing left pointing at the blob, and this build refuses and names the path instead. Same trade as the staged-change refusal above: no reflog here means a silent discard cannot be undone, and a refusal can be acted on.
- The divergence runs both ways round the same collision. A staged
path inside a directory the target replaces with a file is refused
and named too. git succeeds there as well, removing the directory and
dropping the staged entry, which would otherwise leave an index
holding both
slotandslot/child: a shape git’s own index has no room for. The two halves are one decision, so neither is quietly more permissive than the other. - A directory is replaced without following a link out of it. A
listing dereferences, so a link to a directory lists that directory’s
contents; walking them would delete a tree no branch named. The name
plane is asked for each child, and a link is unlinked where a
directory is descended into, which is what
rm -rdoes too. - Two refs cannot hold one path. A ref is a file below
.git, sofooandfoo/barcannot both exist, inrefs/tags/or inrefs/heads/. Creating either over the other is refused with git’s own lock wording and exit 128, before the working tree moves, and-fdoes not help because the obstacle is the path rather than the value. Left to the mount, a disk backend raised its host’s error and a prefix store took both keys and left a ref the loose-ref walk could not find. mvrefuses a directory and something inside it, whatever order the line puts them in and whether or not-kis given. The check reads the whole line once every source has passed its own, so a source with a fault of its own and a same-target collision are both reported first, and a source-kalready skipped is out of the comparison.-kcannot apply here: moving the directory is what makes the other source disappear, so skipping the second move would leave the first one applied and unstaged.- The same split applies to a directory standing on the name itself,
not only above it. A directory holding untracked files is refused and
named (
Updating the following directories would lose untracked files in them); one holding nothing but ignored files is removed whole and the target’s file written in its place, since--overwrite-ignoreis git’s default and the refusal is about untracked content rather than about the directory. - A tree entry’s executable bit is restored with its content. git
records exactly one permission bit and puts it back in both
directions, so
chmod -xon a100755path andchmod +xon a100644one are both modifications thatrestoreandswitchundo. The mode is written unconditionally rather than probed first: deciding costs the same op as setting, and the backends a repository is served from apply it natively. - An unborn branch is not a branch you can be on. A fresh
repository’s HEAD names one that has never been written, and
switch <that name>isfatal: invalid reference: <name>rather thanAlready on, because there is no commit to be on;switch -c <new>stays the one line an unborn HEAD accepts.checkoutanswers the same line differently, as git does, through its pathspec fallback. - A move never crosses a mount boundary, at either end. The rename op binds to the backend serving the source, so a destination another mount serves would be written into the source’s backend at a path it does not own: the file lands hidden behind that mount while the index names the new path. A source that is a mount root, or holds one, is refused for the same reason one level up.
- A rename moves what the node table holds at the path, not just below
it. A symlink and an attr overlay live above every backend, addressed
by absolute path, so a move has to re-anchor the source’s own entry and
its whole subtree or the link is destroyed and the mode is inherited by
whatever is written at the old name next.
git mvrenames through the dispatcher, which does this for every caller that reaches it; a single-mount shellmvrenames through the backend op directly and so repeats it, for a two-operand line only. - A rename onto a directory the namespace has filled is
ENOTEMPTY. A symlink is namespace state no backend can see, so a destination the backend reads as empty can still hold one, and replacing it would delete the link with it. The merged view decides, which is what POSIX promises. tag -aneeds-m, for the reasoncommitdoes: there is no editor to open.tag -nasks for a listing, so-dcannot also be on the line. With nothing else there-nmakes the line a listing, which is whytag -n1 nosuchis a pattern matching nothing at exit 0; once-dhas chosen the mode there is nothing left to imply, and the line dies with every tag still standing.tag -ddeletes every name or none of them. One ref transaction carries the whole line, and a name given twice is two updates for one ref, which the transaction refuses before applying any of them. A name no ref answers is reported and skipped instead, since it never reaches the transaction at all. The one divergence is the wording of the-l/-drefusal: git names the two options in the order they were typed and in the spelling used, where this build has one fixed order.tag -n-1is not a-nat all. git’s option parser starts the count at -1 to mean “never given”, so the sentinel reaches the verb looking like an absent flag:tag -d -n-1 vdeletes andtag -n-1 -a v -m mcreates, where either line with a real-nrefuses. Anything below -1 is a count, and a count has to be positive: the refusal names the format field git was building (positive value expected contents:lines=-2) rather than the option, because that is where git discovers it, which is also why it fires in a repository holding no tags and why-d -n-2still dies on the list-mode refusal first.- A symlinked ancestor hides a tracked file from the walk, not from
rm. git’s own diff refuses to follow a link in a leading path and reports the file deleted, which is whystatusshowsDfor it;rmlstats instead, and that lstat resolves the link and finds whatever lies at the other end. So a trackedslot/childbehindslot -> /elsewhereis a local modification and is refused without-f, even when the file at the other end is byte for byte the same one: two files agreeing is still two files. A link pointing past the name entirely leaves nothing to lose, and the removal goes through. - A nested mount stops a working-tree removal. Replacing a directory
with a file takes the whole directory, and
readdirmerges a mount nested inside it into the listing, so the walk would empty a backend no branch ever recorded and then remove its root. The mount table is asked first and the verb refuses, naming the mount when the session may be told about it and saying only that one is there when it may not. Every destination is asked before the first one is written, which is what the other collision checks already do and what keeps the refusal from landing halfway: aswitchthat had written an earlier path would leave the working tree on the target’s content with HEAD and the index still on the branch being left, andrestore -SWstages before it writes, so a refusal in the second pass would move the index and nothing else. The same boundary stops the tidying pass that drops a directory emptied byrm: it halts at the mount root rather than removing it, since nothing the caller asked for has failed. This is the ruleMountRootPolicyalready applies tormandmvat the command tier, which a git verb reaching the dispatcher directly has to ask for itself. mv -fonto an unmerged path takes its stages with it.-fis the only way to reach a destination that already exists, and git’s answer there is one stage-0 entry holding the source:ls-files -uis empty afterwards. Leaving the stages behind is the worse divergence rather than the smaller one, since the index writer lays them back over the entry and the blob that just moved is the copy that disappears. An unmerged source still moves nowhere, and a plainmvonto an occupied destination is still the ordinary refusal.- An ancestry suffix is only
~and^. Every other character used to count as another first-parent hop, soHEAD^xresolved toHEAD^^andgit tag release HEAD^xwrote the tag at a commit nobody named. The whole expression is refused now, which is git’s own answer, andmain~٣goes with it: the count scans ASCII digits, so what a unicode digit leaves behind is not a step either. The wording follows the verb, since git’s does:tagsaysFailed to resolve '<rev>' as a valid ref.andlogandshowsayambiguous argument '<rev>'.HEAD^0,HEAD^~andHEAD~2^2~1are steps git takes and still parse. - A gitlink is a directory placeholder, not a blob. A
160000entry names a commit in another repository, which this one does not hold, so reading it as content is either a miss or, when the id does happen to resolve here, an empty string written over the whole directory. git checks out no submodule content at all without--recurse-submodules; all the entry asks of the working tree is that a directory stand at the name, so an existing one is left exactly as it is with its untracked work, a regular file or a link is replaced by an empty directory, and a missing one is created. What is under the name is never touched either: a tracked child the restored source drops loses its index entry and keeps its working-tree copy, and a directory full of untracked files is what the entry asked for rather than a collision, where an untracked file at the name still refuses. Taking the entry away is anrmdirrather than an unlink, and it warns rather than failing: an empty directory goes, one that still holds anything stays withwarning: unable to rmdir '<name>': Directory not empty, a file or a link at the name is theNot a directorywording of the same line, and the switch or restore succeeds either way. A gitlink is also invisible to the unstaged comparison, because the submodule’s own HEAD is what git compares and this build cannot read it: reporting the directory as a deleted file is worse than saying nothing, and it refused every branch switch away from a submodule. What is left of the divergence is the untracked side: files inside a submodule’s directory are still listed as untracked, where git says nothing about them. resettakes no revision. Real git resets the index to any commit named as an operand; this build resets it from HEAD only and refuses a revision by name rather than doing nothing quietly.checkoutrefuses a staged change rather than merging it into the target, so nothing is silently resolved.- Diff hunk bodies can differ from git’s on the same change. Headers,
mode lines and blob abbreviations match; the line grouping inside a
hunk comes from a different algorithm than git’s xdiff.
show --statline counts come from the same algorithm, so a rewritten hunk can count slightly differently than git counts it. --prettyknows the block presets, not the wire formats.email,mboxrdandreferenceare refused by name (unsupported --pretty format), not silently misrendered.- History searches use ASCII case folding.
-i/--regexp-ignore-casefolds ASCII letters in--grep,--authorand-Son both hosts. Non-ASCII letters keep their exact spelling, matching Git’s literal searches underLC_ALL=C.--grepvalues containing newlines are lists of alternative patterns. logdates are strict.--since/--untilread ISO-8601 or an epoch second; git’s relative wording (2 weeks ago) is refused rather than misread.