feat(git): a git CLI over any mount, in both languages (#710)

* feat(git): a git CLI over any mount, in both languages

Adds `git` as a builtin CLI (issue #705 item 1): status, log, show,
diff, branch, add, reset, commit, checkout. It takes no config, and the
repository is read entirely through the mount ops, so a repo on RAM, on
disk or on an object store reads the same way. Python goes through
dulwich, TypeScript through isomorphic-git backed by a PromiseFsClient
over the dispatcher.

Status is pinned against the real git binary across 22 repository states
and 6 spellings, the mutation verbs are read back by real git and pass
`git fsck`, and the rename-similarity score matches dulwich digit for
digit. Both run in integ on `git-ram` and `git-disk` in both languages.

Also fixes the grep family this leaned on:

- grep reads a basic regular expression by default, as POSIX says, with
  -G for the default and -E for extended. `grep -l` and `grep -rl`
  compiled their own pattern and still read extended.
- zgrep does the same. Its -E flag was read and discarded.
- an operand grep could not search exits 2, as GNU does, instead of
  being flattened to 1.
- `grep -l` on a directory reports it rather than walking it. The
  shared fallback called the recursive walk whenever a failed read
  turned out to be a directory, which made -l behave like -rl.

Supporting pieces: ranged reads (`read_range`) with a read-and-slice
fallback and a seeking implementation for disk, PATH-typed group options
resolved against the working directory, and git's three unknown-option
dialects.

* fix(rg): report an unreadable operand as exit 2, like ripgrep

CI caught four goldens the grep change had missed, all outside the JSON
harness targets I had been running, plus a divergence the change itself
introduced.

The divergence: TypeScript routes rg through grepGeneric, so rg moved to
exit 2 there while python's rg, which has its own generic, stayed at 1.
Real ripgrep exits 2 for an operand it could not read, so TypeScript was
right and python is brought up to it rather than the other way round.
Its single-operand path also let the error escape to the shared handler,
which flattens every OSError to exit 1; rg now reports it itself, the
same fix grep needed.

The rule is now stated once in grep_helper (`exit_code_for` /
`exitCodeFor`) and imported by both generics in both languages, instead
of living in the grep generic where rg could not reach it.

Goldens updated: integ/cross_commands.{py,ts}, the shared observability
contract, langfuse and dify. Each pinned exit 1 for a missing operand
and carried a comment calling it a deliberate divergence.

* fix(git): refuse the three mutations that could lose work

Three review findings, each measured against git 2.50.1 before fixing.

checkout only compared tracked paths, so a branch holding a file the
working tree has untracked wrote its blob straight over it. The file is
in no index and no tree, so nothing could see it and nothing could get
it back. The untracked set is now part of the conflict check, which
needs UNTRACKED_ALL rather than the mode status uses: "normal" collapses
a wholly untracked directory to one row, and git names the file inside
it. An ignored file stays overwritable, which is git's own split. When
both kinds of conflict apply git prints both paragraphs and aborts once,
so CheckoutConflictError carries both lists.

branch -d deleted a branch HEAD does not contain, dropping the only name
pointing at those commits. It now refuses, and -D is added, because -d
alone would be a delete with no way to say no. dulwich's can_fast_forward
answers exactly this and cannot be used: it asks the repository for its
grafts and shallow boundary and a bare BaseRepo raises. Walker is what
log already walks with and needs only the object store.

add -u ignored its pathspecs and restaged every tracked file, which is
how an unrelated edit reaches the next commit. git tells two misses
apart and so does this now: a pathspec naming nothing is a fatal about
the pathspec, one naming an untracked file is a fatal about git not
knowing it, both exit 128.

Also two CI failures. The spec dumps were stale for the -G flag added
with the zgrep BRE fix. And the git fixture exported its identity as
environment variables, which reach only its own commits, so a test that
committed into the built repository failed with "Author identity
unknown" on any runner with no global identity. It now records the same
identity in the repository, leaving the object ids unchanged.

Covered by unit tests in both languages and six integ cases across
git-ram and git-disk on both hosts.

* fix(test): the redis missing-file suite still pinned grep at exit 1

Sibling of the disk suite, which was updated with the exit-2 change. This
one needs a live server, so it skips silently without REDIS_URL and the
local run never reached it. Only the grep case moves: cat, head, tail and
wc all still exit 1 for an operand they could not read, which is the
split the comment now records here as it does next door.

* fix(grep): read an operand's type from stat, not from how the read failed

Four integ jobs caught this across seven backends, and it is one mistake
with three faces. Classifying a directory operand *after* a failed read
makes the answer depend on what each backend does about reading one, and
they disagree: s3, gridfs, hf and nextcloud read a directory path without
complaint and hand back nothing, and ssh raises an asyncssh SFTP error
that is not an OSError at all, so it escaped the catch entirely and
exited 1 with an unattributed "grep: Is a directory". So the operand is
now stat-ed before it is read, which is what GNU does and what the -r
branch of the same function already did.

operand_is_directory had its own version of the same error: a readdir
that did not raise counted as a directory, and a prefix store answers
readdir for any path at all, returning nothing for one that is not
there. Every missing file on those backends therefore read as a
directory. The listing must now be non-empty to count, which costs a
genuinely empty directory being invisible there, the same divergence du
already documents and the safer way round.

The catch also widens from three exception types to WALK_ERRORS, the
tuple every other operand-tolerant walk in the repo uses, so an errno
split cannot make grep abort where tree and grep -r keep going.

One test fake resolved stat with `undefined as never` and only worked
while nothing read the value. It resolves with a real FileStat now,
which is what the postgres backend answers there; the assertion the test
exists for is untouched.

Verified on gridfs and gridfs-prefix, which reproduce the prefix-store
half locally, plus ram, disk and redis for regressions: all five targets
on both hosts, 0 failed.

* fix(git): three review findings on -C, checkout -b and reset

`-C` accepted any path that was not missing, so naming a file inside a
repository walked up and ran in the parent instead of failing the way
git's chdir does. For a write verb that means mutating a repository the
caller never named. It now refuses a non-directory with git's own second
wording, "Not a directory".

`checkout -b <new> <start>` forced the new branch to HEAD and dropped the
start point without a word, so every commit after it landed on the wrong
history. The operand is honored, and a start point that is not a commit
gets git's sentence naming both it and the branch rather than the
generic "ambiguous argument" the same lookup failure produces elsewhere.

`reset <operand>` that selected no path unstaged nothing and exited 0,
which a script reads as "the index was reset". Two different mistakes
reach that point and they now get different fatals. A typo is git's
"ambiguous argument". A revision is not: real git resets the index to any
commit named there, measured on 2.50.1, so the review's premise that git
refuses one is wrong. This build resets from HEAD only, and says which
feature is missing instead of claiming a revision it can resolve is
unknown. Recorded as a divergence in both git.mdx files.

Six integ cases across git-ram and git-disk on both hosts, plus unit
tests in both languages.

* chore: merge main and regenerate the grep and zgrep specs

#713 landed a `pair` field on the option spec while this branch was
open. CI builds the PR merged with main, so it regenerated specs
carrying the field and compared them against grep.json and zgrep.json,
the two files this branch had already rewritten for `-G`, which
therefore predate it. Every other spec file came across from main
already carrying it.

The regeneration also needs a rebuilt dist: gen-specs.ts reads the built
package, and against a stale one it dies on an Operand field it does not
know, which silently leaves the typescript half unregenerated and shows
up as a python/typescript parity divergence rather than as a build
error.
This commit is contained in:
Zecheng Zhang
2026-08-05 13:38:03 -07:00
committed by GitHub
parent 8ad5ec2a4a
commit 67945abf63
240 changed files with 22062 additions and 462 deletions
+17 -2
View File
@@ -49,8 +49,21 @@ Command history is a recording, not a command log. A hidden `Observer` records e
## CLIs
An installed CLI is a typed program tree (`CLISpec`) bound to a head word on the
workspace. It is **dispatched by name, never by operand path**, and consults no
mount: the VFS is how an agent discovers state, the CLI is how it acts.
workspace. It is **dispatched by name, never by operand path**: the VFS is how an
agent discovers state, the CLI is how it acts.
- **An account CLI consults no mount; `git` is the one that does.** The two are
different tiers, told apart by `config_model`. An account CLI
(`slack`, `linear`, `gws`, …) declares one, initializes from it, and reaches a
service, so a mount would be a second source of truth for the same account.
`git` declares none, because there is nothing to authenticate to, and its
subject is a repository that lives on a mount, so it reads that repository
through the op dispatcher like any command. That is what makes `git` work on a
RAM mount, a disk mount or an object store without knowing which. It reaches
the dispatcher through `CLIVerbOpts` (`dispatch`, `stat_path`, `mount_root`),
which `handle_cli`/`handleCli` supplies; a verb that does not declare them
cannot touch a mount, so this stays opt-in per verb rather than ambient.
Do not give an account CLI a mount, and do not give `git` a `config_model`.
- **The lifecycle is host-side only.** `register_cli`/`unregister_cli`
(`workspace.py`, `workspace.ts`) are called by the embedding program, never by
@@ -61,12 +74,14 @@ mount: the VFS is how an agent discovers state, the CLI is how it acts.
with `command <name>`, and visible in `type -a`. A deployment that needs a head
word pinned enforces that in the policy layer's `pre_execute`, not in the CLI
registry.
- **Precedence is written down once**, in `_layers`/`layers`
(`workspace/route/route.py`, `route.ts`): shell builtin, namespace command,
function, CLI, mount. `route` takes the first match (the winner, which is what
dispatch runs) and `route_all`/`routeAll` takes all of them (every layer, which
is what `type -a` prints). The generator is lazy so the winner still costs one
probe. Do not add a second precedence list.
- **Discoverability is part of shipping a CLI**, and it comes from the spec, so
it works for a user's own registered CLI exactly as for a builtin one. `man <cli>` and `man <cli> <verb>...` render through `node_help`/`nodeHelp`, the
same renderer `--help` uses, so a manual cannot drift from the program; bare
+4 -2
View File
@@ -391,7 +391,8 @@
"python/cli/slack",
"python/cli/discord",
"python/cli/ntn",
"python/cli/linear"
"python/cli/linear",
"python/cli/git"
]
},
{
@@ -580,7 +581,8 @@
"typescript/cli/slack",
"typescript/cli/discord",
"typescript/cli/ntn",
"typescript/cli/linear"
"typescript/cli/linear",
"typescript/cli/git"
]
},
{
+104
View File
@@ -0,0 +1,104 @@
---
title: git
description: Read and change a git repository that lives on any mount, in git's own vocabulary.
icon: terminal
---
Read and change a git repository that lives on any mount, in git's own
vocabulary. Unlike the account CLIs, `git` needs no credentials and takes
no config: it is a program tree with nothing to authenticate to, so
installing it is one line.
## Install
```python
from mirage import Workspace
from mirage.commands.cli.builtin.git import GIT
from mirage.resource.ram import RAMResource
ws = Workspace({"/repo": RAMResource()})
ws.register_cli("git", GIT)
await ws.execute("git -C /repo status --short")
```
In YAML the same install rides the `clis:` section; see the
[CLI overview](/python/cli/index).
## 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.
`-C` defaults to the working directory, and the repository is found by
walking up from there, so a path inside the tree works:
```bash
git -C /repo/src status
```
## Verbs
### Inspect
```bash
git -C /repo status
git -C /repo status --short
git -C /repo status --porcelain -b
git -C /repo status -uall
git -C /repo log --oneline -n 20
git -C /repo log --reverse
git -C /repo log -S delta
git -C /repo log HEAD~2
git -C /repo show 265ec3a
git -C /repo diff HEAD~1 HEAD
git -C /repo branch
```
`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
```bash
git -C /repo add -A
git -C /repo add src/main.py
git -C /repo add -u
git -C /repo reset
git -C /repo reset src/main.py
git -C /repo commit -m "message"
git -C /repo branch topic
git -C /repo branch -d topic
git -C /repo branch -D topic
git -C /repo checkout topic
```
| Verb | Notes |
| ---------- | ---------------------------------------------------------------- |
| `add` | `-A` everything, `-u` tracked only, `-f` to stage an ignored path |
| `reset` | mixed only, from HEAD; a pathspec limits it to those paths |
| `commit` | `-m` required, `--author "Name <email>"` optional |
| `branch` | `-d` deletes a merged branch, `-D` any branch |
| `checkout` | refuses rather than overwrite work you have not committed |
`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 network verbs.** `clone`, `fetch`, `pull` and `push` are absent.
- **No `reset --hard`.** It destroys uncommitted work with no reflog here
to recover it from.
- **`reset` takes 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.
- **`checkout` refuses 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.
+7 -4
View File
@@ -6,10 +6,12 @@ icon: terminal
Mounts make a service readable as files; CLIs make it actionable as a
program. A CLI is a typed program tree (`CLISpec`) installed on the
workspace by name, completely separate from any mount: it initializes
from its own config, consults no mount, and takes no operand path. The
shell dispatches a line to a CLI when its first word matches an
installed name.
workspace by name and separate from the mounts: an account CLI
initializes from its own config, consults no mount, and takes no operand
path. `git` is the credential-free tier, so it takes no config at all and
reads the repository `-C` names through the mount ops. The shell
dispatches a line to a CLI when its first word matches an installed
name.
```python
from mirage import Workspace
@@ -76,6 +78,7 @@ verb for it, so an agent cannot uninstall the tools it was given.
| [discord](/python/cli/discord) | Discord | OpenClaw Discord actions (`send`, `poll`, …)|
| [ntn](/python/cli/ntn) | Notion | official Notion CLI (`pages`, `datasources`)|
| [linear](/python/cli/linear) | Linear | noun/verb (`issue create`, `team list`) |
| [git](/python/cli/git) | git repositories | git (`status`, `log`, `add`, `commit`) |
Reading stays on the mount (`cat`, `grep`, `jq` over the virtual
files); acting goes through the CLI. The mounted tree's
+103
View File
@@ -0,0 +1,103 @@
---
title: git
description: Read and change a git repository that lives on any mount, in git's own vocabulary.
icon: terminal
---
Read and change a git repository that lives on any mount, in git's own
vocabulary. Unlike the account CLIs, `git` needs no credentials and takes
no config: it is a program tree with nothing to authenticate to, so
installing it is one line.
## Install
```typescript
import { GIT } from '@struktoai/mirage-core'
import { RAMResource, Workspace } from '@struktoai/mirage-node'
const ws = new Workspace({ '/repo': new RAMResource() })
ws.registerCli('git', GIT)
await ws.execute('git -C /repo status --short')
```
In YAML the same install rides the `clis:` section; see the
[CLI overview](/typescript/cli/index).
## 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.
`-C` defaults to the working directory, and the repository is found by
walking up from there, so a path inside the tree works:
```bash
git -C /repo/src status
```
## Verbs
### Inspect
```bash
git -C /repo status
git -C /repo status --short
git -C /repo status --porcelain -b
git -C /repo status -uall
git -C /repo log --oneline -n 20
git -C /repo log --reverse
git -C /repo log -S delta
git -C /repo log HEAD~2
git -C /repo show 265ec3a
git -C /repo diff HEAD~1 HEAD
git -C /repo branch
```
`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
```bash
git -C /repo add -A
git -C /repo add src/main.ts
git -C /repo add -u
git -C /repo reset
git -C /repo reset src/main.ts
git -C /repo commit -m "message"
git -C /repo branch topic
git -C /repo branch -d topic
git -C /repo branch -D topic
git -C /repo checkout topic
```
| Verb | Notes |
| ---------- | ---------------------------------------------------------------- |
| `add` | `-A` everything, `-u` tracked only, `-f` to stage an ignored path |
| `reset` | mixed only, from HEAD; a pathspec limits it to those paths |
| `commit` | `-m` required, `--author "Name <email>"` optional |
| `branch` | `-d` deletes a merged branch, `-D` any branch |
| `checkout` | refuses rather than overwrite work you have not committed |
`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 network verbs.** `clone`, `fetch`, `pull` and `push` are absent.
- **No `reset --hard`.** It destroys uncommitted work with no reflog here
to recover it from.
- **`reset` takes 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.
- **`checkout` refuses 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.
+7 -4
View File
@@ -6,10 +6,12 @@ icon: terminal
Mounts make a service readable as files; CLIs make it actionable as a
program. A CLI is a typed program tree (`CLISpec`) installed on the
workspace by name, completely separate from any mount: it initializes
from its own config, consults no mount, and takes no operand path. The
shell dispatches a line to a CLI when its first word matches an
installed name.
workspace by name and separate from the mounts: an account CLI
initializes from its own config, consults no mount, and takes no operand
path. `git` is the credential-free tier, so it takes no config at all and
reads the repository `-C` names through the mount ops. The shell
dispatches a line to a CLI when its first word matches an installed
name.
```typescript
import { EmailResource, HIMALAYA, Workspace } from '@struktoai/mirage-node'
@@ -75,6 +77,7 @@ verb for it, so an agent cannot uninstall the tools it was given.
| [discord](/typescript/cli/discord) | Discord | OpenClaw Discord actions (`send`, `poll`, …)|
| [ntn](/typescript/cli/ntn) | Notion | official Notion CLI (`pages`, `datasources`)|
| [linear](/typescript/cli/linear) | Linear | noun/verb (`issue create`, `team list`) |
| [git](/typescript/cli/git) | git repositories | git (`status`, `log`, `add`, `commit`) |
Reading stays on the mount (`cat`, `grep`, `jq` over the virtual
files); acting goes through the CLI. The mounted tree's
+1 -1
View File
@@ -63,7 +63,7 @@
],
"command": "grep hello sub/missing.txt 2>&1",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "grep: sub/missing.txt: No such file or directory\n",
"stderr": ""
}
+844
View File
@@ -0,0 +1,844 @@
{
"cases": [
{
"id": "git_log_oneline",
"seq": 568000,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log --oneline",
"expect": {
"exit": 0,
"stdout": "8ef2542 add two\n225f39c add docs\n265ec3a add delta\n98b4ffe first commit\n",
"stderr": ""
}
},
{
"id": "git_log_reverse",
"seq": 568001,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log --oneline --reverse",
"expect": {
"exit": 0,
"stdout": "98b4ffe first commit\n265ec3a add delta\n225f39c add docs\n8ef2542 add two\n",
"stderr": ""
}
},
{
"id": "git_log_limit",
"seq": 568002,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log --oneline -n 2",
"expect": {
"exit": 0,
"stdout": "8ef2542 add two\n225f39c add docs\n",
"stderr": ""
}
},
{
"id": "git_log_header",
"seq": 568003,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log -n 1 | head -3",
"expect": {
"exit": 0,
"stdout": "commit 8ef25423772bbfe671c8e285ae1d63991d59db19\nAuthor: Integ Author <integ@example.com>\nDate: Thu Jan 8 14:45:00 2026 +0000\n",
"stderr": ""
}
},
{
"id": "git_log_pickaxe_finds_the_introducing_commit",
"seq": 568004,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log -S delta --oneline --reverse",
"expect": {
"exit": 0,
"stdout": "265ec3a add delta\n",
"stderr": ""
}
},
{
"id": "git_log_pickaxe_misses_an_absent_string",
"seq": 568005,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log -S nowhere --oneline",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "git_log_revision_operand",
"seq": 568006,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log --oneline HEAD~2",
"expect": {
"exit": 0,
"stdout": "265ec3a add delta\n98b4ffe first commit\n",
"stderr": ""
}
},
{
"id": "git_show_reads_a_packed_object",
"seq": 568007,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo show 265ec3a | head -1",
"expect": {
"exit": 0,
"stdout": "commit 265ec3adb16a0c90ad09a180687677927b9d7b7f\n",
"stderr": ""
}
},
{
"id": "git_show_patch_body",
"seq": 568008,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo show 265ec3a | grep -F '+delta'",
"expect": {
"exit": 0,
"stdout": "+delta\n",
"stderr": ""
}
},
{
"id": "git_branch_lists_both",
"seq": 568009,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo branch",
"expect": {
"exit": 0,
"stdout": "* main\n topic\n",
"stderr": ""
}
},
{
"id": "git_status_names_the_branch",
"seq": 568010,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo status",
"expect": {
"exit": 0,
"stdout": "On branch main\nnothing to commit, working tree clean\n",
"stderr": ""
}
},
{
"id": "git_diff_between_revisions",
"seq": 568011,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo diff HEAD~1 HEAD | grep -F '+two'",
"expect": {
"exit": 0,
"stdout": "+two\n",
"stderr": ""
}
},
{
"id": "git_reads_through_the_shell_like_any_command",
"seq": 568012,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log --oneline | wc -l | tr -d ' '",
"expect": {
"exit": 0,
"stdout": "4\n",
"stderr": ""
}
},
{
"id": "git_status_clean_prints_nothing_in_porcelain",
"seq": 568013,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo status --porcelain",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "git_status_finds_an_edit",
"seq": 568014,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'edited\\n' > /repo/letters.txt && git -C /repo status --porcelain && printf 'alpha\\nbeta\\ngamma\\ndelta\\n' > /repo/letters.txt",
"expect": {
"exit": 0,
"stdout": " M letters.txt\n",
"stderr": ""
}
},
{
"id": "git_status_finds_an_edit_of_the_same_length",
"seq": 568015,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'alpha\\nbeta\\nGAMMA\\ndelta\\n' > /repo/letters.txt && git -C /repo status --porcelain && printf 'alpha\\nbeta\\ngamma\\ndelta\\n' > /repo/letters.txt",
"expect": {
"exit": 0,
"stdout": " M letters.txt\n",
"stderr": ""
}
},
{
"id": "git_status_finds_a_deletion",
"seq": 568016,
"targets": [
"git-disk",
"git-ram"
],
"command": "rm /repo/numbers.txt && git -C /repo status --porcelain && printf 'one\\ntwo\\n' > /repo/numbers.txt",
"expect": {
"exit": 0,
"stdout": " D numbers.txt\n",
"stderr": ""
}
},
{
"id": "git_status_names_an_untracked_file",
"seq": 568017,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'x\\n' > /repo/fresh.txt && git -C /repo status --porcelain && rm /repo/fresh.txt",
"expect": {
"exit": 0,
"stdout": "?? fresh.txt\n",
"stderr": ""
}
},
{
"id": "git_status_collapses_an_untracked_directory",
"seq": 568018,
"targets": [
"git-disk",
"git-ram"
],
"command": "mkdir -p /repo/sub && printf 'x\\n' > /repo/sub/deep.txt && git -C /repo status --porcelain && rm -r /repo/sub",
"expect": {
"exit": 0,
"stdout": "?? sub/\n",
"stderr": ""
}
},
{
"id": "git_status_untracked_all_descends",
"seq": 568019,
"targets": [
"git-disk",
"git-ram"
],
"command": "mkdir -p /repo/sub && printf 'x\\n' > /repo/sub/deep.txt && git -C /repo status --porcelain -uall && rm -r /repo/sub",
"expect": {
"exit": 0,
"stdout": "?? sub/deep.txt\n",
"stderr": ""
}
},
{
"id": "git_status_untracked_no_hides_them",
"seq": 568020,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'x\\n' > /repo/fresh.txt && git -C /repo status --porcelain -uno && rm /repo/fresh.txt",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "git_status_honors_gitignore",
"seq": 568021,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf '*.log\\n' > /repo/.gitignore && printf 'x\\n' > /repo/noisy.log && git -C /repo status --porcelain && rm /repo/.gitignore /repo/noisy.log",
"expect": {
"exit": 0,
"stdout": "?? .gitignore\n",
"stderr": ""
}
},
{
"id": "git_status_branch_line_is_opt_in",
"seq": 568022,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo status --porcelain -b",
"expect": {
"exit": 0,
"stdout": "## main\n",
"stderr": ""
}
},
{
"id": "git_status_long_format_sections",
"seq": 568023,
"targets": [
"git-disk",
"git-ram"
],
"command": "rm /repo/numbers.txt && printf 'x\\n' > /repo/fresh.txt && git -C /repo status && printf 'one\\ntwo\\n' > /repo/numbers.txt && rm /repo/fresh.txt",
"expect": {
"exit": 0,
"stdout": "On branch main\nChanges not staged for commit:\n (use \"git add/rm <file>...\" to update what will be committed)\n (use \"git restore <file>...\" to discard changes in working directory)\n\tdeleted: numbers.txt\n\nUntracked files:\n (use \"git add <file>...\" to include in what will be committed)\n\tfresh.txt\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n",
"stderr": ""
}
},
{
"id": "git_status_short_reads_like_porcelain",
"seq": 568024,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'edited\\n' > /repo/letters.txt && git -C /repo status -s && printf 'alpha\\nbeta\\ngamma\\ndelta\\n' > /repo/letters.txt",
"expect": {
"exit": 0,
"stdout": " M letters.txt\n",
"stderr": ""
}
},
{
"id": "git_add_stages_an_edit",
"seq": 568100,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'edited\\n' > /repo/letters.txt && git -C /repo add -A && git -C /repo status --porcelain && git -C /repo reset && printf 'alpha\\nbeta\\ngamma\\ndelta\\n' > /repo/letters.txt",
"expect": {
"exit": 0,
"stdout": "M letters.txt\nUnstaged changes after reset:\nM\tletters.txt\n",
"stderr": ""
}
},
{
"id": "git_add_then_commit_lands_on_the_branch",
"seq": 568101,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'edited\\n' > /repo/letters.txt && git -C /repo add -A && git -C /repo commit -m 'integ change' > /dev/null && git -C /repo log --oneline -n 1 | cut -d' ' -f2- && git -C /repo status --porcelain",
"expect": {
"exit": 0,
"stdout": "integ change\n",
"stderr": ""
}
},
{
"id": "git_commit_with_nothing_staged_prints_the_status",
"seq": 568102,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo status --porcelain && git -C /repo commit -m nope",
"expect": {
"exit": 1,
"stdout": "On branch main\nnothing to commit, working tree clean\n",
"stderr": ""
}
},
{
"id": "git_add_refuses_an_unknown_pathspec",
"seq": 568103,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo add nosuchfile.txt",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: pathspec 'nosuchfile.txt' did not match any files\n"
}
},
{
"id": "git_add_honors_gitignore",
"seq": 568104,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf '*.log\\n' > /repo/.gitignore && printf 'x\\n' > /repo/a.log && git -C /repo add a.log",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "The following paths are ignored by one of your .gitignore files:\na.log\nhint: Use -f if you really want to add them.\nhint: Disable this message with \"git config set advice.addIgnoredFile false\"\n"
}
},
{
"id": "git_add_gitignore_cleanup",
"seq": 568105,
"targets": [
"git-disk",
"git-ram"
],
"command": "rm /repo/.gitignore /repo/a.log",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "git_branch_creates_and_deletes",
"seq": 568106,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo branch integ-feature && git -C /repo branch && git -C /repo branch -d integ-feature > /dev/null",
"expect": {
"exit": 0,
"stdout": " integ-feature\n* main\n topic\n",
"stderr": ""
}
},
{
"id": "git_branch_refuses_a_name_that_exists",
"seq": 568107,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo branch topic",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: a branch named 'topic' already exists\n"
}
},
{
"id": "git_checkout_switches_the_working_tree",
"seq": 568108,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo checkout topic 2>/dev/null && cat /repo/numbers.txt && git -C /repo checkout main 2>/dev/null && cat /repo/numbers.txt",
"expect": {
"exit": 0,
"stdout": "one\none\ntwo\n",
"stderr": ""
}
},
{
"id": "git_checkout_refuses_to_lose_an_edit",
"seq": 568109,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'precious\\n' > /repo/numbers.txt && git -C /repo checkout topic",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "error: Your local changes to the following files would be overwritten by checkout:\n\tnumbers.txt\nPlease commit your changes or stash them before you switch branches.\nAborting\n"
}
},
{
"id": "git_checkout_conflict_cleanup",
"seq": 568110,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'one\\ntwo\\n' > /repo/numbers.txt",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "git_checkout_refuses_an_unknown_target",
"seq": 568111,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo checkout nosuchbranch",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "error: pathspec 'nosuchbranch' did not match any file(s) known to git\n"
}
},
{
"id": "git_mutation_verbs_refuse_an_unknown_switch",
"seq": 568112,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo add -Z",
"expect": {
"exit": 129,
"stdout": "",
"stderr": "error: unknown switch `Z'\n"
}
},
{
"id": "git_a_directory_holding_no_repository_is_fatal",
"seq": 568200,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C / status",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: not a git repository (or any of the parent directories): .git\n"
}
},
{
"id": "git_a_directory_that_is_not_there_is_a_different_fatal",
"seq": 568201,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /tmp log --oneline",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: cannot change to '/tmp': No such file or directory\n"
}
},
{
"id": "git_finds_the_repository_from_a_subdirectory",
"seq": 568202,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo/docs log --oneline -n 1 | cut -d' ' -f2-",
"expect": {
"exit": 0,
"stdout": "integ change\n",
"stderr": ""
}
},
{
"id": "git_log_rejects_an_unknown_revision",
"seq": 568203,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo log --oneline nosuchrev",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: ambiguous argument 'nosuchrev': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git <command> [<revision>...] -- [<file>...]'\n"
}
},
{
"id": "git_branch_refuses_to_delete_the_current_branch",
"seq": 568204,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo branch -d main",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "error: cannot delete branch 'main' used by worktree at '/repo'\n"
}
},
{
"id": "git_add_stages_only_the_named_pathspec",
"seq": 568205,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'x\\n' > /repo/note.txt && printf 'y\\n' > /repo/other.txt && git -C /repo add note.txt && git -C /repo status --porcelain",
"expect": {
"exit": 0,
"stdout": "A note.txt\n?? other.txt\n",
"stderr": ""
}
},
{
"id": "git_reset_unstages_only_the_named_pathspec",
"seq": 568206,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo reset note.txt && git -C /repo status --porcelain",
"expect": {
"exit": 0,
"stdout": "?? note.txt\n?? other.txt\n",
"stderr": ""
}
},
{
"id": "git_pathspec_cleanup",
"seq": 568207,
"targets": [
"git-disk",
"git-ram"
],
"command": "rm /repo/note.txt /repo/other.txt && git -C /repo status --porcelain",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "git_reports_a_directory_it_could_not_enter",
"seq": 568208,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo/nowhere log --oneline",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: cannot change to '/repo/nowhere': No such file or directory\n"
}
},
{
"id": "git_branch_d_refuses_an_unmerged_branch",
"seq": 568209,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo checkout -b side > /dev/null 2>&1 && printf 'sideways\\n' > /repo/letters.txt && git -C /repo add -A && git -C /repo commit -m sideways > /dev/null && git -C /repo checkout main > /dev/null 2>&1 && git -C /repo branch -d side",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "error: the branch 'side' is not fully merged\nhint: If you are sure you want to delete it, run 'git branch -D side'\n"
}
},
{
"id": "git_branch_force_delete_removes_an_unmerged_branch",
"seq": 568210,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo branch -D side | cut -d' ' -f1-3",
"expect": {
"exit": 0,
"stdout": "Deleted branch side\n",
"stderr": ""
}
},
{
"id": "git_checkout_refuses_to_overwrite_an_untracked_file",
"seq": 568211,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo checkout -b holder > /dev/null 2>&1 && printf 'branch\\n' > /repo/fresh.txt && git -C /repo add -A && git -C /repo commit -m fresh > /dev/null && git -C /repo checkout main > /dev/null 2>&1 && printf 'mine\\n' > /repo/fresh.txt && git -C /repo checkout holder",
"expect": {
"exit": 1,
"stdout": "",
"stderr": "error: The following untracked working tree files would be overwritten by checkout:\n\tfresh.txt\nPlease move or remove them before you switch branches.\nAborting\n"
}
},
{
"id": "git_checkout_untracked_conflict_cleanup",
"seq": 568212,
"targets": [
"git-disk",
"git-ram"
],
"command": "rm /repo/fresh.txt && git -C /repo branch -D holder > /dev/null && git -C /repo status --porcelain",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "git_add_update_stages_only_the_named_pathspec",
"seq": 568213,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'edited\\n' > /repo/numbers.txt && printf 'edited\\n' > /repo/docs/readme.md && git -C /repo add -u docs && git -C /repo status --porcelain",
"expect": {
"exit": 0,
"stdout": "M docs/readme.md\n M numbers.txt\n",
"stderr": ""
}
},
{
"id": "git_add_update_refuses_an_untracked_pathspec",
"seq": 568214,
"targets": [
"git-disk",
"git-ram"
],
"command": "printf 'z\\n' > /repo/loose.txt && git -C /repo add -u loose.txt",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "error: pathspec 'loose.txt' did not match any file(s) known to git\n"
}
},
{
"id": "git_add_update_cleanup",
"seq": 568215,
"targets": [
"git-disk",
"git-ram"
],
"command": "rm /repo/loose.txt && git -C /repo reset > /dev/null && printf 'one\\ntwo\\n' > /repo/numbers.txt && printf 'notes\\n' > /repo/docs/readme.md && git -C /repo status --porcelain",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "git_a_file_start_point_is_a_chdir_failure",
"seq": 568216,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo/letters.txt log --oneline",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: cannot change to '/repo/letters.txt': Not a directory\n"
}
},
{
"id": "git_checkout_creates_at_the_start_point",
"seq": 568217,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo checkout -b older HEAD~1 > /dev/null 2>&1 && git -C /repo log --oneline -n 1",
"expect": {
"exit": 0,
"stdout": "8ef2542 add two\n",
"stderr": ""
}
},
{
"id": "git_checkout_start_point_cleanup",
"seq": 568218,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo checkout main > /dev/null 2>&1 && git -C /repo branch -d older",
"expect": {
"exit": 0,
"stdout": "Deleted branch older (was 8ef2542).\n",
"stderr": ""
}
},
{
"id": "git_checkout_refuses_a_start_point_that_is_not_a_commit",
"seq": 568219,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo checkout -b shiny nosuchrev",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: 'nosuchrev' is not a commit and a branch 'shiny' cannot be created from it\n"
}
},
{
"id": "git_reset_refuses_a_pathspec_that_matches_nothing",
"seq": 568220,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo reset nosuch.txt",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: ambiguous argument 'nosuch.txt': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git <command> [<revision>...] -- [<file>...]'\n"
}
},
{
"id": "git_reset_says_which_feature_a_revision_operand_needs",
"seq": 568221,
"targets": [
"git-disk",
"git-ram"
],
"command": "git -C /repo reset HEAD~1",
"expect": {
"exit": 128,
"stdout": "",
"stderr": "fatal: cannot reset to 'HEAD~1': this build resets the index from HEAD only\n"
}
}
]
}
+4 -4
View File
@@ -122,12 +122,12 @@ async def check_read_family(ws: Workspace, dst: str, label: str) -> None:
_, err, code = await run(ws, f"cat {src} {miss}")
check(f"{label}: cat missing strerror", code == 1
and err == f"cat: {miss}: No such file or directory\n")
# grep still searches the good operand, but the missing operand's read
# error forces exit 1 even though the other operand matched (matching
# single-mount grep, which flattens fs errors to 1).
# grep still searches the good operand, and the missing operand still
# decides the exit status: GNU prints the lines it did find and exits 2
# anyway, so a match does not excuse an operand it could not read.
out, err, code = await run(ws, f"grep aaa {src} {miss}")
check(
f"{label}: grep missing strerror", code == 1 and f"{src}:aaa" in out
f"{label}: grep missing strerror", code == 2 and f"{src}:aaa" in out
and err == f"grep: {miss}: No such file or directory\n")
+4 -4
View File
@@ -180,13 +180,13 @@ async function checkReadFamily(
`${label}: cat missing strerror`,
cmiss[2] === 1 && cmiss[1] === `cat: ${miss}: No such file or directory\n`,
);
// grep still searches the good operand, but the missing operand's read
// error forces exit 1 even though the other operand matched (matching
// single-mount grep, which flattens fs errors to 1).
// grep still searches the good operand, and the missing operand still
// decides the exit status: GNU prints the lines it did find and exits 2
// anyway, so a match does not excuse an operand it could not read.
const gmiss = await run(ws, `grep aaa ${src} ${miss}`);
check(
`${label}: grep missing strerror`,
gmiss[2] === 1 &&
gmiss[2] === 2 &&
gmiss[0].includes(`${src}:aaa`) &&
gmiss[1] === `grep: ${miss}: No such file or directory\n`,
);
+1 -1
View File
@@ -26,7 +26,7 @@
],
"command": "grep o /data/a.txt /data2/xpr_nope.txt",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "/data/a.txt:hello\n/data/a.txt:world\n/data/a.txt:foo\n",
"stderr": "grep: /data2/xpr_nope.txt: No such file or directory\n"
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Build the git fixture repository into $1.
#
# Generated rather than committed, because a repository cannot hold
# another repository's .git: `git add` silently refuses any path with a
# .git component, so a checked-in tree would look staged and never be.
# Generating also buys two things a checked-in tree could not: the
# fixture is readable as a script instead of as zlib blobs, and the same
# repository can be handed to the real git binary to produce the truth
# a case is compared against.
#
# Every identity and timestamp is pinned, so the object ids are the same
# on every machine and every run and a case can name a commit directly.
# Verify with: build.sh a && build.sh b && diff <(git -C a log) <(git -C b log)
set -euo pipefail
DEST="${1:?destination directory}"
export GIT_AUTHOR_NAME="Integ Author"
export GIT_AUTHOR_EMAIL="integ@example.com"
export GIT_COMMITTER_NAME="Integ Author"
export GIT_COMMITTER_EMAIL="integ@example.com"
mkdir -p "$DEST"
# -b main: the default branch name is a per-machine config, and a
# fixture whose branch depends on the host is not a fixture.
git -C "$DEST" init -q -b main
# The exports above only reach this script's own commits. Record the same
# identity in the repository so a caller that commits into the fixture
# afterwards works on a machine with no global identity, which is every CI
# runner: git refuses with "Author identity unknown" and exits 128.
git -C "$DEST" config user.name "$GIT_AUTHOR_NAME"
git -C "$DEST" config user.email "$GIT_AUTHOR_EMAIL"
commit() {
local when="$1" message="$2"
GIT_AUTHOR_DATE="$when" GIT_COMMITTER_DATE="$when" \
git -C "$DEST" commit -q -m "$message"
}
printf 'alpha\nbeta\ngamma\n' > "$DEST/letters.txt"
printf 'one\n' > "$DEST/numbers.txt"
git -C "$DEST" add -A
commit "2026-01-05T10:00:00+0000" "first commit"
printf 'alpha\nbeta\ngamma\ndelta\n' > "$DEST/letters.txt"
git -C "$DEST" add -A
commit "2026-01-06T11:30:00+0000" "add delta"
mkdir -p "$DEST/docs"
printf 'notes\n' > "$DEST/docs/readme.md"
git -C "$DEST" add -A
commit "2026-01-07T09:15:00+0000" "add docs"
# Pack what exists so far, then commit once more. The fixture then holds
# both halves of an object database and exercises both read paths: the
# packfile window and a loose object fetched by id.
git -C "$DEST" repack -adq
printf 'one\ntwo\n' > "$DEST/numbers.txt"
git -C "$DEST" add -A
commit "2026-01-08T14:45:00+0000" "add two"
git -C "$DEST" branch -q topic HEAD~1
+1 -1
View File
@@ -21,7 +21,7 @@
],
"command": "grep x /knowledge/__nf_missing__.txt",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "",
"stderr": "grep: /knowledge/__nf_missing__.txt: No such file or directory\n"
}
+2 -2
View File
@@ -77,7 +77,7 @@
],
"command": "grep x {mount}/__nf_missing__",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "",
"stderr": "grep: {mount}/__nf_missing__: No such file or directory\n"
}
@@ -621,7 +621,7 @@
],
"command": "rg x {mount}/__nf_missing__",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "",
"stderr": "rg: {mount}/__nf_missing__: No such file or directory\n"
}
@@ -47,7 +47,7 @@
],
"command": "grep x /lf/traces/__nf_missing__.json",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "",
"stderr": "grep: /lf/traces/__nf_missing__.json: No such file or directory\n"
}
+25 -5
View File
@@ -2117,6 +2117,30 @@ def build_mounts(
return mounts, cleanups
def cli_install(service: "Service | None",
cli_name: str) -> tuple[CLISpec, dict[str, object] | None]:
"""The spec and config to install one CLI under its head word.
Every CLI here so far talks to an API, so its mock service hands
over both the tree and the credentials pointing at itself. `git` is
the first with neither: it reads a repository out of a mount, which
is what makes it installable from a bare name, so it resolves
through the registry the YAML ``clis:`` section uses and installs
with no config at all.
Args:
service (Service | None): the target's mock service, None for a
target that needs none.
cli_name (str): the head word the target declared.
"""
if service is None:
return cli_spec_for(cli_name), None
# Widen the assert when another service grows a CLI.
assert isinstance(service, (DiscordService, EmailService, GwsService,
LinearService, NotionService, SlackService))
return service.cli_installs()[cli_name]
async def mutate_write(shadow_ws: Workspace, path: str,
content: bytes) -> None:
await shadow_ws.ops.write(path, content)
@@ -2151,11 +2175,7 @@ async def open_target(
else:
ws = Workspace(mounts, mode=MountMode.WRITE, agent_id=agent_id)
for cli_name in target.get("clis", []):
# Widen the assert when another service grows a CLI.
assert isinstance(service,
(DiscordService, EmailService, GwsService,
LinearService, NotionService, SlackService))
spec, config = service.cli_installs()[cli_name]
spec, config = cli_install(service, cli_name)
ws.register_cli(cli_name, spec, config)
return ws, functools.partial(teardown_target, [ws], cleanups, service)
+40 -9
View File
@@ -14,6 +14,8 @@
import json
import os
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -52,19 +54,48 @@ def load_cases(root: Path) -> list[dict]:
return cases
def build_fixture(
base: Path) -> tuple[Path, tempfile.TemporaryDirectory
| None]:
"""Where a fixture's files are, building them first if it says to.
A fixture holding a ``build.sh`` generates its own contents into a
temporary directory instead of shipping them. Only git needs this
so far, and it needs it absolutely: a repository cannot hold another
repository's ``.git``, because ``git add`` silently refuses any path
with a ``.git`` component, so a checked-in tree would look staged
and never be. Generating also keeps the fixture readable as a script
rather than as zlib blobs.
Args:
base (Path): the fixture directory under integ/fixtures.
"""
script = base / "build.sh"
if not script.is_file():
return base, None
holder = tempfile.TemporaryDirectory(prefix="integ-fixture-")
built = Path(holder.name) / "root"
subprocess.run([str(script), str(built)], check=True)
return built, holder
async def seed_fixture(ws, fixture: str | None, mount_path: str,
root: Path) -> None:
if not fixture:
return
base = root / "fixtures" / fixture
for src in sorted(base.rglob("*")):
if not src.is_file():
continue
rel = src.relative_to(base).as_posix()
dest = f"{mount_path.rstrip('/')}/{rel}"
parent = dest.rsplit("/", 1)[0]
await ws.execute(f"mkdir -p {parent}")
await ws.execute(f"tee {dest} > /dev/null", stdin=src.read_bytes())
base, holder = build_fixture(root / "fixtures" / fixture)
try:
for src in sorted(base.rglob("*")):
if not src.is_file():
continue
rel = src.relative_to(base).as_posix()
dest = f"{mount_path.rstrip('/')}/{rel}"
parent = dest.rsplit("/", 1)[0]
await ws.execute(f"mkdir -p {parent}")
await ws.execute(f"tee {dest} > /dev/null", stdin=src.read_bytes())
finally:
if holder is not None:
holder.cleanup()
async def seed_mount_root(ws, mount_path: str) -> None:
+15
View File
@@ -49,6 +49,7 @@ import {
DISCORD,
GWS,
HIMALAYA,
GIT,
LINEAR,
NTN,
SLACK,
@@ -159,6 +160,18 @@ function runId(): string {
return `${String(process.pid)}-${String(Date.now())}`
}
/**
* Install the CLIs a mount-backed target declares.
*
* `git` is the first CLI here that talks to no API: it reads a repository out of
* a mount, which is what makes it installable from a bare name with no config at
* all, where every other one needs its mock service to hand over both the tree
* and the credentials pointing at itself.
*/
function installLocalClis(ws: { registerCli: (name: string, spec: unknown) => void }, target: Target): void {
if (target.clis?.includes('git') === true) ws.registerCli('git', GIT)
}
async function openRam(target: Target): Promise<Open> {
const mounts: Record<string, RAMResource | [RAMResource, MountMode]> = {}
const built: Record<string, RAMResource> = {}
@@ -178,6 +191,7 @@ async function openRam(target: Target): Promise<Open> {
mode: MountMode.WRITE,
...(target.agentId !== undefined ? { agentId: target.agentId } : {}),
})
installLocalClis(ws, target)
return { ws: ws as unknown as ExecWorkspace, cleanup: () => ws.close() }
}
@@ -191,6 +205,7 @@ async function openDisk(target: Target): Promise<Open> {
mounts[m.path] = m.mode === 'read' ? [resource, MountMode.READ] : resource
}
const ws = new Workspace(mounts, { mode: MountMode.WRITE })
installLocalClis(ws, target)
const cleanup = async (): Promise<void> => {
await ws.close()
for (const root of roots) rmSync(root, { recursive: true, force: true })
+32 -2
View File
@@ -12,7 +12,9 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -175,6 +177,26 @@ export function walkFiles(base: string): string[] {
return out
}
/**
* Where a fixture's files are, building them first if it says to.
*
* A fixture holding a `build.sh` generates its own contents into a temporary
* directory instead of shipping them. Only git needs this so far, and it needs
* it absolutely: a repository cannot hold another repository's `.git`, because
* `git add` silently refuses any path with a `.git` component, so a checked-in
* tree would look staged and never be. Generating also keeps the fixture
* readable as a script rather than as zlib blobs.
*
* The caller owns the temporary directory when one is returned.
*/
export function buildFixture(base: string): [string, string | null] {
const script = join(base, 'build.sh')
if (!existsSync(script)) return [base, null]
const built = mkdtempSync(join(tmpdir(), 'mirage-integ-fixture-'))
execFileSync('bash', [script, join(built, 'repo')], { stdio: 'ignore' })
return [join(built, 'repo'), built]
}
export async function seedFixture(
ws: ExecWorkspace,
fixture: string | undefined,
@@ -182,7 +204,15 @@ export async function seedFixture(
root: string,
): Promise<void> {
if (!fixture) return
const base = join(root, 'fixtures', fixture)
const [base, built] = buildFixture(join(root, 'fixtures', fixture))
try {
await seedFrom(ws, base, mountPath)
} finally {
if (built !== null) rmSync(built, { recursive: true, force: true })
}
}
async function seedFrom(ws: ExecWorkspace, base: string, mountPath: string): Promise<void> {
for (const file of walkFiles(base)) {
const rel = relative(base, file).split(sep).join('/')
const dest = `${mountPath.replace(/\/+$/, '')}/${rel}`
+36
View File
@@ -1746,6 +1746,42 @@
"mode": "read"
}
]
},
{
"id": "git-disk",
"hosts": [
"python",
"typescript-node"
],
"clis": [
"git"
],
"mounts": [
{
"path": "/repo",
"resource": "disk",
"backend": "tmpdir",
"fixture": "git"
}
]
},
{
"id": "git-ram",
"hosts": [
"python",
"typescript-node"
],
"clis": [
"git"
],
"mounts": [
{
"path": "/repo",
"resource": "ram",
"backend": "memory",
"fixture": "git"
}
]
}
]
}
+202
View File
@@ -0,0 +1,202 @@
{
"cases": [
{
"id": "grep_bre_plus_is_literal",
"seq": 500300,
"targets": ["ram", "disk"],
"command": "printf 'a+b\\naab\\n' > /data/bre.txt && grep 'a+b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "a+b\n",
"stderr": ""
}
},
{
"id": "grep_bre_escaped_plus_is_the_quantifier",
"seq": 500301,
"targets": ["ram", "disk"],
"command": "printf 'a+b\\naab\\n' > /data/bre.txt && grep 'a\\+b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "aab\n",
"stderr": ""
}
},
{
"id": "grep_bre_question_is_literal",
"seq": 500302,
"targets": ["ram", "disk"],
"command": "printf 'a?b\\nab\\n' > /data/bre.txt && grep 'a?b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "a?b\n",
"stderr": ""
}
},
{
"id": "grep_bre_pipe_is_literal",
"seq": 500303,
"targets": ["ram", "disk"],
"command": "printf 'a|b\\nab\\n' > /data/bre.txt && grep 'a|b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "a|b\n",
"stderr": ""
}
},
{
"id": "grep_bre_escaped_pipe_alternates",
"seq": 500304,
"targets": ["ram", "disk"],
"command": "printf 'xa\\nyb\\nzc\\n' > /data/bre.txt && grep 'a\\|b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "xa\nyb\n",
"stderr": ""
}
},
{
"id": "grep_bre_braces_are_literal",
"seq": 500305,
"targets": ["ram", "disk"],
"command": "printf 'a{2}\\naa\\n' > /data/bre.txt && grep 'a{2}' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "a{2}\n",
"stderr": ""
}
},
{
"id": "grep_bre_escaped_braces_are_the_interval",
"seq": 500306,
"targets": ["ram", "disk"],
"command": "printf 'a{2}\\naa\\n' > /data/bre.txt && grep 'a\\{2\\}' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "aa\n",
"stderr": ""
}
},
{
"id": "grep_bre_parens_are_literal",
"seq": 500307,
"targets": ["ram", "disk"],
"command": "printf '(ab)\\nab\\n' > /data/bre.txt && grep '(ab)' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "(ab)\n",
"stderr": ""
}
},
{
"id": "grep_bre_caret_mid_pattern_is_literal",
"seq": 500308,
"targets": ["ram", "disk"],
"command": "printf 'a^b\\nab\\n' > /data/bre.txt && grep 'a^b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "a^b\n",
"stderr": ""
}
},
{
"id": "grep_bre_dollar_mid_pattern_is_literal",
"seq": 500309,
"targets": ["ram", "disk"],
"command": "printf 'a$b\\nab\\n' > /data/bre.txt && grep 'a$b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "a$b\n",
"stderr": ""
}
},
{
"id": "grep_bre_star_with_nothing_to_repeat_is_literal",
"seq": 500310,
"targets": ["ram", "disk"],
"command": "printf '*abc\\nabc\\n' > /data/bre.txt && grep '*abc' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "*abc\n",
"stderr": ""
}
},
{
"id": "grep_bre_backreference",
"seq": 500311,
"targets": ["ram", "disk"],
"command": "printf 'aa\\nab\\n' > /data/bre.txt && grep '\\(a\\)\\1' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "aa\n",
"stderr": ""
}
},
{
"id": "grep_G_is_the_default_spelled_out",
"seq": 500312,
"targets": ["ram", "disk"],
"command": "printf 'a+b\\naab\\n' > /data/bre.txt && grep -G 'a+b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "a+b\n",
"stderr": ""
}
},
{
"id": "grep_E_still_reads_extended",
"seq": 500313,
"targets": ["ram", "disk"],
"command": "printf 'a+b\\naab\\n' > /data/bre.txt && grep -E 'a+b' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "aab\n",
"stderr": ""
}
},
{
"id": "grep_bre_anchors_still_anchor",
"seq": 500314,
"targets": ["ram", "disk"],
"command": "printf 'ab\\nxab\\n' > /data/bre.txt && grep '^ab' /data/bre.txt",
"expect": {
"exit": 0,
"stdout": "ab\n",
"stderr": ""
}
},
{
"id": "grep_bre_files_only_reads_the_same_dialect",
"seq": 500315,
"targets": ["ram", "disk"],
"command": "printf 'a+b\\n' > /data/bhit.txt && printf 'aab\\n' > /data/bmiss.txt && grep -l 'a+b' /data/bhit.txt /data/bmiss.txt",
"expect": {
"exit": 0,
"stdout": "/data/bhit.txt\n",
"stderr": ""
}
},
{
"id": "grep_bre_files_only_recursive_reads_the_same_dialect",
"seq": 500316,
"targets": ["ram", "disk"],
"command": "mkdir -p /data2/bre && printf 'a+b\\n' > /data2/bre/hit.txt && printf 'aab\\n' > /data2/bre/miss.txt && grep -rl 'a+b' /data2/bre",
"expect": {
"exit": 0,
"stdout": "/data2/bre/hit.txt\n",
"stderr": ""
}
},
{
"id": "grep_E_files_only_still_reads_extended",
"seq": 500317,
"targets": ["ram", "disk"],
"command": "printf 'a+b\\n' > /data/bhit.txt && printf 'aab\\n' > /data/bmiss.txt && grep -lE 'a+b' /data/bhit.txt /data/bmiss.txt",
"expect": {
"exit": 0,
"stdout": "/data/bmiss.txt\n",
"stderr": ""
}
}
]
}
+308 -2
View File
@@ -242,7 +242,7 @@
],
"command": "grep hello /data/sub",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "",
"stderr": "grep: /data/sub: Is a directory\n"
}
@@ -276,10 +276,316 @@
],
"command": "grep hello /data/a.txt /data/sub",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "/data/a.txt:hello\n",
"stderr": "grep: /data/sub: Is a directory\n"
}
},
{
"id": "grep_files_only_dir_among_files",
"seq": 612,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "grep -l hello /data/a.txt /data/sub",
"expect": {
"exit": 2,
"stdout": "/data/a.txt\n",
"stderr": "grep: /data/sub: Is a directory\n"
}
},
{
"id": "grep_files_only_missing_operand",
"seq": 613,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "grep -l hello /data/nosuch.txt",
"expect": {
"exit": 2,
"stdout": "",
"stderr": "grep: /data/nosuch.txt: No such file or directory\n"
}
},
{
"id": "grep_quiet_dir_operand",
"seq": 614,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "grep -q hello /data/sub",
"expect": {
"exit": 2,
"stdout": "",
"stderr": "grep: /data/sub: Is a directory\n"
}
},
{
"id": "grep_quiet_match_outranks_a_failed_operand",
"seq": 615,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "grep -q hello /data/sub /data/a.txt",
"expect": {
"exit": 0,
"stdout": "",
"stderr": "grep: /data/sub: Is a directory\n"
}
},
{
"id": "grep_recursive_missing_operand",
"seq": 616,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "grep -r hello /data/nosuch.txt",
"expect": {
"exit": 2,
"stdout": "",
"stderr": "grep: /data/nosuch.txt: No such file or directory\n"
}
},
{
"id": "grep_walk_provision",
"seq": 618,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "mkdir -p /data/gwalk && printf 'nested\\n' > /data/gwalk/inner.txt",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "grep_files_only_dir_operand_is_not_walked",
"seq": 619,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "grep -l nested /data/gwalk",
"expect": {
"exit": 2,
"stdout": "",
"stderr": "grep: /data/gwalk: Is a directory\n"
}
},
{
"id": "grep_recursive_still_walks_a_dir_operand",
"seq": 620,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "grep -rl nested /data/gwalk",
"expect": {
"exit": 0,
"stdout": "/data/gwalk/inner.txt\n",
"stderr": ""
}
},
{
"id": "grep_walk_cleanup",
"seq": 621,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"databricks",
"gridfs",
"s3-prefix",
"databricks-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"sharepoint-prefix",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "rm -r /data/gwalk",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
}
]
}
+2 -2
View File
@@ -29,7 +29,7 @@
],
"command": "grep x /data/missing.txt",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "",
"stderr": "grep: /data/missing.txt: No such file or directory\n"
}
@@ -63,7 +63,7 @@
],
"command": "(cd /data && grep -r x missing)",
"expect": {
"exit": 1,
"exit": 2,
"stdout": "",
"stderr": "grep: missing: No such file or directory\n"
}
+144
View File
@@ -0,0 +1,144 @@
{
"cases": [
{
"id": "zgrep_bre_provision",
"seq": 500520,
"targets": [
"ram",
"disk"
],
"command": "mkdir -p /data/zbre && printf 'aab\\na+b\\n' > /data/zbre/both.txt && printf 'aab\\n' > /data/zbre/ere.txt && printf 'a+b\\n' > /data/zbre/bre.txt && gzip /data/zbre/both.txt /data/zbre/ere.txt /data/zbre/bre.txt",
"expect": {
"exit": 0,
"stdout": "",
"stderr": ""
}
},
{
"id": "zgrep_reads_a_basic_expression",
"seq": 500521,
"targets": [
"ram",
"disk"
],
"command": "zgrep 'a+b' /data/zbre/both.txt.gz",
"expect": {
"exit": 0,
"stdout": "a+b\n",
"stderr": ""
}
},
{
"id": "zgrep_E_reads_an_extended_expression",
"seq": 500522,
"targets": [
"ram",
"disk"
],
"command": "zgrep -E 'a+b' /data/zbre/both.txt.gz",
"expect": {
"exit": 0,
"stdout": "aab\n",
"stderr": ""
}
},
{
"id": "zgrep_G_asks_for_the_default_dialect",
"seq": 500523,
"targets": [
"ram",
"disk"
],
"command": "zgrep -G 'a+b' /data/zbre/both.txt.gz",
"expect": {
"exit": 0,
"stdout": "a+b\n",
"stderr": ""
}
},
{
"id": "zgrep_bre_backslash_plus_is_the_operator",
"seq": 500524,
"targets": [
"ram",
"disk"
],
"command": "zgrep 'a\\+b' /data/zbre/both.txt.gz",
"expect": {
"exit": 0,
"stdout": "aab\n",
"stderr": ""
}
},
{
"id": "zgrep_bre_pipe_is_literal",
"seq": 500525,
"targets": [
"ram",
"disk"
],
"command": "zgrep 'aab|zzz' /data/zbre/both.txt.gz",
"expect": {
"exit": 1,
"stdout": "",
"stderr": ""
}
},
{
"id": "zgrep_E_pipe_is_alternation",
"seq": 500526,
"targets": [
"ram",
"disk"
],
"command": "zgrep -E 'aab|zzz' /data/zbre/both.txt.gz",
"expect": {
"exit": 0,
"stdout": "aab\n",
"stderr": ""
}
},
{
"id": "zgrep_l_reads_the_same_dialect",
"seq": 500527,
"targets": [
"ram",
"disk"
],
"command": "zgrep -l 'a+b' /data/zbre/ere.txt.gz /data/zbre/bre.txt.gz",
"expect": {
"exit": 0,
"stdout": "/data/zbre/bre.txt.gz\n",
"stderr": ""
}
},
{
"id": "zgrep_c_reads_the_same_dialect",
"seq": 500528,
"targets": [
"ram",
"disk"
],
"command": "zgrep -c 'a+b' /data/zbre/both.txt.gz",
"expect": {
"exit": 0,
"stdout": "1\n",
"stderr": ""
}
},
{
"id": "zgrep_o_reads_the_same_dialect",
"seq": 500529,
"targets": [
"ram",
"disk"
],
"command": "zgrep -o 'a+b' /data/zbre/both.txt.gz",
"expect": {
"exit": 0,
"stdout": "a+b\n",
"stderr": ""
}
}
]
}
+1
View File
@@ -34,6 +34,7 @@ from mirage.core.box.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_stream,
stat=_stat,
is_dir_name=lambda _accessor, child: _is_dir_name(child),
@@ -33,6 +33,7 @@ from mirage.core.databricks_volume.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: True,
@@ -23,6 +23,7 @@ from mirage.core.disk.exists import exists as _exists
from mirage.core.disk.find import find as _find
from mirage.core.disk.mkdir import mkdir as _mkdir
from mirage.core.disk.read import read_bytes as _read
from mirage.core.disk.read import read_range as _read_range
from mirage.core.disk.readdir import readdir as _readdir
from mirage.core.disk.rename import rename as _rename
from mirage.core.disk.rm import rm_r as _rm_r
@@ -37,6 +38,7 @@ from mirage.core.disk.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read_range,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: a.root is not None,
@@ -33,6 +33,7 @@ from mirage.core.dropbox.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_stream,
stat=_stat,
is_mounted=lambda a: True,
@@ -42,6 +42,7 @@ from mirage.core.gdrive.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=partial(stream_from_bytes, _read),
stat=_stat,
is_dir_name=lambda _accessor, child: _is_dir_name(child),
+46 -38
View File
@@ -7,8 +7,8 @@ from mirage.cache.read_through import (cache_aware_bound_bytes,
cache_aware_bound_stream)
from mirage.commands.builtin.grep_helper import ( # yapf: disable
compile_pattern, count_exit_stream, count_records_have_matches,
grep_files_only, grep_lines, grep_recursive, grep_stream, prefix_lines,
resolve_pattern)
exit_code_for, grep_files_only, grep_lines, grep_recursive, grep_stream,
prefix_lines, resolve_pattern)
from mirage.commands.builtin.utils.lines import split_lines
from mirage.commands.builtin.utils.output import (format_optional_records,
format_records)
@@ -35,6 +35,7 @@ class GrepFlags:
files_only: bool
whole_word: bool
fixed_string: bool
basic_regexp: bool
only_matching: bool
quiet: bool
recursive: bool
@@ -64,6 +65,9 @@ def parse_flags(fl: FlagView, never_match: bool) -> GrepFlags:
files_only=fl.as_bool("args_l"),
whole_word=fl.as_bool("w"),
fixed_string=fl.as_bool("F") and not never_match,
# grep reads a basic expression unless -E says
# otherwise; -G asks for the default explicitly.
basic_regexp=not fl.as_bool("E"),
only_matching=fl.as_bool("o"),
quiet=fl.as_bool("q"),
recursive=fl.as_bool("r") or fl.as_bool("R"),
@@ -142,25 +146,25 @@ async def grep(
only_matching=f.only_matching,
max_count=f.max_count,
whole_word=f.whole_word,
basic=f.basic_regexp,
warnings=warnings,
read_stream_fn=None,
)
results.extend(respell_raw(hits, p.virtual, p.raw_path))
stderr = format_optional_records(warnings)
if f.quiet:
return b"", IOResult(exit_code=0 if results else 1,
stderr=stderr)
if not results:
return b"", IOResult(exit_code=1, stderr=stderr)
# A failed operand fails the command (deliberate divergence:
# GNU grep uses exit 2 for errors, mirage flattens fs errors
# to 1); -q above keeps GNU's match-wins rule.
return format_records(results), IOResult(
exit_code=1 if warnings else 0, stderr=stderr)
# Under -c a result is a count, and a zero count is not a match,
# so emptiness alone cannot decide the exit status.
matched = bool(results) and (not f.count_only or
count_records_have_matches(results))
code = exit_code_for(matched, bool(warnings), f.quiet)
if f.quiet or not results:
return b"", IOResult(exit_code=code, stderr=stderr)
return format_records(results), IOResult(exit_code=code,
stderr=stderr)
if f.recursive:
pat = compile_pattern(pattern, f.ignore_case, f.fixed_string,
f.whole_word)
f.whole_word, f.basic_regexp)
# OPTIMIZATION (see #207): this buffers every match into
# all_results and returns it materialized, so
# `grep -r PATTERN dir | head -n 3`
@@ -172,7 +176,12 @@ async def grep(
all_results: list[str] = []
warnings = []
for p in paths:
s = await st(p.virtual)
try:
s = await st(p.virtual)
except FileNotFoundError:
warnings.append(
f"grep: {p.raw_path}: No such file or directory")
continue
if s.type == FileType.DIRECTORY:
res = await grep_recursive(
rd,
@@ -205,19 +214,14 @@ async def grep(
stderr = format_optional_records(warnings)
matched = bool(all_results) and (
not f.count_only or count_records_have_matches(all_results))
if f.quiet:
return b"", IOResult(exit_code=0 if matched else 1,
stderr=stderr)
if not all_results:
return b"", IOResult(exit_code=1, stderr=stderr)
if not matched:
return format_records(all_results), IOResult(exit_code=1,
stderr=stderr)
return format_records(all_results), IOResult(
exit_code=1 if warnings else 0, stderr=stderr)
code = exit_code_for(matched, bool(warnings), f.quiet)
if f.quiet or not all_results:
return b"", IOResult(exit_code=code, stderr=stderr)
return format_records(all_results), IOResult(exit_code=code,
stderr=stderr)
pat = compile_pattern(pattern, f.ignore_case, f.fixed_string,
f.whole_word)
f.whole_word, f.basic_regexp)
if len(paths) > 1:
all_results = []
@@ -249,21 +253,24 @@ async def grep(
stderr = format_optional_records(multi_warnings)
matched = bool(all_results) and (
not f.count_only or count_records_have_matches(all_results))
if f.quiet:
return b"", IOResult(exit_code=0 if matched else 1,
stderr=stderr)
if not all_results:
return b"", IOResult(exit_code=1, stderr=stderr)
if not matched:
return format_records(all_results), IOResult(exit_code=1,
stderr=stderr)
return format_records(all_results), IOResult(
exit_code=1 if multi_warnings else 0, stderr=stderr)
code = exit_code_for(matched, bool(multi_warnings), f.quiet)
if f.quiet or not all_results:
return b"", IOResult(exit_code=code, stderr=stderr)
return format_records(all_results), IOResult(exit_code=code,
stderr=stderr)
first_stat = await st(paths[0].virtual)
# An unreadable operand is grep's own error to report, not the
# dispatcher's: the shared handler flattens every OSError to exit 1,
# which is right for cat and wrong for grep.
try:
first_stat = await st(paths[0].virtual)
except FileNotFoundError:
stderr = (f"grep: {paths[0].raw_path}: "
"No such file or directory\n").encode()
return b"", IOResult(exit_code=2, stderr=stderr)
if first_stat.type == FileType.DIRECTORY:
stderr = f"grep: {paths[0].raw_path}: Is a directory\n".encode()
return b"", IOResult(exit_code=1, stderr=stderr)
return b"", IOResult(exit_code=2, stderr=stderr)
if read_stream is not None:
source: AsyncIterator[bytes] = read_stream(paths[0])
@@ -296,7 +303,8 @@ async def grep(
source = _resolve_source(stdin,
"grep: usage: grep [flags] pattern [path]",
error_cls=UsageError)
pat = compile_pattern(pattern, f.ignore_case, f.fixed_string, f.whole_word)
pat = compile_pattern(pattern, f.ignore_case, f.fixed_string, f.whole_word,
f.basic_regexp)
stream = grep_stream(
source,
pat,
+23 -18
View File
@@ -5,11 +5,9 @@ from functools import partial
from mirage.cache.read_through import (cache_aware_bound_bytes,
cache_aware_bound_stream)
from mirage.commands.builtin.grep_helper import (compile_pattern,
grep_count_has_matches,
grep_lines, grep_stream,
nonzero_count_stream,
resolve_pattern)
from mirage.commands.builtin.grep_helper import ( # yapf: disable
compile_pattern, exit_code_for, grep_count_has_matches, grep_lines,
grep_stream, nonzero_count_stream, resolve_pattern)
from mirage.commands.builtin.rg_helper import rg_full
from mirage.commands.builtin.utils.lines import split_lines
from mirage.commands.builtin.utils.output import (format_optional_records,
@@ -134,17 +132,20 @@ async def rg(
rb = partial(call_read_bytes, read_bytes, prefix=mount_prefix)
is_dir = False
unreadable: BaseException | None = None
try:
s = await st(paths[0].virtual)
is_dir = s.type == FileType.DIRECTORY
except WALK_ERRORS:
except WALK_ERRORS as exc:
try:
await rd(paths[0].virtual)
is_dir = True
except WALK_ERRORS:
# Neither statable nor listable: keep is_dir False and let
# the plain-file search path report the real read error.
pass
# Neither statable nor listable: keep is_dir False and
# carry the error, which the single-operand path below
# reports as ripgrep's own rather than letting the shared
# handler flatten it to exit 1.
unreadable = exc
# ripgrep labels when searching multiple files; -H forces the label
# for a single file and -I suppresses it (cross-mount fanout forces
@@ -182,13 +183,11 @@ async def rg(
)
results.extend(respell_raw(hits_full, p.virtual, p.raw_path))
stderr = format_optional_records(warnings_f)
code = exit_code_for(bool(results), bool(warnings_f), False)
if not results:
return b"", IOResult(exit_code=1, stderr=stderr)
# A failed operand fails the command (deliberate divergence:
# ripgrep uses exit 2 for errors, mirage flattens fs errors
# to 1).
return format_records(results), IOResult(
exit_code=1 if warnings_f else 0, stderr=stderr)
return b"", IOResult(exit_code=code, stderr=stderr)
return format_records(results), IOResult(exit_code=code,
stderr=stderr)
pat = compile_pattern(pattern, f.ignore_case, f.fixed_string,
f.whole_word)
@@ -219,10 +218,16 @@ async def rg(
else:
all_results.extend(hits)
stderr = format_optional_records(warnings)
code = exit_code_for(bool(all_results), bool(warnings), False)
if not all_results:
return b"", IOResult(exit_code=1, stderr=stderr)
return format_records(all_results), IOResult(
exit_code=1 if warnings else 0, stderr=stderr)
return b"", IOResult(exit_code=code, stderr=stderr)
return format_records(all_results), IOResult(exit_code=code,
stderr=stderr)
if unreadable is not None:
stderr = (f"rg: {paths[0].raw_path}: "
f"{fs_strerror(unreadable)}\n").encode()
return b"", IOResult(exit_code=2, stderr=stderr)
if read_stream is not None:
source: AsyncIterator[bytes] = read_stream(paths[0])
@@ -92,6 +92,7 @@ class ZgrepFlags:
files_only: bool
line_numbers: bool
fixed: bool
basic_regexp: bool
force_filename: bool
suppress_filename: bool
only_matching: bool
@@ -115,6 +116,9 @@ def parse_flags(fl: FlagView, never_match: bool) -> ZgrepFlags:
files_only=fl.as_bool("args_l"),
line_numbers=fl.as_bool("n"),
fixed=fl.as_bool("F") and not never_match,
# zgrep is grep over decompressed bytes, so it reads a basic
# expression unless -E says otherwise; -G asks for the default.
basic_regexp=not fl.as_bool("E"),
force_filename=fl.as_bool("H"),
suppress_filename=fl.as_bool("h"),
only_matching=fl.as_bool("o"),
@@ -137,7 +141,8 @@ async def zgrep(
texts, fl, partial(_read_plain, read_bytes),
"zgrep: usage: zgrep [flags] pattern [path]")
f = parse_flags(fl, never_match)
compiled = build_pattern_str(pattern, f.fixed, f.whole_word)
compiled = build_pattern_str(pattern, f.fixed, f.whole_word,
f.basic_regexp)
multi = len(paths) > 1
show_filename = f.force_filename or (multi and not f.suppress_filename)
any_match = False
@@ -129,6 +129,15 @@ class CommandIO:
is_mounted: OperationFn
local: bool = True
max_glob_matches: int | None = DEFAULT_MAX_GLOB_MATCHES
# Fetch a byte range without pulling the whole object. Absent means
# the generic read fetches everything and slices, which is correct
# everywhere and is the only meaningful behavior for a backend that
# renders its content rather than storing it: there is no remote
# range to ask for when the bytes do not exist until we make them.
# Called as (accessor, path, index, offset, size), so most backends
# point it at their own read_bytes, which already takes the window;
# disk needs a separate function because its read_bytes does not.
read_range: OperationFn | None = None
write: OperationFn | None = None
exists: OperationFn | None = None
mkdir: OperationFn | None = None
+148 -69
View File
@@ -26,7 +26,8 @@ from mirage.commands.resolve import COMPOUND_EXTENSIONS
from mirage.commands.spec.types import FlagView
from mirage.io.async_line_iterator import AsyncLineIterator
from mirage.io.types import IOResult
from mirage.types import FileType, PathSpec
from mirage.types import FileStat, FileType, PathSpec
from mirage.utils.bre import bre_to_python
from mirage.utils.errors import WALK_ERRORS
from mirage.utils.key_prefix import mount_prefix_of
@@ -297,10 +298,25 @@ def merge_pattern_list(
return "\n".join(parts)
def _source_of(part: str, fixed_string: bool, basic: bool) -> str:
"""One pattern's regex source, in the syntax it was written in.
Args:
part (str): a single pattern from the list.
fixed_string (bool): True if -F flag is set.
basic (bool): True when the pattern is a basic regular
expression (grep's default), False for an extended one.
"""
if fixed_string:
return re.escape(part)
return bre_to_python(part) if basic else part
def build_pattern_str(
pattern: str,
fixed_string: bool = False,
whole_word: bool = False,
basic: bool = False,
) -> str:
"""Build a regex source string from a POSIX pattern list.
@@ -309,19 +325,24 @@ def build_pattern_str(
any of the patterns matches.
fixed_string (bool): True if -F flag is set.
whole_word (bool): True if -w flag is set.
basic (bool): True when the patterns are basic regular
expressions, which grep reads by default and which invert
most of Python's operators. False leaves them alone, which
is right for -E and for rg's own dialect.
Returns:
str: regex source string.
"""
parts = pattern.split("\n")
if len(parts) == 1:
pat_str = re.escape(pattern) if fixed_string else pattern
pat_str = _source_of(pattern, fixed_string, basic)
if whole_word:
pat_str = r"\b" + pat_str + r"\b"
return pat_str
subs: list[str] = []
for part in parts:
sub = re.escape(part) if fixed_string else f"(?:{part})"
source = _source_of(part, fixed_string, basic)
sub = source if fixed_string else f"(?:{source})"
if whole_word:
sub = r"\b" + sub + r"\b"
subs.append(sub)
@@ -333,10 +354,20 @@ def compile_pattern(
ignore_case: bool = False,
fixed_string: bool = False,
whole_word: bool = False,
basic: bool = False,
) -> re.Pattern[str]:
"""Compile a pattern list into one matcher.
Args:
pattern (str): newline-separated pattern list.
ignore_case (bool): True if -i flag is set.
fixed_string (bool): True if -F flag is set.
whole_word (bool): True if -w flag is set.
basic (bool): True for a basic regular expression.
"""
flags = re.IGNORECASE if ignore_case else 0
return re.compile(build_pattern_str(pattern, fixed_string, whole_word),
flags)
return re.compile(
build_pattern_str(pattern, fixed_string, whole_word, basic), flags)
def get_extension(path: str) -> str | None:
@@ -643,6 +674,83 @@ async def grep_recursive(
return results
async def operand_is_directory(
readdir_fn: _AsyncReaddir,
info: FileStat | None,
path: str,
) -> bool:
"""Whether an operand names a directory, asked on both channels.
Both channels are consulted because on a prefix store a directory is
the set of keys under it rather than an object, so stat misses one
that readdir lists happily. The listing has to be non-empty to count:
such a store answers readdir for any path at all, returning nothing
for one that does not exist, so a bare "it did not raise" reads every
missing file as a directory. The cost is that a genuinely empty
directory is invisible there, which is the same thing ``du`` already
documents and the safer way round: naming a missing file is a report
a caller can act on, calling it a directory is not.
Args:
readdir_fn (_AsyncReaddir): backend directory reader.
info (FileStat | None): what stat said, None when it could not
answer.
path (str): the operand path.
Returns:
bool: True when either channel reports a directory.
"""
if info is not None:
return info.type is FileType.DIRECTORY
try:
return bool(await readdir_fn(path))
except WALK_ERRORS:
return False
def exit_code_for(matched: bool, failed: bool, quiet: bool) -> int:
"""The exit status grep and ripgrep share.
An operand the search could not read is exit 2, and it outranks a
match: both tools print the lines they did find and still exit 2. The
one exception is grep's -q, documented as exiting zero when a match is
found "even if an error was detected". Everything else is the familiar
0 for a match, 1 for none.
Args:
matched (bool): True when any line was selected.
failed (bool): True when an operand could not be searched.
quiet (bool): True if -q is set; ripgrep passes False.
Returns:
int: the exit code.
"""
if matched and quiet:
return 0
if failed:
return 2
return 0 if matched else 1
def operand_error(path: str, exc: BaseException) -> str:
"""GNU's stderr line for an operand grep could not read.
A directory does not reach here: it is recognized from its type
before the read, because what a read raises for one is whatever the
backend happens to do about it.
Args:
path (str): the operand as it was named.
exc (BaseException): what the read raised.
Returns:
str: the `grep: <path>: <reason>` line, without a trailing newline.
"""
if isinstance(exc, FileNotFoundError):
return f"grep: {path}: No such file or directory"
return f"grep: {path}: {exc}"
async def grep_files_only(
readdir_fn: _AsyncReaddir,
stat_fn: _AsyncStat,
@@ -658,21 +766,29 @@ async def grep_files_only(
only_matching: bool,
max_count: int | None,
whole_word: bool,
basic: bool,
warnings: list[str] | None,
read_stream_fn=None,
) -> list[str]:
compiled = compile_pattern(pattern, ignore_case, fixed_string, whole_word)
compiled = compile_pattern(pattern, ignore_case, fixed_string, whole_word,
basic)
# What the operand is, asked before it is read. A failed read is a
# backend-dependent proxy for the type and a poor one: a keyed store
# reads a directory path without complaint and returns nothing, and
# ssh answers with an SFTP error that is not an OSError at all, so
# classifying afterwards gets a different answer per backend.
info: FileStat | None = None
try:
info = await stat_fn(path)
except WALK_ERRORS:
info = None
if recursive:
# GNU only walks directory operands; a file operand under -r takes
# the plain single-file scan (TS grepGeneric parity). Stat failures
# keep the walk so missing operands surface its error shape.
operand_is_file = False
try:
s = await stat_fn(path)
operand_is_file = s.type != FileType.DIRECTORY
except WALK_ERRORS:
operand_is_file = False
operand_is_file = info is not None and info.type != FileType.DIRECTORY
if not operand_is_file:
return await grep_recursive(
readdir_fn,
@@ -690,64 +806,27 @@ async def grep_files_only(
read_stream_fn,
)
# GNU names a directory operand and moves on without descending into
# it; only -r walks one, and that branch returned above. Walking here
# would make -l alone behave like -rl.
if await operand_is_directory(readdir_fn, info, path):
if warnings is not None:
warnings.append(f"grep: {path}: Is a directory")
return []
try:
data = await read_bytes_fn(path)
text_lines = data.decode(errors="replace").splitlines()
count = 0
for _i, line in enumerate(text_lines, 1):
m = compiled.search(line)
matched = bool(m) != invert
if matched:
count += 1
if max_count is not None and count >= max_count:
break
if count_only:
return [str(count)]
return [path] if count > 0 else []
except (FileNotFoundError, ValueError, IsADirectoryError) as exc:
if warnings is not None:
warnings.append(f"grep: {path}: {exc}")
try:
s = await stat_fn(path)
if s.type == FileType.DIRECTORY:
return await grep_recursive(
readdir_fn,
stat_fn,
read_bytes_fn,
path,
compiled,
invert,
line_numbers,
count_only,
True,
only_matching,
max_count,
warnings,
read_stream_fn,
)
except WALK_ERRORS as exc:
if warnings is not None:
warnings.append(f"grep: {path}: {exc}")
try:
await readdir_fn(path)
return await grep_recursive(
readdir_fn,
stat_fn,
read_bytes_fn,
path,
compiled,
invert,
line_numbers,
count_only,
True,
only_matching,
max_count,
warnings,
read_stream_fn,
)
except WALK_ERRORS as exc2:
if warnings is not None:
warnings.append(f"grep: {path}: {exc2}")
return []
warnings.append(operand_error(path, exc))
return []
text_lines = data.decode(errors="replace").splitlines()
count = 0
for line in text_lines:
if bool(compiled.search(line)) != invert:
count += 1
if max_count is not None and count >= max_count:
break
if count_only:
return [str(count)]
return [path] if count > 0 else []
@@ -35,6 +35,7 @@ from mirage.core.gridfs.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: True,
@@ -33,6 +33,7 @@ from mirage.core.hf_buckets.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: True,
@@ -35,6 +35,7 @@ from mirage.core.nextcloud.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: True,
@@ -34,6 +34,7 @@ from mirage.core.onedrive.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: True,
+1
View File
@@ -35,6 +35,7 @@ from mirage.core.s3.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: True,
@@ -37,6 +37,7 @@ from mirage.core.sharepoint.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: True,
+1
View File
@@ -36,6 +36,7 @@ from mirage.core.ssh.write import write_bytes as _write
IO = CommandIO(
readdir=_readdir,
read_bytes=_read,
read_range=_read,
read_stream=_read_stream,
stat=_stat,
is_mounted=lambda a: a.root is not None,
@@ -0,0 +1,189 @@
# ========= 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. =========
from mirage.commands.cli.builtin.git.add import add
from mirage.commands.cli.builtin.git.branch import branch
from mirage.commands.cli.builtin.git.checkout import checkout
from mirage.commands.cli.builtin.git.commit import commit
from mirage.commands.cli.builtin.git.diff import diff
from mirage.commands.cli.builtin.git.log import log
from mirage.commands.cli.builtin.git.reset import reset
from mirage.commands.cli.builtin.git.show import show
from mirage.commands.cli.builtin.git.status import status
from mirage.commands.cli.types import CLISpec, UsageStyle
from mirage.commands.spec.types import Operand, Option
# `-C` is git's own before-anything-else option, so it sits on the root
# and every verb inherits it. The "." default is load-bearing: a PATH
# default lands as if typed, so an absent -C resolves to the session cwd
# and the leaves need no separate working-directory fact.
DIRECTORY_OPTION = Option(short="-C",
type="path",
default=".",
description="Run as if git was started in <path>")
REVISION = Operand(type="str")
LOG_OPTIONS = (
Option(short="-n",
type="int",
numeric_shorthand=True,
description="Limit the number of commits shown"),
Option(long="--oneline", description="One abbreviated line per commit"),
Option(long="--reverse", description="Print commits oldest first"),
# The pickaxe, and the reason `git log -S <name> --reverse` answers
# "which commit introduced this": it selects commits that changed
# how many times the string occurs, not commits that mention it.
Option(short="-S",
type="str",
description="Show commits that change the number of occurrences "
"of the string"),
Option(long="--since",
type="str",
description="Commits more recent than a date (ISO-8601 or epoch)"),
Option(long="--until",
type="str",
description="Commits older than a date (ISO-8601 or epoch)"),
)
BRANCH_OPTIONS = (
Option(short="-a", description="List local and remote-tracking branches"),
Option(short="-r", description="List remote-tracking branches"),
Option(short="-d",
long="--delete",
description="Delete a fully merged branch"),
Option(short="-D", description="Delete a branch even if not merged"),
)
PATHSPEC = Operand(type="str")
ADD_OPTIONS = (
Option(short="-A", long="--all", description="Stage every change"),
Option(short="-u",
long="--update",
description="Stage changes to tracked files only"),
Option(short="-f",
long="--force",
description="Stage paths an ignore rule covers"),
)
COMMIT_OPTIONS = (
# Required, not defaulted: git would open an editor without it, and
# a mount has none to open.
Option(short="-m",
long="--message",
type="str",
description="Commit message"),
Option(long="--author",
type="str",
description="Override the recorded author"),
)
CHECKOUT_OPTIONS = (Option(short="-b",
description="Create the branch and switch to it"), )
STATUS_OPTIONS = (
Option(long="--porcelain",
description="Machine-readable output, stable across versions"),
Option(short="-s",
long="--short",
description="Give the output in the "
"short format"),
Option(short="-b",
long="--branch",
description="Show the branch line even in short format"),
# git spells the mode attached (`-uall`) or not at all, never as a
# separate token, which is what value_optional says: a bare -u means
# "all" and the next word is left alone to be an operand.
Option(short="-u",
long="--untracked-files",
type="str",
value_optional=True,
choices=("no", "normal", "all"),
description="Show untracked files: no, normal or all"),
)
# The git program tree. No config_model: local git needs no credentials,
# which is what makes it installable with a bare `cli: git`.
GIT = CLISpec(
name="git",
description="Content tracker",
usage_style=UsageStyle.GIT,
options=(DIRECTORY_OPTION, ),
subcommands=(
CLISpec(
name="status",
description="Show the working tree status",
fn=status,
options=STATUS_OPTIONS,
),
CLISpec(
name="log",
description="Show commit logs",
fn=log,
options=LOG_OPTIONS,
rest=REVISION,
),
CLISpec(
name="show",
description="Show a commit and its diff",
fn=show,
rest=REVISION,
),
CLISpec(
name="diff",
description="Show changes between commits",
fn=diff,
rest=REVISION,
),
CLISpec(
name="branch",
description="List, create or delete branches",
fn=branch,
options=BRANCH_OPTIONS,
rest=Operand(type="str"),
write=True,
),
CLISpec(
name="add",
description="Stage working tree content",
fn=add,
options=ADD_OPTIONS,
rest=PATHSPEC,
write=True,
),
CLISpec(
name="reset",
description="Unstage, putting the index back to HEAD",
fn=reset,
rest=PATHSPEC,
write=True,
),
CLISpec(
name="commit",
description="Record the index as a new commit",
fn=commit,
options=COMMIT_OPTIONS,
write=True,
),
CLISpec(
name="checkout",
description="Switch branches",
fn=checkout,
options=CHECKOUT_OPTIONS,
rest=REVISION,
write=True,
),
),
)
@@ -0,0 +1,280 @@
# ========= 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 posixpath
from dataclasses import dataclass
from typing import Any, Callable
from dulwich.index import IndexEntry
from dulwich.objects import ObjectID
from mirage.commands.cli.builtin.git.errors import GitError # yapf: disable
from mirage.commands.cli.builtin.git.errors import ( # yapf: disable
IgnoredPathsError, NothingSpecifiedError, NoWorkspaceError, PathspecError,
UnknownPathspecError, UnknownSwitchError)
from mirage.commands.cli.builtin.git.ignore import IgnoreStack, load_ignores
from mirage.commands.cli.builtin.git.index import read_index, write_index
from mirage.commands.cli.builtin.git.io import read_file
from mirage.commands.cli.builtin.git.objects import store_blob
from mirage.commands.cli.builtin.git.pathspec import matched, repo_relative
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.types import RepoLocation, WorkTree
from mirage.commands.cli.builtin.git.util import (check_operands, fatal,
start_point)
from mirage.commands.cli.builtin.git.worktree import UNTRACKED_ALL, scan
from mirage.commands.spec.types import FlagView
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import FileStat, FileType, PathSpec
# git records one of two modes for a regular file and reads only the
# owner execute bit to choose. A mount that reports no mode at all
# stages the ordinary one, which is what the file will read back as.
REGULAR = 0o100644
EXECUTABLE = 0o100755
OWNER_EXECUTE = 0o100
@dataclass(frozen=True, slots=True)
class AddFlags:
"""The parsed shape of a ``git add`` invocation.
Args:
every (bool): ``-A``, stage every change in the working tree.
update (bool): ``-u``, stage changes to tracked files only.
force (bool): ``-f``, stage a path an ignore rule covers.
"""
every: bool
update: bool
force: bool
def parse_flags(fl: FlagView) -> AddFlags:
"""Read the raw add flag kwargs into a frozen struct.
Args:
fl (FlagView): spec-validated view over the raw flag kwargs.
"""
return AddFlags(every=fl.as_bool("all"),
update=fl.as_bool("update"),
force=fl.as_bool("force"))
def entry_mode(info: FileStat) -> int:
"""The mode git would record for a working-tree file.
Args:
info (FileStat): what the mount says about the file.
"""
if info.mode is not None and info.mode & OWNER_EXECUTE:
return EXECUTABLE
return REGULAR
def staged_entry(sha: ObjectID, info: FileStat, size: int) -> IndexEntry:
"""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.
Args:
sha (bytes): the blob id that was written.
info (FileStat): what the mount says about the file.
size (int): the byte length actually staged.
"""
return IndexEntry(ctime=0,
mtime=0,
dev=0,
ino=0,
mode=entry_mode(info),
uid=0,
gid=0,
size=size,
sha=sha)
def keep_addable(paths: set[str], tracked: set[str],
ignores: IgnoreStack) -> set[str]:
"""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.
Args:
paths (set[str]): candidate paths, repository-relative.
tracked (set[str]): paths the index already holds.
ignores (IgnoreStack): the repository's ignore rules.
"""
return {
path
for path in paths if path in tracked or not ignores.is_ignored(path)
}
def _update_scope(location: RepoLocation, start: str, tracked: set[str],
present: set[str], operands: tuple[str, ...]) -> set[str]:
"""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.
Args:
location (RepoLocation): the discovered repository.
start (str): absolute virtual path git is running in.
tracked (set[str]): repository-relative paths the index holds.
present (set[str]): repository-relative paths the walk found.
operands (tuple[str, ...]): the pathspecs as typed.
"""
selected: set[str] = set()
for operand in operands:
target = repo_relative(location, start, operand)
hits = matched(tracked, target)
if not hits and not matched(present, target):
raise PathspecError(operand)
if not hits:
raise UnknownPathspecError(operand, fatal=True)
selected |= hits
return selected
async def _resolve(stat_path: StatPath, location: RepoLocation, start: str,
operands: tuple[str, ...], found: WorkTree,
tracked: set[str], ignores: IgnoreStack,
force: bool) -> tuple[set[str], set[str]]:
"""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.
Args:
stat_path (StatPath): dispatcher-backed stat, both channels.
location (RepoLocation): the discovered repository.
start (str): absolute virtual path git is running in.
operands (tuple[str, ...]): the pathspecs as typed.
found (WorkTree): what the walk of the working tree found.
tracked (set[str]): repository-relative paths the index holds.
ignores (IgnoreStack): the repository's ignore rules.
force (bool): whether ``-f`` was given.
"""
present = set(found.files)
stage: set[str] = set()
remove: set[str] = set()
ignored: list[str] = []
for operand in operands:
target = repo_relative(location, start, operand)
gone = matched(tracked, target) - present
if target in present:
if force or target in tracked or not ignores.is_ignored(target):
stage.add(target)
else:
ignored.append(target)
continue
hits = matched(present, target)
if hits or gone:
stage |= hits if force else keep_addable(hits, tracked, ignores)
remove |= gone
continue
info = await stat_path(posixpath.join(location.worktree, target))
if info is None or info.type is FileType.DIRECTORY:
raise PathspecError(operand)
found.files[target] = info
stage.add(target)
if ignored:
raise IgnoredPathsError(ignored)
return stage, remove
async def add(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""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.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): path operands, unused; pathspecs arrive
as text so they can be resolved against ``-C``.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
try:
if stat_path is None or mount_root is None or dispatch is None:
raise NoWorkspaceError()
check_operands(texts, UnknownSwitchError)
parsed = parse_flags(fl)
if not texts and not parsed.every and not parsed.update:
raise NothingSpecifiedError()
_repo, location = await opened(fl, stat_path, mount_root, dispatch)
state = await read_index(dispatch, location.gitdir)
tracked = {
path.decode("utf-8", errors="replace")
for path in state.entries
}
found = await scan(dispatch, stat_path, location, tracked,
UNTRACKED_ALL)
ignores = await load_ignores(dispatch, location.gitdir,
location.worktree)
if parsed.update:
scope = (_update_scope(location, start_point(fl), tracked,
set(found.files), texts)
if texts else tracked)
stage = scope & set(found.files)
remove = scope - set(found.files)
elif parsed.every and not texts:
stage = keep_addable(set(found.files), tracked, ignores)
remove = tracked - set(found.files)
else:
stage, remove = await _resolve(stat_path, location,
start_point(fl), texts, found,
tracked, ignores, parsed.force)
for path in sorted(stage):
data = await read_file(dispatch,
posixpath.join(location.worktree, path))
sha = await store_blob(dispatch, location.commondir, data)
state.entries[path.encode()] = staged_entry(
sha, found.files[path], len(data))
for path in remove:
state.entries.pop(path.encode(), None)
await write_index(dispatch, location.gitdir, state)
except GitError as exc:
return fatal(exc)
return None, IOResult()
@@ -0,0 +1,232 @@
# ========= 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 asyncio
from typing import Any, Callable
from dulwich.objects import ObjectID
from dulwich.refs import Ref
from dulwich.repo import BaseRepo
from dulwich.walk import Walker
from mirage.commands.cli.builtin.git.errors import ( # yapf: disable
BranchExistsError, BranchNameRequiredError, CheckedOutBranchError,
GitError, NoBranchError, NoWorkspaceError, UnknownSwitchError,
UnmergedBranchError)
from mirage.commands.cli.builtin.git.format import short
from mirage.commands.cli.builtin.git.objects import abbrev_for
from mirage.commands.cli.builtin.git.refs import (delete_ref, read_head,
write_ref)
from mirage.commands.cli.builtin.git.revparse import resolve_commit
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.types import HeadRef, RepoLocation
from mirage.commands.cli.builtin.git.util import HEAD, check_operands, fatal
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import PathSpec
HEADS_PREFIX = b"refs/heads/"
REMOTES_PREFIX = b"refs/remotes/"
SYMREF_PREFIX = b"ref: "
CURRENT = "* "
OTHER = " "
REMOTE = "remotes/"
def _symref_suffix(repo: BaseRepo, ref: bytes) -> str:
"""The ``-> target`` a symbolic ref carries in a branch listing.
``refs/remotes/origin/HEAD`` is a pointer, not a branch, and git
renders it as ``remotes/origin/HEAD -> origin/main``. Empty for an
ordinary ref.
Args:
repo (BaseRepo): repository holding the ref table.
ref (bytes): full ref name.
"""
raw = repo.refs.read_loose_ref(Ref(ref))
if raw is None or not raw.startswith(SYMREF_PREFIX):
return ""
target = raw[len(SYMREF_PREFIX):].strip()
if target.startswith(REMOTES_PREFIX):
target = target[len(REMOTES_PREFIX):]
return f" -> {target.decode()}"
async def _create(dispatch: Callable[..., Any], repo: BaseRepo,
location: RepoLocation, name: str,
start: str | None) -> None:
"""Point a new branch at a commit, refusing to move an existing one.
Args:
dispatch (Callable): workspace op dispatcher.
repo (BaseRepo): the opened repository.
location (RepoLocation): the discovered repository.
name (str): the branch name.
start (str | None): the revision to start it at, HEAD when None.
"""
ref = f"{HEADS_PREFIX.decode()}{name}"
if Ref(ref.encode()) in repo.refs.allkeys():
raise BranchExistsError(name)
commit = resolve_commit(repo, start or HEAD)
await write_ref(dispatch, location.commondir, ref, commit.id)
def _head_commit(repo: BaseRepo, head: HeadRef) -> bytes | None:
"""The commit HEAD resolves to, None on an unborn branch.
HEAD carries an object id only when detached; attached it names a
ref, which is unset until the first commit.
Args:
repo (BaseRepo): the opened repository.
head (HeadRef): what HEAD points at.
"""
if head.commit is not None:
return head.commit.encode()
if head.ref is None:
return None
ref = Ref(head.ref.encode())
return repo.refs[ref] if ref in repo.refs.allkeys() else None
def _merged(repo: BaseRepo, sha: bytes, head: bytes | None) -> bool:
"""Whether HEAD already holds every commit a branch points at.
Synchronous, and called on a worker thread: walking ancestry pulls
commit objects through the dispatcher. The walk stops at the first
sighting, so a merged branch costs only as much history as separates
it from HEAD; only a negative answer walks the whole thing, which is
what any repository without a commit graph pays.
An unborn HEAD holds nothing, which is git's answer too: on an
orphan branch every other branch reads as unmerged.
``dulwich.graph.can_fast_forward`` answers exactly this question and
cannot be used: it asks the repository for its grafts and shallow
boundary, and a bare ``BaseRepo`` raises rather than answering.
``Walker`` is what ``log`` already walks with, and it needs only the
object store.
Only HEAD is consulted. git also accepts a branch contained in its
own upstream, and there are no remotes here to have one.
Args:
repo (BaseRepo): the opened repository.
sha (bytes): the branch tip.
head (bytes | None): the commit HEAD resolves to.
"""
if head is None:
return False
if sha == head:
return True
return any(entry.commit.id == sha
for entry in Walker(repo.object_store, [ObjectID(head)]))
async def _delete(dispatch: Callable[..., Any], repo: BaseRepo,
location: RepoLocation, head: HeadRef, name: str,
force: bool) -> bytes:
"""Remove a branch, refusing when the removal would lose commits.
Args:
dispatch (Callable): workspace op dispatcher.
repo (BaseRepo): the opened repository.
location (RepoLocation): the discovered repository.
head (HeadRef): what HEAD points at.
name (str): the branch name.
force (bool): whether ``-D`` was given, which deletes a branch
HEAD does not contain.
"""
ref = Ref(f"{HEADS_PREFIX.decode()}{name}".encode())
if ref not in repo.refs.allkeys():
raise NoBranchError(name)
if name == head.branch:
raise CheckedOutBranchError(name, location.worktree)
sha = repo.refs[ref]
if not force and not await asyncio.to_thread(_merged, repo, sha,
_head_commit(repo, head)):
raise UnmergedBranchError(name)
await delete_ref(dispatch, location.commondir, ref.decode())
return (f"Deleted branch {name} "
f"(was {short(sha, abbrev_for(repo))}).\n").encode()
async def branch(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""List, create or delete branches.
A name operand creates a branch, ``-d`` deletes one, and neither
lists them with the checked-out one marked. ``-d`` deletes only a
branch HEAD already contains, and ``-D`` deletes one regardless,
which is git's own split and the reason both are here: without
``-D`` there is nothing ``-d`` can refuse to do. ``-r`` lists
remote-tracking branches instead of local ones and ``-a`` lists
both; local names sort together and remotes follow.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): path operands.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
remotes_only = fl.as_bool("r")
include_remotes = remotes_only or fl.as_bool("a")
try:
if dispatch is None:
raise NoWorkspaceError()
check_operands(texts, UnknownSwitchError)
repo, location = await opened(fl, stat_path, mount_root, dispatch)
head = await read_head(dispatch, location.gitdir)
force = fl.as_bool("D")
if fl.as_bool("delete") or force:
if not texts:
raise BranchNameRequiredError()
deleted = b"".join([
await _delete(dispatch, repo, location, head, name, force)
for name in texts
])
return yield_bytes(deleted), IOResult()
if texts:
await _create(dispatch, repo, location, texts[0],
texts[1] if len(texts) > 1 else None)
return None, IOResult()
except GitError as exc:
return fatal(exc)
keys = repo.refs.allkeys()
lines: list[str] = []
if not remotes_only:
for ref in sorted(k for k in keys if k.startswith(HEADS_PREFIX)):
name = ref[len(HEADS_PREFIX):].decode()
marker = CURRENT if name == head.branch else OTHER
lines.append(f"{marker}{name}")
if include_remotes:
for ref in sorted(k for k in keys if k.startswith(REMOTES_PREFIX)):
name = ref[len(REMOTES_PREFIX):].decode()
lines.append(f"{OTHER}{REMOTE}{name}{_symref_suffix(repo, ref)}")
if not lines:
return None, IOResult()
return yield_bytes(("\n".join(lines) + "\n").encode()), IOResult()
@@ -0,0 +1,418 @@
# ========= 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 asyncio
import posixpath
from stat import S_IFMT, S_IFREG, S_IXUSR
from typing import Any, Callable
from dulwich.diff_tree import _similarity_score
from dulwich.index import ConflictedIndexEntry, IndexEntry
from dulwich.object_store import BaseObjectStore, iter_tree_contents
from dulwich.objects import Blob, ObjectID
from dulwich.objectspec import parse_commit
from dulwich.refs import Ref
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.index import read_index
from mirage.commands.cli.builtin.git.io import read_file
from mirage.commands.cli.builtin.git.types import (IndexState, RepoLocation,
StatusEntry, WorkTree)
from mirage.commands.cli.builtin.git.worktree import scan
from mirage.ops.types import StatPath
from mirage.types import FileStat
from mirage.utils.errors import MISS_ERRORS
HEAD_REF = Ref(b"HEAD")
UNCHANGED = " "
MODIFIED = "M"
ADDED = "A"
DELETED = "D"
RENAMED = "R"
UNMERGED = "U"
UNTRACKED = "?"
# git's own two rename knobs: a pair counts as a rename at 60% shared
# content, and the search is abandoned entirely once the add-by-delete
# matrix would exceed 200 by 200. Matching both is what makes mirage
# give up exactly where git gives up rather than answer differently.
RENAME_THRESHOLD = 60
MAX_RENAME_FILES = 200
# git spells an unmerged path by which of the three index stages it kept,
# keyed here as (ancestor, ours, theirs). The pair is the porcelain XY,
# and the long format's label follows from it.
CONFLICT_CODES = {
(True, True, True): "UU",
(True, True, False): "UD",
(True, False, True): "DU",
(True, False, False): "DD",
(False, True, True): "AA",
(False, True, False): "AU",
(False, False, True): "UA",
}
def head_entries(repo: BaseRepo) -> dict[bytes, tuple[int, bytes]] | None:
"""Every path HEAD's tree holds, with its mode and blob id.
None rather than an empty mapping when HEAD resolves to nothing: a
repository before its first commit is a different thing from one
whose commit is empty, and git says so ("No commits yet").
Resolved through dulwich's own committish parser so a HEAD detached
onto a tag peels to the commit it names, rather than being read as a
tree it does not have.
Synchronous, and called on a worker thread: reading a tree pulls
objects, and this store fetches them through the dispatcher.
Args:
repo (BaseRepo): the opened repository.
"""
try:
commit = parse_commit(repo, HEAD_REF)
except KeyError:
return None
return {
entry.path: (entry.mode, entry.sha)
for entry in iter_tree_contents(repo.object_store, commit.tree)
}
def _exact_renames(adds: list[str], deletes: list[str],
shas: dict[str, bytes]) -> list[tuple[str, str]]:
"""Pair an add with a delete holding byte-identical content.
Costs a dictionary rather than a read, so it runs first and takes
every pair it can before anything is fetched.
Args:
adds (list[str]): paths the index has and HEAD does not.
deletes (list[str]): paths HEAD has and the index does not.
shas (dict[str, bytes]): blob id of each, on whichever side it
exists.
"""
sources: dict[bytes, str] = {}
for path in deletes:
sources.setdefault(shas[path], path)
taken: set[str] = set()
pairs = []
for path in adds:
origin = sources.get(shas[path])
if origin is not None and origin not in taken:
taken.add(origin)
pairs.append((path, origin))
return pairs
def _content_renames(store: BaseObjectStore, adds: list[str],
deletes: list[str],
shas: dict[str, bytes]) -> list[tuple[str, str]]:
"""Pair the rest by how much content they still have in common.
This is what makes a move that also edited the file read as one
rename instead of an add beside a delete. It costs a read of both
sides of every candidate pair, which is why git bounds the matrix
and stops trying rather than slow down on a large rewrite; the same
bound is kept here so the answer agrees with git's on the trees
where git gives up.
Args:
store (BaseObjectStore): the object database, read for blobs.
adds (list[str]): unpaired paths the index has and HEAD does not.
deletes (list[str]): unpaired paths HEAD has and the index does
not.
shas (dict[str, bytes]): blob id of each.
"""
if not adds or not deletes or len(adds) * len(
deletes) > MAX_RENAME_FILES**2:
return []
cache: dict[ObjectID, dict[int, int]] = {}
candidates = []
for old in deletes:
source = store[ObjectID(shas[old])]
for new in adds:
score = _similarity_score(source, store[ObjectID(shas[new])],
cache)
if score >= RENAME_THRESHOLD:
# Negative score so the strongest pair sorts first while
# paths still tie-break in ascending order, which is what
# makes two equally similar candidates resolve the same
# way on every run.
candidates.append((-score, new, old))
candidates.sort()
taken_new: set[str] = set()
taken_old: set[str] = set()
pairs = []
for _score, new, old in candidates:
if new in taken_new or old in taken_old:
continue
taken_new.add(new)
taken_old.add(old)
pairs.append((new, old))
return pairs
def _pair_renames(store: BaseObjectStore, staged: dict[str, str],
shas: dict[str, bytes],
regular: set[str]) -> dict[str, tuple[str, str | None]]:
"""Fold an add and a delete of the same file into one rename.
Two passes, git's own order: identical content first, then what is
merely similar enough. Only regular files are candidates, because a
symlink and a file that happen to share bytes are not a rename of
each other.
Args:
store (BaseObjectStore): the object database, read for blobs.
staged (dict[str, str]): path to its one-letter staged status.
shas (dict[str, bytes]): blob id on whichever side exists, for
the added and deleted paths only.
regular (set[str]): of those, the ones that are regular files.
"""
adds = sorted(path for path, letter in staged.items()
if letter == ADDED and path in regular)
deletes = sorted(path for path, letter in staged.items()
if letter == DELETED and path in regular)
pairs = _exact_renames(adds, deletes, shas)
matched_new = {new for new, _old in pairs}
matched_old = {old for _new, old in pairs}
pairs.extend(
_content_renames(store, [p for p in adds if p not in matched_new],
[p for p in deletes if p not in matched_old], shas))
paired = {new: (RENAMED, old) for new, old in pairs}
consumed = {old for _new, old in pairs}
return {
path: paired.get(path, (letter, None))
for path, letter in staged.items() if path not in consumed
}
def stage_changes(store: BaseObjectStore,
head: dict[bytes, tuple[int, bytes]] | None,
entries: dict[bytes, IndexEntry],
conflicts: set[bytes]) -> dict[str, tuple[str, str | None]]:
"""Compare HEAD's tree with the index: what a commit would record.
Conflicted paths are excluded rather than compared. An unmerged path
holds no ordinary index entry, so comparing it against HEAD's tree
would find the path on one side only and call it deleted, which is
the opposite of what is happening to it.
Args:
store (BaseObjectStore): the object database, read for blobs when
a rename has to be scored.
head (dict | None): HEAD's tree, or None before the first commit.
entries (dict[bytes, IndexEntry]): the index.
conflicts (set[bytes]): paths left unmerged.
"""
tree = head or {}
staged: dict[str, str] = {}
shas: dict[str, bytes] = {}
regular: set[str] = set()
for path, entry in entries.items():
name = path.decode("utf-8", errors="replace")
recorded = tree.get(path)
if recorded is None:
staged[name] = ADDED
shas[name] = entry.sha
if S_IFMT(entry.mode) == S_IFREG:
regular.add(name)
elif recorded[1] != entry.sha or recorded[0] != entry.mode:
staged[name] = MODIFIED
for path, (mode, sha) in tree.items():
if path not in entries and path not in conflicts:
name = path.decode("utf-8", errors="replace")
staged[name] = DELETED
shas[name] = sha
if S_IFMT(mode) == S_IFREG:
regular.add(name)
return _pair_renames(store, staged, shas, regular)
def staged_state(
repo: BaseRepo, entries: dict[bytes, IndexEntry], conflicts: set[bytes]
) -> tuple[dict[str, tuple[str, str | None]], bool]:
"""Everything HEAD-against-index, computed off the event loop.
One function rather than two calls because both halves read objects
through a store that fetches over the dispatcher, so both have to sit
on the worker thread; splitting them would put a blocking fetch back
on the loop that has to serve it.
Args:
repo (BaseRepo): the opened repository.
entries (dict[bytes, IndexEntry]): the index.
conflicts (set[bytes]): paths left unmerged.
"""
head = head_entries(repo)
changed = stage_changes(repo.object_store, head, entries, conflicts)
return changed, head is None
def conflict_codes(
conflicts: dict[bytes, ConflictedIndexEntry]) -> dict[str, str]:
"""The two-letter code for each unmerged path.
Args:
conflicts (dict[bytes, ConflictedIndexEntry]): unmerged entries.
"""
codes: dict[str, str] = {}
for path, entry in conflicts.items():
name = path.decode("utf-8", errors="replace")
stages = (entry.ancestor is not None, entry.this
is not None, entry.other is not None)
codes[name] = CONFLICT_CODES.get(stages, "UU")
return codes
def _mode_differs(entry: IndexEntry, info: FileStat) -> bool:
"""Whether the executable bit moved since the path was staged.
git tracks exactly one permission bit and only the owner's copy of
it: ``chmod 744`` is a modification and ``chmod 645`` is not, pinned
against git 2.47. Nothing is claimed when the mount reports no mode
at all, which is most of them; a backend that has no permissions to
report would otherwise make every executable file look changed.
Args:
entry (IndexEntry): what the index staged for the path.
info (FileStat): what the mount says about it now.
"""
if info.mode is None or S_IFMT(entry.mode) != S_IFREG:
return False
return bool(entry.mode & S_IXUSR) != bool(info.mode & S_IXUSR)
async def _differs(dispatch: Callable[..., Any], worktree: str, path: str,
entry: IndexEntry, info: FileStat) -> bool:
"""Whether a working-tree file differs from what the index staged.
A size the mount already reported settles most of it for free, since
an edit that keeps the byte count is the exception. When the sizes
agree the file is read and hashed, because that is the only thing
that actually answers the question. git normally skips even that by
trusting the stat data it cached (device, inode, mtime to the
nanosecond); a mount serves none of those meaningfully, so the cheap
answer is not available here and a wrong one is worse than a slow
one.
A recorded size of zero is read as "not stated" rather than "empty",
because that is what mirage writes for an entry it restored from a
tree without reading the blob. Trusting it would report every
tracked file as modified the moment anything was unstaged, which is
exactly what it did. Nothing is lost: a genuinely empty file falls
through to the hash and compares equal there.
Args:
dispatch (Callable): workspace op dispatcher.
worktree (str): absolute virtual path of the working tree root.
path (str): repository-relative path.
entry (IndexEntry): what the index staged for it.
info (FileStat): what the mount says about it now.
"""
if _mode_differs(entry, info):
return True
if info.size is not None and entry.size and info.size != entry.size:
return True
try:
data = await read_file(dispatch, posixpath.join(worktree, path))
except MISS_ERRORS:
return True
return Blob.from_string(data).id != entry.sha
async def work_changes(dispatch: Callable[..., Any], worktree: str,
entries: dict[bytes, IndexEntry],
found: WorkTree) -> dict[str, str]:
"""Compare the index with the working tree: what is not staged yet.
Args:
dispatch (Callable): workspace op dispatcher.
worktree (str): absolute virtual path of the working tree root.
entries (dict[bytes, IndexEntry]): the index.
found (WorkTree): what the walk of the working tree found.
"""
changes: dict[str, str] = {}
for path, entry in entries.items():
name = path.decode("utf-8", errors="replace")
if name not in found.files:
changes[name] = DELETED
elif await _differs(dispatch, worktree, name, entry,
found.files[name]):
changes[name] = MODIFIED
return changes
def merge(staged: dict[str, tuple[str, str | None]], unstaged: dict[str, str],
conflicts: dict[str,
str], untracked: list[str]) -> list[StatusEntry]:
"""Assemble one row per path from the three comparisons.
A path can appear in both the staged and unstaged mappings, and that
is the point of carrying two columns: it is one row reading ``MM``,
not two rows.
Sorting is per group, not overall, which is git's own order:
everything tracked sorts together (an unmerged path among the rest,
verified against git 2.47), and untracked paths follow as their own
sorted block however they collate against the tracked ones.
Args:
staged (dict): HEAD against the index.
unstaged (dict[str, str]): the index against the working tree.
conflicts (dict[str, str]): unmerged paths and their codes.
untracked (list[str]): paths the working tree holds and the
index does not.
"""
rows: list[StatusEntry] = []
for path in sorted(set(staged) | set(unstaged) | set(conflicts)):
code = conflicts.get(path)
if code is not None:
rows.append(StatusEntry(path, code[0], code[1]))
continue
letter, origin = staged.get(path, (UNCHANGED, None))
rows.append(
StatusEntry(path, letter, unstaged.get(path, UNCHANGED), origin))
for path in sorted(untracked):
rows.append(StatusEntry(path, UNTRACKED, UNTRACKED))
return rows
async def collect(dispatch: Callable[..., Any], stat_path: StatPath,
repo: BaseRepo, location: RepoLocation,
mode: str) -> tuple[list[StatusEntry], IndexState, bool]:
"""Everything ``status`` reports, in one pass over the three sources.
Args:
dispatch (Callable): workspace op dispatcher.
stat_path (StatPath): dispatcher-backed stat, both channels.
repo (BaseRepo): the opened repository.
location (RepoLocation): the discovered repository.
mode (str): which untracked files to report.
"""
state = await read_index(dispatch, location.gitdir)
staged, no_commits = await asyncio.to_thread(staged_state, repo,
state.entries,
set(state.conflicts))
tracked = {
path.decode("utf-8", errors="replace")
for path in (set(state.entries) | set(state.conflicts))
}
found = await scan(dispatch, stat_path, location, tracked, mode)
unstaged = await work_changes(dispatch, location.worktree, state.entries,
found)
rows = merge(staged, unstaged, conflict_codes(state.conflicts),
found.untracked)
return rows, state, no_commits
@@ -0,0 +1,317 @@
# ========= 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 asyncio
import posixpath
import time
from typing import Any, Callable
from dulwich.index import IndexEntry
from dulwich.object_store import iter_tree_contents
from dulwich.objects import Blob, ObjectID
from dulwich.objectspec import parse_commit
from dulwich.refs import Ref
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.changes import head_entries, work_changes
from mirage.commands.cli.builtin.git.errors import ( # yapf: disable
BadStartPointError, BranchExistsError, CheckoutConflictError, GitError,
NoWorkspaceError, UnknownPathspecError, UnknownSwitchError)
from mirage.commands.cli.builtin.git.format import short
from mirage.commands.cli.builtin.git.index import read_index, write_index
from mirage.commands.cli.builtin.git.io import remove_file, write_file
from mirage.commands.cli.builtin.git.objects import abbrev_for
from mirage.commands.cli.builtin.git.reflog import record
from mirage.commands.cli.builtin.git.refs import (BRANCH_PREFIX, HEAD_REF,
detach_head, read_head,
set_head, write_ref)
from mirage.commands.cli.builtin.git.reset import restored
from mirage.commands.cli.builtin.git.revparse import resolve_commit
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.types import RepoLocation
from mirage.commands.cli.builtin.git.util import HEAD, check_operands, fatal
from mirage.commands.cli.builtin.git.worktree import UNTRACKED_ALL, scan
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import PathSpec
Tree = dict[bytes, tuple[int, bytes]]
# What checkout records in the reflog. There is no committer here, only
# a move of HEAD, so the same stated identity commit uses is reused.
IDENTITY = b"mirage <mirage@localhost>"
# git's word-for-word warning when HEAD leaves a branch, kept verbatim.
# It is the only thing telling a caller that commits made from here
# become unreachable once HEAD moves again, and an agent that has read
# this text before should not have to read a paraphrase of it.
DETACHED_ADVICE = """You are in 'detached HEAD' state. You can look around, \
make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
"""
def tree_of(repo: BaseRepo, commit_id: ObjectID) -> Tree:
"""Every path a commit's tree holds, with its mode and blob id.
Synchronous, and called on a worker thread: reading a tree pulls
objects through the dispatcher.
Args:
repo (BaseRepo): the opened repository.
commit_id (ObjectID): the commit to read.
"""
commit = parse_commit(repo, commit_id)
return {
entry.path: (entry.mode, entry.sha)
for entry in iter_tree_contents(repo.object_store, commit.tree)
}
def contents(repo: BaseRepo, shas: list[bytes]) -> dict[bytes, bytes]:
"""Fetch several blobs at once, off the event loop.
Args:
repo (BaseRepo): the opened repository.
shas (list[bytes]): the blob ids to read.
"""
out: dict[bytes, bytes] = {}
for sha in shas:
obj = repo.object_store[ObjectID(sha)]
out[sha] = obj.data if isinstance(obj, Blob) else b""
return out
def _conflicts(before: Tree, after: Tree, dirty: set[str]) -> list[str]:
"""Which uncommitted changes the switch would overwrite.
A file edited but not committed survives a branch switch when both
branches record the same content for it: git carries the edit
across rather than refusing, and only refuses when the target
branch would have to write over it. Pinned against git 2.47.
Deliberate divergence for a *staged* change to such a file: git
carries that across too, applying its own two-way merge to the
index, and mirage refuses instead. Refusing is the safe half of the
trade. Getting the merge wrong loses staged work with no reflog to
recover it from, and a refusal that names the file is something the
caller can act on, where a silent clobber is not.
Args:
before (Tree): the tree HEAD records.
after (Tree): the tree being switched to.
dirty (set[str]): paths whose working tree or index differs from
HEAD.
"""
return sorted(path for path in dirty
if before.get(path.encode()) != after.get(path.encode()))
def _overwritten(after: Tree, untracked: list[str]) -> list[str]:
"""Which untracked files the tree being switched to would write over.
An untracked file is in neither tree and neither index, so the
comparison above cannot see it, and writing the target branch's blob
over it destroys the only copy there is. git refuses and names each
one. An ignored file is not in this list and git overwrites it
silently, which is the same split. Pinned against git 2.50.
Args:
after (Tree): the tree being switched to.
untracked (list[str]): every untracked path the walk found.
"""
return sorted(path for path in untracked if path.encode() in after)
async def _switch(dispatch: Callable[..., Any], repo: BaseRepo,
location: RepoLocation, before: Tree, after: Tree,
keep: set[str], staged: dict[bytes, IndexEntry]) -> None:
"""Make the working tree and index match the tree being switched to.
Only paths whose recorded content differs are touched, so a file
that is the same on both branches keeps whatever the working tree
has, including an uncommitted edit. A path carried across keeps its
index entry too, which is what preserves a staged change that both
branches happen to agree about.
Args:
dispatch (Callable): workspace op dispatcher.
repo (BaseRepo): the opened repository.
location (RepoLocation): the discovered repository.
before (Tree): the tree HEAD records.
after (Tree): the tree being switched to.
keep (set[str]): paths whose working-tree copy must not be
rewritten.
staged (dict[bytes, IndexEntry]): the index as it stands, read
for the entries of the paths being kept.
"""
state = await read_index(dispatch, location.gitdir)
state.entries.clear()
state.conflicts.clear()
changed = [
path for path in after if before.get(path) != after[path]
and path.decode("utf-8", errors="replace") not in keep
]
blobs = await asyncio.to_thread(contents, repo,
[after[path][1] for path in changed])
for path in changed:
name = path.decode("utf-8", errors="replace")
await write_file(dispatch, posixpath.join(location.worktree, name),
blobs[after[path][1]])
for path in set(before) - set(after):
name = path.decode("utf-8", errors="replace")
await remove_file(dispatch, posixpath.join(location.worktree, name))
for path, (mode, sha) in after.items():
name = path.decode("utf-8", errors="replace")
held = staged.get(path)
if name in keep and held is not None:
state.entries[path] = held
else:
state.entries[path] = restored(ObjectID(sha), mode)
await write_index(dispatch, location.gitdir, state)
async def checkout(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""Switch the working tree to another branch or commit.
Refuses rather than overwriting when the switch would destroy work
that is not committed, whether that is an edit to a tracked file or
an untracked file the target branch happens to hold. That check is
the whole reason this verb is safe to offer: without it a branch
switch silently throws away whatever was changed and not staged, and
there is no reflog here to get it back from.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): path operands, unused.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
try:
if stat_path is None or mount_root is None or dispatch is None:
raise NoWorkspaceError()
check_operands(texts, UnknownSwitchError)
if not texts:
raise UnknownPathspecError("")
target = texts[0]
repo, location = await opened(fl, stat_path, mount_root, dispatch)
head = await read_head(dispatch, location.gitdir)
creating = fl.as_bool("b")
ref = Ref(f"{BRANCH_PREFIX}{target}".encode())
known = repo.refs.allkeys()
if creating and ref in known:
raise BranchExistsError(target)
if not creating and ref not in known and target != head.branch:
try:
resolve_commit(repo, target)
except GitError as exc:
raise UnknownPathspecError(target) from exc
if not creating and target == head.branch:
return None, IOResult(stderr=f"Already on '{target}'\n".encode())
# ``checkout -b <new> [<start>]`` branches from the start point
# when one is given, HEAD otherwise. Forcing HEAD here put the new
# branch on the current commit and dropped the operand without a
# word, so every commit after it landed on the wrong history.
start = texts[1] if creating and len(texts) > 1 else None
if start is not None:
try:
commit = resolve_commit(repo, start)
except GitError as exc:
raise BadStartPointError(start, target) from exc
else:
commit = resolve_commit(repo, target if not creating else HEAD)
before = await asyncio.to_thread(head_entries, repo) or {}
after = await asyncio.to_thread(tree_of, repo, commit.id)
state = await read_index(dispatch, location.gitdir)
tracked = {
path.decode("utf-8", errors="replace")
for path in state.entries
}
# UNTRACKED_ALL, not the mode status uses: "normal" collapses a
# wholly untracked directory to one ``dir/`` entry, and a
# collision has to be decided per file. git names the file
# inside such a directory, so the list has to hold it.
found = await scan(dispatch, stat_path, location, tracked,
UNTRACKED_ALL)
unstaged = await work_changes(dispatch, location.worktree,
state.entries, found)
# Both kinds of uncommitted change count: an edit in the working
# tree, and one already staged. Leaving the staged ones out is
# what silently threw them away.
staged = {
path.decode("utf-8", errors="replace")
for path, entry in state.entries.items()
if before.get(path) != (entry.mode, entry.sha)
}
dirty = set(unstaged) | staged
blocked = _conflicts(before, after, dirty)
overwritten = _overwritten(after, found.untracked)
if blocked or overwritten:
raise CheckoutConflictError(blocked, overwritten)
await _switch(dispatch, repo, location, before, after, dirty,
state.entries)
attached = creating or ref in known
if creating:
await write_ref(dispatch, location.commondir, ref.decode(),
commit.id)
if attached:
await set_head(dispatch, location.gitdir, ref.decode())
else:
await detach_head(dispatch, location.gitdir, commit.id)
where = head.branch if head.branch is not None else short(
(head.commit or "").encode(), abbrev_for(repo))
await record(
dispatch, location.gitdir,
ref.decode() if attached else None,
head.commit.encode() if head.commit else
(repo.refs[HEAD_REF] if head.ref in known else None), commit.id,
IDENTITY, int(time.time()),
f"checkout: moving from {where} to {target}")
except GitError as exc:
return fatal(exc)
carried = "".join(f"M\t{path}\n" for path in sorted(dirty))
if attached:
verb = "Switched to a new branch" if creating else "Switched to branch"
note = f"{verb} '{target}'\n"
else:
subject = commit.message.decode(errors="replace").splitlines()[0]
note = (f"Note: switching to '{target}'.\n\n{DETACHED_ADVICE}\n"
f"HEAD is now at {short(commit.id, abbrev_for(repo))} "
f"{subject}\n")
return yield_bytes(carried.encode()), IOResult(stderr=note.encode())
@@ -0,0 +1,172 @@
# ========= 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 asyncio
import time
from typing import Any, Callable
from dulwich.index import commit_tree
from dulwich.objects import Commit, ObjectID
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.changes import head_entries
from mirage.commands.cli.builtin.git.errors import (GitError,
MissingMessageError,
NothingToCommitError,
NoWorkspaceError,
UnmergedIndexError)
from mirage.commands.cli.builtin.git.index import read_index
from mirage.commands.cli.builtin.git.objects import abbrev_for
from mirage.commands.cli.builtin.git.reflog import record
from mirage.commands.cli.builtin.git.refs import (HEAD_REF, detach_head,
read_head, write_ref)
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.status import render_report
from mirage.commands.cli.builtin.git.summary import report
from mirage.commands.cli.builtin.git.types import IndexState
from mirage.commands.cli.builtin.git.util import fatal
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import PathSpec
# git tags the first commit on a branch so the reflog reads
# "commit (initial): ..." rather than plain "commit: ...".
ROOT_NOTE = " (initial)"
DEFAULT_NAME = "mirage"
DEFAULT_EMAIL = "mirage@localhost"
UTC = 0
def identity(fl: FlagView) -> bytes:
"""Who to record as author and committer.
git reads ``user.name`` and ``user.email`` from a config file it
finds by walking the filesystem and the user's home directory.
Neither is reachable from a mount, so the identity is taken from
``--author`` when given and is otherwise a stated default rather
than a guess at the operator's own name.
Args:
fl (FlagView): the leaf's flag bag.
"""
author = fl.as_str("author")
if author:
return author.encode()
return f"{DEFAULT_NAME} <{DEFAULT_EMAIL}>".encode()
def build_commit(repo: BaseRepo, state: IndexState, message: str,
author: bytes, parents: list[ObjectID],
when: int) -> tuple[Commit, dict[bytes, tuple[int, bytes]]]:
"""Write the trees the index describes and the commit above them.
Synchronous, and called on a worker thread: every tree written goes
back through the dispatcher.
Args:
repo (BaseRepo): the opened repository.
state (IndexState): the index to commit.
message (str): the commit message.
author (bytes): the identity to record on both sides.
parents (list[bytes]): parent commit ids, empty for a root
commit.
when (int): the commit timestamp, in epoch seconds.
"""
store = repo.object_store
blobs = [(path, entry.sha, entry.mode)
for path, entry in sorted(state.entries.items())]
tree = commit_tree(store, blobs)
commit = Commit()
commit.tree = tree
commit.parents = parents
commit.author = author
commit.committer = author
commit.author_time = when
commit.commit_time = when
commit.author_timezone = UTC
commit.commit_timezone = UTC
commit.encoding = b"UTF-8"
commit.message = message.encode() + b"\n"
store.add_object(commit)
return commit, {
path: (entry.mode, entry.sha)
for path, entry in state.entries.items()
}
async def commit(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""Record the index as a new commit on the current branch.
The message must come from ``-m``: git would otherwise open an
editor, which a mount has no way to offer, and inventing a message
would put an unreviewed one into history.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): path operands, unused.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
try:
if stat_path is None or mount_root is None or dispatch is None:
raise NoWorkspaceError()
message = fl.as_str("message")
if not message:
raise MissingMessageError()
repo, location = await opened(fl, stat_path, mount_root, dispatch)
state = await read_index(dispatch, location.gitdir)
if state.conflicts:
raise UnmergedIndexError()
head = await read_head(dispatch, location.gitdir)
before = await asyncio.to_thread(head_entries, repo)
after = {
path: (entry.mode, entry.sha)
for path, entry in state.entries.items()
}
if before is not None and before == after:
raise NothingToCommitError(await
render_report(dispatch, stat_path, repo,
location, head))
parents = [] if before is None else [repo.refs[HEAD_REF]]
who = identity(fl)
when = int(time.time())
written, tree = await asyncio.to_thread(build_commit, repo, state,
message, who, parents, when)
if head.ref is not None:
await write_ref(dispatch, location.commondir, head.ref, written.id)
else:
await detach_head(dispatch, location.gitdir, written.id)
await record(
dispatch, location.gitdir, head.ref,
parents[0] if parents else None, written.id, who, when,
f"commit{ROOT_NOTE if before is None else ''}: "
f"{message.splitlines()[0]}")
except GitError as exc:
return fatal(exc)
body = report(repo.object_store, written, head.branch, before or {}, tree,
abbrev_for(repo), before is None)
return yield_bytes(body), IOResult()
@@ -0,0 +1,106 @@
# ========= 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 asyncio
from io import BytesIO
from typing import Any, Callable
from dulwich.patch import write_tree_diff
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.errors import GitError, InvalidOptionError
from mirage.commands.cli.builtin.git.revparse import resolve_commit
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.util import check_operands, fatal
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import PathSpec
HEAD = "HEAD"
# Deliberate divergence, verified against git 2.47.3 on a real
# repository. The patch is correct and applies cleanly, and file
# headers, mode lines and blob abbreviations match git exactly, but the
# hunks are not byte-identical:
#
# ours: @@ -3,6 +3,10 @@
# git: @@ -4,6 +4,10 @@ from collections import defaultdict
#
# Two causes, both from dulwich rendering through Python's difflib
# rather than git's xdiff. git appends the enclosing function or section
# to a hunk header (xfuncname), and git slides a hunk to the equivalent
# boundary xdiff prefers, so a blank line can be attributed to the
# additions on one side and the context on the other. Closing this means
# reimplementing xdl_change_compact and the xfuncname scan; until then
# do not claim byte parity for diff bodies. `log`, `log --oneline`,
# `show`'s header and `branch` ARE byte-identical.
def _render(repo: BaseRepo, old_rev: str, new_rev: str) -> bytes:
"""Resolve both revisions and render the patch, synchronously.
Runs on a worker thread, because resolving and reading blobs both
fetch through the dispatcher.
Args:
repo (BaseRepo): repository to read.
old_rev (str): the revision on the minus side.
new_rev (str): the revision on the plus side.
"""
old = resolve_commit(repo, old_rev)
new = resolve_commit(repo, new_rev)
out = BytesIO()
write_tree_diff(out, repo.object_store, old.tree, new.tree)
return out.getvalue()
async def diff(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""Diff two commits.
One revision diffs it against HEAD's tree, two diff against each
other. The working tree is not a party to this yet: comparing
against it needs the index and the worktree scan, which is where
unstaged and staged diffs live.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): path operands.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
if not texts:
return None, IOResult()
try:
check_operands(texts, InvalidOptionError)
repo, _location = await opened(fl, stat_path, mount_root, dispatch)
new_rev = texts[1] if len(texts) >= 2 else HEAD
body = await asyncio.to_thread(_render, repo, texts[0], new_rev)
except GitError as exc:
return fatal(exc)
if not body:
return None, IOResult()
return yield_bytes(body), IOResult()
@@ -0,0 +1,178 @@
# ========= 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 posixpath
from typing import Any, Callable
from mirage.commands.cli.builtin.git.errors import ( # yapf: disable
InvalidGitFileError, NotARepositoryError, NoWorkingDirectoryError)
from mirage.commands.cli.builtin.git.io import read_file, read_optional
from mirage.commands.cli.builtin.git.types import RepoLocation
from mirage.ops.types import MountRoot, StatPath
from mirage.types import FileType
GIT_DIR = ".git"
GITDIR_PREFIX = "gitdir:"
COMMON_DIR = "commondir"
def _normalize(path: str) -> str:
"""Strip a virtual path to its canonical no-trailing-slash spelling.
Args:
path (str): absolute virtual path, with or without a trailing
slash (mount prefixes carry one, operands usually do not).
"""
stripped = path.rstrip("/")
return stripped or "/"
def _parent(path: str) -> str:
"""The directory above a normalized virtual path, "/" at the top.
Args:
path (str): normalized absolute virtual path.
"""
return posixpath.dirname(path) or "/"
def _against(base: str, target: str) -> str:
"""Resolve a path a git file names, relative to the file's directory.
git writes either form. A submodule and a ``--relative-paths``
worktree point relatively so the pair can be moved together; an
ordinary ``git worktree add`` writes an absolute path.
Args:
base (str): directory the naming file lives in.
target (str): the path as the file spelled it.
"""
if target.startswith("/"):
return _normalize(target)
return _normalize(posixpath.normpath(posixpath.join(base, target)))
async def _follow_gitfile(dispatch: Callable[..., Any], stat_path: StatPath,
gitfile: str) -> str:
"""Read a ``.git`` file and return the directory it points at.
A ``.git`` that is a file rather than a directory holds one
``gitdir: <path>`` line. git writes one for every linked worktree
(``git worktree add``) and every submodule, so the real git
directory sits outside the tree being worked in, and reading the
file as if it were a directory is how this used to fail.
Args:
dispatch (Callable): workspace op dispatcher.
stat_path (StatPath): dispatcher-backed stat, both channels.
gitfile (str): absolute virtual path of the ``.git`` file.
"""
text = (await read_file(dispatch, gitfile)).decode("utf-8",
errors="replace")
line = text.strip()
if not line.startswith(GITDIR_PREFIX):
raise InvalidGitFileError(gitfile)
target = line[len(GITDIR_PREFIX):].strip()
if not target:
raise InvalidGitFileError(gitfile)
resolved = _against(_parent(gitfile), target)
if await stat_path(resolved) is None:
# An absolute pointer names a path on the backend's own
# filesystem, which is only reachable when the mount happens to
# span it: a worktree mounted alone cannot see the repository it
# was cut from. git says the same thing when the target is gone.
raise NotARepositoryError(resolved, quoted=False)
return resolved
async def _common_dir(dispatch: Callable[..., Any], gitdir: str) -> str:
"""The shared git directory behind a per-worktree one.
A linked worktree's git directory carries a ``commondir`` file
naming the repository it belongs to, usually as ``../..``. Objects,
packed-refs and branches live there; only HEAD and the index are
the worktree's own. An ordinary checkout has no such file and is its
own common directory.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of the git directory.
"""
data = await read_optional(dispatch, posixpath.join(gitdir, COMMON_DIR))
if data is None:
return gitdir
target = data.decode("utf-8", errors="replace").strip()
return _against(gitdir, target) if target else gitdir
async def discover(dispatch: Callable[..., Any], stat_path: StatPath,
mount_root: MountRoot, start: str) -> RepoLocation:
"""Find the repository governing a path, or raise git's own fatal.
Walks up from ``start`` looking for a ``.git`` entry, stopping at the
mount root. Real git stops discovery at a filesystem boundary unless
GIT_DISCOVERY_ACROSS_FILESYSTEM is set, and a mount prefix is exactly
that boundary: crossing it would probe a different backend for a
repository that has nothing to do with the operand.
Existence comes from ``stat_path`` rather than one backend's stat
because on a prefix store a directory is not an object: ``.git``
answers on readdir while a point lookup misses it entirely. That is
the same fact ``find`` asks about its own start point.
What is found is not always the git directory. A ``.git`` file
points at one elsewhere, and the directory it points at may share
its objects with another, so the three paths are resolved here and
carried separately rather than derived again by each verb.
Args:
dispatch (Callable): workspace op dispatcher, for the two files
that redirect a git directory.
stat_path (StatPath): dispatcher-backed stat asking both channels
a backend can answer on; None means nothing is there.
mount_root (MountRoot): the mount prefix serving a path.
start (str): absolute virtual path to start from, normally the
session cwd or the argument of ``-C``.
"""
root = _normalize(mount_root(start))
current = _normalize(start)
first = True
while True:
candidate = posixpath.join(current, GIT_DIR)
info = await stat_path(candidate)
if info is not None:
gitdir = (candidate if info.type is FileType.DIRECTORY else await
_follow_gitfile(dispatch, stat_path, candidate))
return RepoLocation(gitdir=gitdir,
commondir=await _common_dir(dispatch, gitdir),
worktree=current,
mount_root=root)
if first:
# git enters ``-C`` before it looks for anything, so a path it
# cannot enter fails on its own terms even when a directory
# above it holds a repository. A file counts as one it cannot
# enter: tolerating it would walk up and run in the parent
# repository, which for a write verb means mutating a
# repository the caller did not name. Asked only after the
# first probe missed, because a hit already proves the
# directory is there.
here = await stat_path(current)
if here is None:
raise NoWorkingDirectoryError(start)
if here.type is not FileType.DIRECTORY:
raise NoWorkingDirectoryError(start, "Not a directory")
first = False
if current == root or current == "/":
raise NotARepositoryError()
current = _parent(current)
@@ -0,0 +1,507 @@
# ========= 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. =========
# git exits 128 for every fatal, which is neither the argparse usage
# exit (2) nor the generic command failure (1) the CLI dispatcher
# applies to a thrown handler error. Leaves therefore return this
# code themselves rather than raising into the dispatcher's catch-all.
FATAL_EXIT = 128
# git's parse-options refuses a bad option with 129, not the 128 it uses
# for a fatal. Both appear below, which is git's own split rather than
# ours: `git log --zzz` is 128 and `git diff --zzz` is 129.
OPTION_EXIT = 129
# git closes each of these with a line naming the config knob that
# turns it off. Kept verbatim so the advice reads the same whether an
# agent hit real git or this one.
ADVICE_IGNORED = ('hint: Disable this message with "git config set '
'advice.addIgnoredFile false"')
ADVICE_EMPTY_PATHSPEC = ('hint: Disable this message with "git config '
'set advice.addEmptyPathspec false"')
class GitError(Exception):
"""Base for a git fatal: rendered as ``fatal: <message>``, exit 128.
Subclasses override ``prefix``, ``code`` and ``stream`` when git
words their case differently, so every verb can keep one
``except GitError`` arm and the rendering stays in one place. A None
prefix prints the message alone, which is what git does when the
refusal is a report rather than an error ("nothing to commit"), and
such a report goes to stdout because that is where the report it
replaces would have gone.
"""
prefix: str | None = "fatal"
code = FATAL_EXIT
stream = "stderr"
class NotARepositoryError(GitError):
"""No ``.git`` at the start point or above it, up to the mount root.
Args:
gitdir (str | None): the git directory that failed to resolve,
None when discovery walked up from the working directory.
quoted (bool): whether to quote the path. git quotes one the
user typed (``--git-dir``, ``GIT_DIR``) and leaves unquoted
one it read out of a ``.git`` file, pinned against git
2.50.1.
"""
def __init__(self, gitdir: str | None = None, quoted: bool = True) -> None:
if gitdir is None:
super().__init__("not a git repository (or any of the parent "
"directories): .git")
elif quoted:
super().__init__(f"not a git repository: '{gitdir}'")
else:
super().__init__(f"not a git repository: {gitdir}")
class InvalidGitFileError(GitError):
"""A ``.git`` file that is not a ``gitdir:`` pointer.
Args:
path (str): absolute virtual path of the offending file.
"""
def __init__(self, path: str) -> None:
super().__init__(f"invalid gitfile format: {path}")
class AmbiguousArgumentError(GitError):
"""A revision that resolves to nothing.
git answers every unresolvable revision with one wording, whether
the ref is unknown, the short sha matches nothing, or an ancestry
step walked off the end of history (pinned against git 2.47.3 for
``show``, ``log`` and ``rev-parse`` alike).
Args:
revision (str): the revision as the user spelled it.
"""
def __init__(self, revision: str) -> None:
super().__init__(
f"ambiguous argument '{revision}': unknown revision or path "
f"not in the working tree.\n"
f"Use '--' to separate paths from revisions, like this:\n"
f"'git <command> [<revision>...] -- [<file>...]'")
class BadDateError(GitError):
"""A date flag whose value could not be read.
git accepts relative wording (``2 weeks ago``) that mirage does not,
so an unreadable value is refused rather than ignored: silently
dropping the flag would widen the window instead of narrowing it.
Args:
flag (str): the flag as spelled on the command line.
value (str): the value that could not be read.
"""
def __init__(self, flag: str, value: str) -> None:
super().__init__(f"invalid date format for {flag}: {value} "
f"(expected ISO-8601 or an epoch second)")
class NoWorkspaceError(GitError):
"""The CLI ran with no workspace behind it, so no file is reachable.
Only possible when a leaf is called directly in a unit test: inside
a workspace the dispatcher always offers the facts a leaf declares.
"""
def __init__(self) -> None:
super().__init__("this operation must be run in a work tree")
class NoWorkingDirectoryError(GitError):
"""``-C`` named a path git could not enter.
Two reasons, both in git's own wording: nothing is there, or
something is and it is not a directory. The second matters as much as
the first, because discovery walks upwards from the start point: a
file operand that is merely tolerated finds the repository above it
and quietly runs there instead.
Args:
path (str): the path as the user spelled it.
reason (str): the strerror git names, absence by default.
"""
def __init__(self,
path: str,
reason: str = "No such file or directory") -> None:
super().__init__(f"cannot change to '{path}': {reason}")
class BadStartPointError(GitError):
"""``checkout -b`` given a start point that is not a commit.
git blames the start point rather than the branch name, and says so
in one sentence naming both, which is more use than the generic
"ambiguous argument" the same lookup failure produces elsewhere.
Args:
start (str): the start point as the user spelled it.
name (str): the branch that would have been created.
"""
def __init__(self, start: str, name: str) -> None:
super().__init__(f"'{start}' is not a commit and a branch '{name}' "
f"cannot be created from it")
class RevisionResetError(GitError):
"""``reset`` given a revision, which this build does not take.
Real git resets the index to any commit named here. mirage resets it
from HEAD only, so the operand has nothing to do, and doing nothing
quietly is the one answer a caller cannot act on: a script reads the
zero exit as "the index was reset" when it was not. Saying which
feature is missing beats reusing "unknown revision" for a revision
that is perfectly well known.
Args:
revision (str): the operand as the user spelled it.
"""
def __init__(self, revision: str) -> None:
super().__init__(f"cannot reset to '{revision}': this build resets "
f"the index from HEAD only")
class UnrecognizedArgumentError(GitError):
"""A dashed operand, which is a git feature this build does not have.
mirage implements a subset of every verb, so an undeclared flag is
rarely a typo: it is a real git option (``-p``, ``--stat``,
``--graph``) arriving at a build that lacks it. Left alone it lands
on the revision operand and comes back as "ambiguous argument",
which blames the repository for missing a commit rather than mirage
for missing a feature, and an agent reading that draws the wrong
conclusion. git words the same mistake this way for ``log`` and
``show``.
Args:
argument (str): the operand as the user spelled it.
"""
def __init__(self, argument: str) -> None:
super().__init__(f"unrecognized argument: {argument}")
class OutsideRepositoryError(GitError):
"""A path operand that resolves outside the working tree.
Args:
operand (str): the operand as the user spelled it.
root (str): absolute virtual path of the working tree.
"""
def __init__(self, operand: str, root: str) -> None:
super().__init__(f"{operand}: '{operand}' is outside repository at "
f"'{root}'")
class PathspecError(GitError):
"""A path operand that matches nothing in the working tree.
Args:
pathspec (str): the operand as the user spelled it.
"""
def __init__(self, pathspec: str) -> None:
super().__init__(f"pathspec '{pathspec}' did not match any files")
class IgnoredPathsError(GitError):
"""Explicitly named paths that an ignore rule covers.
git refuses rather than staging them, because naming an ignored path
is far more often a mistake than an intention, and exits 1 rather
than its usual 128. Expanding a directory is not the same act: there
the ignored files are silently skipped, which is why only operands
that name a file reach this.
Args:
paths (list[str]): the refused paths, repository-relative.
"""
prefix = None
code = 1
def __init__(self, paths: list[str]) -> None:
listed = "\n".join(sorted(paths))
super().__init__(f"The following paths are ignored by one of your "
f".gitignore files:\n{listed}\nhint: Use -f if you "
f"really want to add them.\n{ADVICE_IGNORED}")
class NothingSpecifiedError(GitError):
"""``add`` with no pathspec at all.
Not an error by exit code: git says what it did not do and exits 0,
because nothing went wrong and nothing happened.
"""
prefix = None
code = 0
def __init__(self) -> None:
super().__init__("Nothing specified, nothing added.\nhint: Maybe "
"you wanted to say 'git add .'?\n"
f"{ADVICE_EMPTY_PATHSPEC}")
class NothingToCommitError(GitError):
"""``commit`` with an index that matches HEAD.
Printed on stdout, where the status report it stands in for would
have gone, and exits 1.
Args:
report (str): the status report to print in place of a commit.
"""
prefix = None
code = 1
stream = "stdout"
def __init__(self, report: str) -> None:
super().__init__(report.rstrip("\n"))
class MissingMessageError(GitError):
"""``commit`` with no ``-m``.
git would open an editor here. A mount has no editor to open and no
terminal to open it on, and inventing a message would put an
unreviewed sentence into history, so the flag is required rather
than defaulted.
"""
def __init__(self) -> None:
super().__init__("no commit message supplied (mirage has no editor "
"to open; pass -m)")
class UnmergedIndexError(GitError):
"""``commit`` while paths are still in conflict.
Args:
None.
"""
def __init__(self) -> None:
super().__init__("Exiting because of an unresolved conflict.")
class BranchExistsError(GitError):
"""``branch <name>`` naming a branch that is already there.
Args:
name (str): the branch name.
"""
def __init__(self, name: str) -> None:
super().__init__(f"a branch named '{name}' already exists")
class BranchNameRequiredError(GitError):
"""``branch -d`` with nothing to delete.
Args:
None.
"""
prefix = "error"
code = OPTION_EXIT
def __init__(self) -> None:
super().__init__("branch name required")
class CheckedOutBranchError(GitError):
"""``branch -d`` naming the branch HEAD is on.
Args:
name (str): the branch name.
worktree (str): absolute virtual path of the working tree.
"""
prefix = "error"
code = 1
def __init__(self, name: str, worktree: str) -> None:
super().__init__(f"cannot delete branch '{name}' used by worktree at "
f"'{worktree}'")
class UnmergedBranchError(GitError):
"""``-d`` naming a branch whose commits HEAD does not already hold.
The branch name is the only thing pointing at those commits, so
deleting it leaves them unreachable and there is no reflog here to
find them again. git refuses for that reason and reserves ``-D`` for
a caller who means it, which is why ``-d`` alone would be the wrong
shape to ship: it would be a delete with no way to say no.
Only the first of git's two hint lines is kept. The second names the
config knob that silences the advice, and there is no git config
here to set.
Args:
name (str): the branch name.
"""
prefix = "error"
code = 1
def __init__(self, name: str) -> None:
super().__init__(f"the branch '{name}' is not fully merged\n"
f"hint: If you are sure you want to delete it, run "
f"'git branch -D {name}'")
class NoBranchError(GitError):
"""A branch name that resolves to nothing.
Args:
name (str): the branch name as the user spelled it.
"""
prefix = "error"
code = 1
def __init__(self, name: str) -> None:
super().__init__(f"branch '{name}' not found")
class UnknownPathspecError(GitError):
"""An operand that is neither a ref nor a path git has heard of.
One sentence, two exit codes, which is git's own split rather than
ours: ``checkout`` refuses with 1, and ``add -u`` treats the same
sentence as a fatal and exits 128. Measured one verb at a time on
git 2.50.1.
Args:
target (str): the operand as the user spelled it.
fatal (bool): whether to exit as a fatal rather than with 1.
"""
prefix = "error"
def __init__(self, target: str, fatal: bool = False) -> None:
self.code = FATAL_EXIT if fatal else 1
super().__init__(f"pathspec '{target}' did not match any file(s) "
f"known to git")
def _conflict_block(header: str, paths: list[str], advice: str) -> str:
"""One named-files paragraph of a checkout refusal.
Args:
header (str): the line that introduces the list.
paths (list[str]): the files to name, one per tab-indented line.
advice (str): the line telling the caller what to do about them.
"""
listed = "\n".join(f"\t{path}" for path in sorted(paths))
return f"{header}\n{listed}\n{advice}"
class CheckoutConflictError(GitError):
"""A checkout that would throw away work that is not committed.
git refuses and names every file rather than overwriting, which is
the one safety check that makes checkout usable at all: without it a
branch switch silently destroys whatever was edited and not staged.
Two kinds of work are at risk and git words them differently: a
tracked file carrying uncommitted changes, and an untracked file the
target branch would write over. Both are carried here rather than
raised separately because when both apply git prints both
paragraphs and aborts once, pinned against git 2.50.
Args:
local (list[str]): tracked files with uncommitted changes.
untracked (list[str]): untracked files the target branch holds.
"""
prefix = "error"
code = 1
def __init__(self, local: list[str], untracked: list[str]) -> None:
blocks: list[str] = []
if local:
blocks.append(
_conflict_block(
"Your local changes to the following files would be "
"overwritten by checkout:", local,
"Please commit your changes or stash them before you "
"switch branches."))
if untracked:
blocks.append(
_conflict_block(
"The following untracked working tree files would be "
"overwritten by checkout:", untracked,
"Please move or remove them before you switch branches."))
# git emits each paragraph as its own error, so the second one
# carries the prefix inline: the renderer only writes the first.
joined = "error: ".join(f"{block}\n" for block in blocks)
super().__init__(f"{joined}Aborting")
class UnknownSwitchError(GitError):
"""The wording of git's own option parser, used by most verbs.
Three verbs word this three ways and git means all of them: ``log``
and ``show`` say "unrecognized argument" and exit 128, ``diff`` says
"invalid option" and exits 129, and everything built on
parse-options (``status``, ``add``, ``branch``, ``reset``,
``checkout``, ``commit``) says this and exits 129. Measured on git
2.47, one verb at a time.
Args:
argument (str): the option as the user spelled it.
"""
prefix = "error"
code = OPTION_EXIT
def __init__(self, argument: str) -> None:
noun = "option" if argument.startswith("--") else "switch"
super().__init__(f"unknown {noun} `{argument.lstrip('-')}'")
class InvalidOptionError(GitError):
"""``diff``'s wording for an option it does not know.
Same mistake as UnrecognizedArgumentError and a different sentence,
because git itself words it differently here and exits 129 rather
than 128. Pinned against git 2.50.1.
Args:
argument (str): the operand as the user spelled it.
"""
prefix = "error"
code = OPTION_EXIT
def __init__(self, argument: str) -> None:
super().__init__(f"invalid option: {argument}")
@@ -0,0 +1,139 @@
# ========= 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. =========
from datetime import datetime, timedelta, timezone
from dulwich.objects import Commit
SHORT_SHA = 7
INDENT = " "
DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
"Nov", "Dec")
def abbrev_length(packed: int) -> int:
"""How many hex digits an abbreviated id needs in a repository.
git widens the abbreviation as a repository grows, so ``--oneline``
on a large repository prints nine characters where a fresh one
prints seven, and a build that always printed seven disagreed with
real git on every line of every big repository.
Measured against git 2.50.1 rather than read off its source, and the
boundary is sharp: 16,383 packed objects abbreviate to 7 and 16,384
to 8, which is one hex digit per two bits of object count, floored
at git's seven. Confirmed again at 20,102 (8), 70,102 (9) and
184,401 (9).
Only packed objects count. The same 70,102 objects abbreviate to 7
while loose and to 9 once packed, which is consistent with it being
an estimate: a pack index states its object count in its header,
while counting loose objects means walking 256 directories.
Args:
packed (int): how many objects the repository's packs hold.
"""
return max(SHORT_SHA, -(-packed.bit_length() // 2))
def short(sha: bytes, length: int = SHORT_SHA) -> str:
"""Abbreviate an object id the way ``--oneline`` prints it.
Args:
sha (bytes): hex object id.
length (int): how many hex digits to keep, from abbrev_length.
"""
return sha.decode()[:length]
def git_date(timestamp: int, offset: int) -> str:
"""Render a commit time in git's default date format.
``Fri Jan 16 11:30:00 2026 +0000``: the day of the month is not
padded, which is why this is built by hand rather than with strftime
(``%d`` zero-pads and ``%-d`` is not portable). The stored offset is
seconds east of UTC, and the timestamp is read in that offset, so a
commit prints the wall clock its author saw.
Args:
timestamp (int): seconds since the epoch.
offset (int): the author's UTC offset in seconds.
"""
tz = timezone(timedelta(seconds=offset))
moment = datetime.fromtimestamp(timestamp, tz)
sign = "+" if offset >= 0 else "-"
hours, minutes = divmod(abs(offset) // 60, 60)
return (f"{DAYS[moment.weekday()]} {MONTHS[moment.month - 1]} "
f"{moment.day} {moment:%H:%M:%S} {moment.year} "
f"{sign}{hours:02d}{minutes:02d}")
def subject(commit: Commit) -> str:
"""The first line of a commit message.
Args:
commit (Commit): the commit to read.
"""
text = commit.message.decode("utf-8", errors="replace")
return text.split("\n", 1)[0].rstrip()
def oneline(commit: Commit, length: int = SHORT_SHA) -> str:
"""One ``--oneline`` row: abbreviated id then subject.
Args:
commit (Commit): the commit to render.
length (int): how many hex digits of the id to print.
"""
return f"{short(commit.id, length)} {subject(commit)}"
def message_block(commit: Commit) -> list[str]:
"""A commit message indented the way log and show print it.
Every line is indented by four spaces, blank lines included, so an
empty line inside a message renders as four spaces rather than as an
empty one. Verified against git 2.47.3; it is trailing whitespace on
purpose.
Args:
commit (Commit): the commit to render.
"""
text = commit.message.decode("utf-8", errors="replace").rstrip("\n")
return [f"{INDENT}{line}" for line in text.split("\n")]
def entry(commit: Commit, length: int = SHORT_SHA) -> list[str]:
"""A full log entry: the header block and the indented message.
A merge carries an extra ``Merge:`` line naming its parents in
abbreviated form, which git prints between the id and the author for
both ``log`` and ``show``.
Args:
commit (Commit): the commit to render.
length (int): how many hex digits of a parent id to print.
"""
lines = [f"commit {commit.id.decode()}"]
if len(commit.parents) > 1:
lines.append(f"Merge: "
f"{' '.join(short(p, length) for p in commit.parents)}")
lines.extend([
f"Author: {commit.author.decode('utf-8', errors='replace')}",
f"Date: {git_date(commit.author_time, commit.author_timezone)}",
"",
*message_block(commit),
])
return lines
@@ -0,0 +1,121 @@
# ========= 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. =========
from dataclasses import dataclass
from dulwich.objects import Commit
from dulwich.repo import BaseRepo
from dulwich.walk import Walker
from mirage.commands.cli.builtin.git.errors import BadDateError
from mirage.commands.cli.builtin.git.pickaxe import touches
from mirage.commands.spec.types import FlagView
from mirage.utils.dates import iso_timestamp
@dataclass(frozen=True, slots=True)
class LogFlags:
"""The parsed shape of a ``git log`` invocation.
Args:
max_count (int | None): ``-n``, how many commits to print.
oneline (bool): ``--oneline``, one abbreviated row per commit.
reverse (bool): ``--reverse``, oldest first.
search (str | None): ``-S``, the pickaxe string.
since (float | None): ``--since`` as an epoch second.
until (float | None): ``--until`` as an epoch second.
"""
max_count: int | None
oneline: bool
reverse: bool
search: str | None
since: float | None
until: float | None
def _timestamp(value: str | None, flag: str) -> float | None:
"""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.
Args:
value (str | None): the flag's value.
flag (str): flag name, for error attribution.
"""
if value is None:
return None
parsed = iso_timestamp(value)
if parsed is not None:
return parsed
try:
return float(value)
except ValueError as exc:
raise BadDateError(flag, value) from exc
def parse_flags(fl: FlagView) -> LogFlags:
"""Read the raw log flag kwargs into a frozen struct.
Args:
fl (FlagView): spec-validated view over the raw flag kwargs.
"""
return LogFlags(
max_count=fl.as_int("n"),
oneline=fl.as_bool("oneline"),
reverse=fl.as_bool("reverse"),
search=fl.as_str("S"),
since=_timestamp(fl.as_str("since"), "--since"),
until=_timestamp(fl.as_str("until"), "--until"),
)
def select(repo: BaseRepo, start: Commit, flags: LogFlags) -> list[Commit]:
"""The commits a log invocation prints, in the order it prints them.
Order of operations is git's: walk history, drop what the filters
reject, cut to ``-n``, and only then reverse. Reversing last is what
makes ``-S <name> --reverse`` name the commit that introduced a
string rather than the most recent one to touch it.
``-n`` cannot be pushed into the walker when a pickaxe is active,
because the limit counts commits that survive the filter, not
commits visited.
Args:
repo (BaseRepo): repository to walk.
start (Commit): the commit to walk back from.
flags (LogFlags): the parsed invocation.
"""
store = repo.object_store
needle = flags.search.encode() if flags.search is not None else None
walker = Walker(
store,
[start.id],
max_entries=flags.max_count if needle is None else None,
since=int(flags.since) if flags.since is not None else None,
until=int(flags.until) if flags.until is not None else None,
)
selected: list[Commit] = []
for entry in walker:
commit = entry.commit
if needle is not None and not touches(store, commit, needle):
continue
selected.append(commit)
if flags.max_count is not None and len(selected) >= flags.max_count:
break
if flags.reverse:
selected.reverse()
return selected
@@ -0,0 +1,116 @@
# ========= 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 posixpath
from io import BytesIO
from typing import Any, BinaryIO, Callable, cast
from dulwich.ignore import IgnoreFilter, read_ignore_patterns
from mirage.commands.cli.builtin.git.io import read_optional
GITIGNORE = ".gitignore"
INFO_EXCLUDE = "info/exclude"
def _relative(prefix: str, path: str) -> str:
"""Spell a repository-relative path relative to a filter's directory.
A ``.gitignore`` names paths from its own directory down, so the same
path is a different string to each filter on the stack.
Args:
prefix (str): the filter's directory, repository-relative, empty
at the root.
path (str): repository-relative path being tested.
"""
return path[len(prefix) + 1:] if prefix else path
class IgnoreStack:
"""The ``.gitignore`` files governing one directory, innermost last.
Immutable, and pushed rather than mutated, so a walk hands each
subdirectory its own stack and cannot forget to pop one on the way
back up.
Precedence runs the opposite way to the list: a deeper file wins
over a shallower one, so the search runs from the end. Within one
file the last matching pattern wins, which is what ``IgnoreFilter``
already does, and is how a ``!`` line un-ignores something the lines
above it caught.
Args:
filters (list[tuple[str, IgnoreFilter]]): each directory prefix
and the filter parsed from the file it holds.
"""
def __init__(self, filters: list[tuple[str, IgnoreFilter]]) -> None:
self._filters = filters
def push(self, prefix: str, patterns: bytes) -> "IgnoreStack":
"""A new stack with one more ``.gitignore`` on top.
Args:
prefix (str): the directory the file was found in,
repository-relative.
patterns (bytes): the file's contents.
"""
parsed = list(read_ignore_patterns(cast(BinaryIO, BytesIO(patterns))))
return IgnoreStack(self._filters + [(prefix, IgnoreFilter(parsed))])
def is_ignored(self, path: str, is_dir: bool = False) -> bool:
"""Whether git would leave a path out of an untracked listing.
Args:
path (str): repository-relative path.
is_dir (bool): whether the path names a directory, which
decides whether a ``build/`` pattern can match it.
"""
spelled = f"{path}/" if is_dir else path
for prefix, ignore_filter in reversed(self._filters):
verdict = ignore_filter.is_ignored(_relative(prefix, spelled))
if verdict is not None:
return verdict
return False
async def load_ignores(dispatch: Callable[..., Any], gitdir: str,
worktree: str) -> IgnoreStack:
"""The root of the ignore stack: the repository's own two files.
``.git/info/exclude`` sits below the root ``.gitignore`` because it
is the repository's private list and a tracked ``.gitignore`` should
be able to override it.
``core.excludesFile``, the per-user global list, is deliberately not
read. It names a path on whichever machine git ran on, and a mount
has no way to reach that machine's home directory; honoring it would
mean reading the operator's own file and applying it to somebody
else's repository.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of the git directory.
worktree (str): absolute virtual path of the working tree root.
"""
stack = IgnoreStack([])
private = await read_optional(dispatch,
posixpath.join(gitdir, INFO_EXCLUDE))
if private is not None:
stack = stack.push("", private)
root = await read_optional(dispatch, posixpath.join(worktree, GITIGNORE))
if root is not None:
stack = stack.push("", root)
return stack
@@ -0,0 +1,96 @@
# ========= 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 posixpath
from io import BytesIO
from typing import IO, Any, BinaryIO, Callable, cast
from dulwich.index import (ConflictedIndexEntry, IndexEntry, read_index_dict,
write_index_dict)
from mirage.commands.cli.builtin.git.io import read_optional, write_file
from mirage.commands.cli.builtin.git.types import IndexState
INDEX_FILE = "index"
MERGE_HEAD = "MERGE_HEAD"
async def read_index(dispatch: Callable[..., Any], gitdir: str) -> IndexState:
"""Read ``.git/index`` through the dispatcher.
The index is the third thing ``status`` compares, and the only one
that is a single file: HEAD's tree is assembled from objects and the
working tree is walked, but staged content is one binary blob. That
makes it the cheapest of the three and the reason it is read whole
rather than windowed.
An absent index is not an error. ``git init`` writes none until the
first ``git add``, and every path is then untracked, which is what an
empty table already says.
The index lives in ``gitdir``, never ``commondir``: a linked
worktree stages its own content, and pointing this at the shared
directory would show one checkout's staged changes in another.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of this checkout's git
directory.
"""
data = await read_optional(dispatch, posixpath.join(gitdir, INDEX_FILE))
merging = await read_optional(dispatch, posixpath.join(gitdir, MERGE_HEAD))
if data is None:
return IndexState(entries={},
conflicts={},
merging=merging is not None)
# Cast because dulwich types the parameter as BinaryIO while reading
# it through the buffer protocol, which BytesIO satisfies.
parsed = read_index_dict(cast(BinaryIO, BytesIO(data)))
entries: dict[bytes, IndexEntry] = {}
conflicts: dict[bytes, ConflictedIndexEntry] = {}
for path, entry in parsed.items():
if isinstance(entry, ConflictedIndexEntry):
conflicts[path] = entry
else:
entries[path] = entry
return IndexState(entries=entries,
conflicts=conflicts,
merging=merging is not None)
async def write_index(dispatch: Callable[..., Any], gitdir: str,
state: IndexState) -> None:
"""Write ``.git/index`` back through the dispatcher.
Written whole, because that is what the format is: a header, every
entry in path order, then a checksum over all of it. git writes a
temporary file and renames it so a reader never sees half an index;
that cannot be reproduced here, since a mount offers no atomic
rename across every backend, and a torn write would leave the
repository unreadable. What bounds the risk instead is that the
index is small and written in one call.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of this checkout's git
directory.
state (IndexState): what the index should now say.
"""
merged: dict[bytes, IndexEntry | ConflictedIndexEntry] = {}
merged.update(state.entries)
merged.update(state.conflicts)
buffer = BytesIO()
write_index_dict(cast(IO[bytes], buffer), merged)
await write_file(dispatch, posixpath.join(gitdir, INDEX_FILE),
buffer.getvalue())
@@ -0,0 +1,188 @@
# ========= 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 logging
import posixpath
from typing import Any, Callable
from mirage.types import PathSpec
from mirage.utils.errors import MISS_ERRORS
logger = logging.getLogger(__name__)
async def read_file(dispatch: Callable[..., Any], path: str) -> bytes:
"""Read one virtual path through the workspace dispatcher.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path.
"""
data, _ = await dispatch("read", PathSpec.from_str_path(path))
return data if isinstance(data, bytes) else bytes(data)
async def read_range(dispatch: Callable[..., Any], path: str, offset: int,
size: int) -> bytes:
"""Read a byte range of one virtual path.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path.
offset (int): first byte to read.
size (int): how many bytes to read.
"""
data, _ = await dispatch("read",
PathSpec.from_str_path(path),
offset=offset,
size=size)
return data if isinstance(data, bytes) else bytes(data)
async def file_size(dispatch: Callable[..., Any], path: str) -> int | None:
"""A path's byte length, or None when the backend does not know it.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path.
"""
stat, _ = await dispatch("stat", PathSpec.from_str_path(path))
return getattr(stat, "size", None)
async def read_optional(dispatch: Callable[..., Any],
path: str) -> bytes | None:
"""Read a path that a repository may legitimately not have.
``packed-refs`` and ``HEAD``-adjacent files are absent in perfectly
valid repositories, so a miss is an answer rather than an error.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path.
"""
try:
return await read_file(dispatch, path)
except MISS_ERRORS:
return None
async def read_names(dispatch: Callable[..., Any], path: str) -> list[str]:
"""List a directory, empty when it does not exist.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path of the directory.
"""
try:
entries, _ = await dispatch("readdir", PathSpec.from_str_path(path))
except MISS_ERRORS:
return []
return list(entries or [])
async def ensure_dir(dispatch: Callable[..., Any], path: str) -> None:
"""Create a directory and every missing directory above it.
Written out rather than delegated to ``mkdir -p`` because the
parents flag is a per-backend capability: the ops factory only wires
``parents=True`` for backends that declare it, so a plain ``mkdir``
of ``objects/ab`` fails on the rest.
Existence is probed with a point stat, which on a prefix store misses
a directory that has no object of its own. That false negative is
harmless here and the reason this does not need the two-channel
stat: on such a store a directory is the set of keys under it, so
creating one again costs a no-op rather than an error.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path of the directory.
"""
missing: list[str] = []
current = path.rstrip("/")
while current and current != "/":
try:
await dispatch("stat", PathSpec.from_str_path(current))
break
except MISS_ERRORS:
missing.append(current)
current = posixpath.dirname(current)
for target in reversed(missing):
await dispatch("mkdir", PathSpec.from_str_path(target))
async def exists(dispatch: Callable[..., Any], path: str) -> bool:
"""Whether a point lookup finds anything at a path.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path.
"""
try:
await dispatch("stat", PathSpec.from_str_path(path))
except MISS_ERRORS:
return False
return True
async def write_file(dispatch: Callable[..., Any], path: str,
data: bytes) -> None:
"""Write one virtual path, creating the directories above it.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path.
data (bytes): the whole contents.
"""
await ensure_dir(dispatch, posixpath.dirname(path))
await dispatch("write", PathSpec.from_str_path(path), data=data)
async def write_once(dispatch: Callable[..., Any], path: str,
data: bytes) -> None:
"""Write a path only if nothing is there yet.
For content-addressed files, which is every object in the database:
a path that exists already holds exactly these bytes, because its
name is a hash of them. Skipping the write is therefore not an
optimisation but a requirement, since git writes loose objects
read-only (0444) and rewriting one fails with EACCES. Re-staging an
unchanged file hits that on the first try.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path.
data (bytes): the whole contents.
"""
if await exists(dispatch, path):
return
await write_file(dispatch, path, data)
async def remove_file(dispatch: Callable[..., Any], path: str) -> None:
"""Delete one virtual path, tolerating one that is already gone.
A miss is an answer rather than an error for every caller here:
unstaging a path deletes whatever ref or lock may or may not exist,
and a checkout removes files the other branch does not have.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path.
"""
try:
await dispatch("unlink", PathSpec.from_str_path(path))
except MISS_ERRORS as exc:
logger.debug("nothing to remove at %s: %s", path, exc)
@@ -0,0 +1,132 @@
# ========= 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 asyncio
from io import SEEK_CUR, SEEK_END, SEEK_SET
from typing import Any, Callable
from mirage.bridge.sync import run_async_from_sync
from mirage.commands.cli.builtin.git.io import read_range
# Measured on a 19-pack, 400 MB repository reading one commit: 64 KiB
# costs 3.4 MB over 63 requests, 256 KiB costs 7.6 MB over 37, and 1 MiB
# costs 11.2 MB over 23. Smaller blocks buy bandwidth and spend
# round trips, which is the wrong way round on an object store where a
# GET is tens of milliseconds. 256 KiB is the middle. Note that 19 of
# those fetches only want a 12-byte pack header, so any block size
# over-fetches them.
BLOCK = 1 << 18
class LazyFile:
"""A read-only seekable file over a mount path, fetched in blocks.
dulwich reaches a packfile through nothing but ``seek`` and ``read``,
so a packfile living in a mount never has to be pulled whole: reads
are served from a block cache and only the blocks a read lands in are
fetched. Pulling one commit out of a 400 MB pack costs a block or
two, which is the same trick git plays with mmap windows, minus the
kernel doing it for us.
Blocking by design. The dulwich object store is synchronous, so the
caller runs it on a worker thread and each fetch is marshalled back
onto the workspace's event loop.
Args:
dispatch (Callable): workspace op dispatcher.
path (str): absolute virtual path of the file.
size (int): the file's byte length.
loop (asyncio.AbstractEventLoop): the loop serving the mount.
"""
def __init__(self, dispatch: Callable[..., Any], path: str, size: int,
loop: asyncio.AbstractEventLoop) -> None:
self._dispatch = dispatch
self._path = path
self._size = size
self._loop = loop
self._blocks: dict[int, bytes] = {}
self._position = 0
def _block(self, index: int) -> bytes:
"""Fetch one block, or serve it from the cache.
Args:
index (int): block number, counted in BLOCK-sized units.
"""
cached = self._blocks.get(index)
if cached is not None:
return cached
start = index * BLOCK
length = min(BLOCK, self._size - start)
data = run_async_from_sync(
read_range(self._dispatch, self._path, start, length), self._loop)
self._blocks[index] = data
return data
def read(self, size: int = -1) -> bytes:
"""Read from the current position, advancing it.
Args:
size (int): how many bytes, or -1 for the rest of the file.
"""
if size < 0:
size = self._size - self._position
size = max(0, min(size, self._size - self._position))
if size == 0:
return b""
chunks = []
remaining = size
while remaining > 0:
index, inside = divmod(self._position, BLOCK)
piece = self._block(index)[inside:inside + remaining]
if not piece:
break
chunks.append(piece)
self._position += len(piece)
remaining -= len(piece)
return b"".join(chunks)
def seek(self, offset: int, whence: int = SEEK_SET) -> int:
"""Move the read position.
Args:
offset (int): byte offset, relative to whence.
whence (int): SEEK_SET, SEEK_CUR or SEEK_END.
"""
if whence == SEEK_SET:
self._position = offset
elif whence == SEEK_CUR:
self._position += offset
elif whence == SEEK_END:
self._position = self._size + offset
else:
raise ValueError(f"invalid whence: {whence}")
self._position = max(0, self._position)
return self._position
def tell(self) -> int:
return self._position
def readable(self) -> bool:
return True
def writable(self) -> bool:
return False
def seekable(self) -> bool:
return True
def close(self) -> None:
self._blocks.clear()
@@ -0,0 +1,92 @@
# ========= 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 asyncio
from typing import Any, Callable
from dulwich.objects import Commit
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.errors import GitError
from mirage.commands.cli.builtin.git.format import entry, oneline
from mirage.commands.cli.builtin.git.history import (LogFlags, parse_flags,
select)
from mirage.commands.cli.builtin.git.objects import abbrev_for
from mirage.commands.cli.builtin.git.revparse import resolve_commit
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.util import (check_operands, fatal,
revision_arg)
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import PathSpec
def _collect(repo: BaseRepo, revision: str, flags: LogFlags) -> list[Commit]:
"""Resolve the starting revision and walk it, synchronously.
Runs on a worker thread. dulwich's walker is synchronous and now
fetches objects as it goes, so it has to sit off the event loop that
serves those fetches.
Args:
repo (BaseRepo): repository to walk.
revision (str): the revision to start from.
flags (LogFlags): the parsed invocation.
"""
return select(repo, resolve_commit(repo, revision), flags)
async def log(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""Show commit logs.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): path operands.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
try:
check_operands(texts)
parsed = parse_flags(fl)
repo, _location = await opened(fl, stat_path, mount_root, dispatch)
commits = await asyncio.to_thread(_collect, repo, revision_arg(texts),
parsed)
except GitError as exc:
return fatal(exc)
width = abbrev_for(repo)
if parsed.oneline:
lines = [oneline(commit, width) for commit in commits]
else:
blocks = [entry(commit, width) for commit in commits]
lines = []
for index, block in enumerate(blocks):
if index:
lines.append("")
lines.extend(block)
if not lines:
return None, IOResult()
return yield_bytes(("\n".join(lines) + "\n").encode()), IOResult()
@@ -0,0 +1,374 @@
# ========= 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 asyncio
import posixpath
from io import BytesIO
from typing import IO, Any, Callable, Iterator, cast
from dulwich.object_format import SHA1
from dulwich.object_store import PackCapableObjectStore
from dulwich.objects import Blob, ObjectID, RawObjectID, ShaFile
from dulwich.pack import Pack, PackData, load_pack_index_file
from dulwich.repo import BaseRepo
from mirage.bridge.sync import run_async_from_sync
from mirage.commands.cli.builtin.git.format import abbrev_length
from mirage.commands.cli.builtin.git.io import (file_size, read_file,
read_names, read_optional,
write_once)
from mirage.commands.cli.builtin.git.lazyfile import LazyFile
OBJECTS_DIR = "objects"
PACK_DIR = "objects/pack"
IDX_SUFFIX = ".idx"
PACK_SUFFIX = ".pack"
FANOUT_LEN = 2
SHA_LEN = 40
def _basename(entry: str) -> str:
"""The final segment of a readdir entry, directory marker stripped.
Args:
entry (str): one entry as the backend reported it, which may be
a bare name or a path and may carry a trailing slash.
"""
return entry.rstrip("/").rsplit("/", 1)[-1]
def loose_path(commondir: str, oid: ObjectID) -> str:
"""Where an object id lives when it is loose.
A pure function of the id, which is why a loose object never has to
be searched for: the read is attempted and a miss means it is not
loose.
Args:
commondir (str): absolute virtual path of the shared git
directory, which owns the object database.
oid (ObjectID): hex object id.
"""
name = oid.decode()
return posixpath.join(commondir, OBJECTS_DIR, name[:FANOUT_LEN],
name[FANOUT_LEN:])
async def store_blob(dispatch: Callable[..., Any], commondir: str,
data: bytes) -> ObjectID:
"""Write file contents into the object database as a blob.
Written straight through the dispatcher rather than through the
object store, because the store is synchronous and would have to be
driven from a worker thread; staging reads its files on the event
loop already, and the loose path is a pure function of the id, so
there is nothing the store would add here.
Args:
dispatch (Callable): workspace op dispatcher.
commondir (str): absolute virtual path of the shared git
directory.
data (bytes): the file's contents.
"""
blob = Blob.from_string(data)
await write_once(dispatch, loose_path(commondir, blob.id),
blob.as_legacy_object())
return blob.id
class LooseObjects:
"""The loose half of an object database, read one object at a time.
A loose object's path is a pure function of its id, so nothing has to
be listed to look one up: the read is attempted and a miss means the
object is not loose. Only enumeration needs the directory walk, and
that reads names rather than contents.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of the ``.git`` directory.
loop (asyncio.AbstractEventLoop): the loop serving the mount.
"""
def __init__(self, dispatch: Callable[..., Any], gitdir: str,
loop: asyncio.AbstractEventLoop) -> None:
self._dispatch = dispatch
self._gitdir = gitdir
self._root = posixpath.join(gitdir, OBJECTS_DIR)
self._loop = loop
self._cache: dict[ObjectID, ShaFile | None] = {}
def _path(self, oid: ObjectID) -> str:
"""Where an object id lives when it is loose.
Args:
oid (ObjectID): hex object id.
"""
return loose_path(self._gitdir, oid)
def get(self, oid: ObjectID) -> ShaFile | None:
"""One loose object, or None when it is not loose here.
Args:
oid (ObjectID): hex object id.
"""
if oid in self._cache:
return self._cache[oid]
data = run_async_from_sync(
read_optional(self._dispatch, self._path(oid)), self._loop)
obj = None if data is None else ShaFile.from_file(BytesIO(data))
self._cache[oid] = obj
return obj
def ids_under(self, fanout: str) -> list[ObjectID]:
"""Every loose id in one fanout directory, by listing only.
Args:
fanout (str): the two-character directory name.
"""
names = run_async_from_sync(
read_names(self._dispatch, posixpath.join(self._root, fanout)),
self._loop)
found = []
for entry in names:
rest = _basename(entry)
if len(fanout) + len(rest) == SHA_LEN:
found.append(ObjectID(f"{fanout}{rest}".encode()))
return found
def ids(self) -> Iterator[ObjectID]:
"""Every loose id, walking the fanout directories by name."""
for entry in run_async_from_sync(
read_names(self._dispatch, self._root), self._loop):
fanout = _basename(entry)
if len(fanout) == FANOUT_LEN:
yield from self.ids_under(fanout)
def put(self, obj: ShaFile) -> None:
"""Write one object out loose, where its id says it belongs.
Objects are written loose and never packed, which is what git
does for anything it creates as it goes; packing is a separate
maintenance step (``git gc``) that mirage does not offer.
Args:
obj (ShaFile): the object to store.
"""
oid = obj.id
run_async_from_sync(
write_once(self._dispatch, self._path(oid),
obj.as_legacy_object()), self._loop)
self._cache[oid] = obj
class VfsObjectStore(PackCapableObjectStore):
"""A git object database whose bytes come from a mirage mount.
Loose objects are fetched by id when something asks for them, so a
repository with thousands of them costs nothing to open. A pack
contributes its index, which says whether an object is here and
where it sits; the packfile itself is a window that fetches only the
blocks a read lands in.
Being lazy means the store reflects the backend as it is now rather
than as it was when the store was built, which is also what git does.
Writes go back the same way reads come: one loose object per call,
through the dispatcher, marshalled onto the workspace's loop. That
is what lets dulwich's own tree builder run against a mount, so
``commit`` assembles its trees with the same code a local
repository would use. Packs stay read-only, which costs nothing:
git never writes into an existing pack either.
Args:
loose (LooseObjects): the loose half, read on demand.
packs (list[Pack]): every pack, already open.
"""
def __init__(self, loose: LooseObjects, packs: list[Pack]) -> None:
self._loose = loose
self._packs = packs
self.object_format = SHA1
@property
def packed_count(self) -> int:
"""How many objects the packs hold, for the id abbreviation.
Read off each pack index, which states its own count, so this
costs nothing already paid for. Loose objects are deliberately
not counted: git's own estimate ignores them, and matching that
is what makes an abbreviated id agree with real git.
"""
return sum(len(pack.index) for pack in self._packs)
def contains_loose(self, sha: ObjectID | RawObjectID) -> bool:
"""Whether an object id names a loose object here.
Args:
sha (ObjectID | RawObjectID): hex object id.
"""
return self._loose.get(ObjectID(bytes(sha))) is not None
def __contains__(self, sha: object) -> bool:
if not isinstance(sha, bytes):
return False
oid = ObjectID(sha)
if any(oid in pack for pack in self._packs):
return True
return self.contains_loose(oid)
def __iter__(self) -> Iterator[ObjectID]:
for pack in self._packs:
yield from pack
yield from self._loose.ids()
def iter_prefix(self, prefix: bytes) -> Iterator[ObjectID]:
"""Every id starting with a hex prefix, without reading objects.
Overridden because the inherited version walks the whole store,
which for a lazy database means fetching every object to answer
an abbreviated id. A pack index is already in memory and sorted,
and the loose half narrows to a single fanout directory.
Args:
prefix (bytes): hex id prefix, as typed.
"""
seen: set[ObjectID] = set()
for pack in self._packs:
for oid in pack:
if oid.startswith(prefix) and oid not in seen:
seen.add(oid)
yield oid
if len(prefix) < FANOUT_LEN:
candidates = list(self._loose.ids())
else:
candidates = self._loose.ids_under(prefix[:FANOUT_LEN].decode())
for oid in candidates:
if oid.startswith(prefix) and oid not in seen:
seen.add(oid)
yield oid
def get_raw(self, sha: ObjectID | RawObjectID) -> tuple[int, bytes]:
"""The raw (type number, contents) for one object id.
Args:
sha (ObjectID | RawObjectID): hex object id.
"""
oid = ObjectID(bytes(sha))
for pack in self._packs:
if oid in pack:
return pack.get_raw(oid)
obj = self._loose.get(oid)
if obj is not None:
return obj.type_num, obj.as_raw_string()
raise KeyError(sha)
def __getitem__(self, sha: ObjectID | RawObjectID) -> ShaFile:
oid = ObjectID(bytes(sha))
type_num, raw = self.get_raw(oid)
return ShaFile.from_raw_string(type_num, raw, sha=oid)
def add_object(self, obj: ShaFile) -> None:
"""Store one object, loose.
Args:
obj (ShaFile): the object to store.
"""
self._loose.put(obj)
def add_objects(self, objects, progress=None) -> None:
"""Store several objects, loose.
Args:
objects (Iterable): (object, path) pairs, dulwich's shape.
progress (Callable | None): ignored; nothing here is slow
enough to report on and there is no terminal to report
to.
"""
for obj, _path in objects:
self._loose.put(obj)
def add_pack(self):
# Packing is git's maintenance step, not part of any verb mirage
# offers, and a pack cannot be built one object at a time through
# a dispatcher anyway.
raise NotImplementedError("VfsObjectStore writes loose objects only")
def add_pack_data(self, count, unpacked_objects, progress=None):
raise NotImplementedError("VfsObjectStore writes loose objects only")
async def load_packs(dispatch: Callable[..., Any], gitdir: str,
loop: asyncio.AbstractEventLoop) -> list[Pack]:
"""Open every packfile under ``.git/objects/pack``.
The index is read whole, because dulwich unpacks it through the
buffer protocol and it is the small half. The packfile is opened as
a lazy window instead: dulwich reaches pack data through nothing but
seek and read, so a lookup fetches the blocks it lands in rather than
the whole file. A backend that cannot report a size has to be read
whole, which is correct but costs what this exists to avoid.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of the ``.git`` directory.
loop (asyncio.AbstractEventLoop): the loop serving the mount.
"""
root = posixpath.join(gitdir, PACK_DIR)
packs: list[Pack] = []
for entry in await read_names(dispatch, root):
name = _basename(entry)
if not name.endswith(IDX_SUFFIX):
continue
stem = name[:-len(IDX_SUFFIX)]
idx_bytes = await read_file(dispatch, posixpath.join(root, name))
index = load_pack_index_file(name, BytesIO(idx_bytes), SHA1)
pack_path = posixpath.join(root, f"{stem}{PACK_SUFFIX}")
size = await file_size(dispatch, pack_path)
if size is None:
raw = await read_file(dispatch, pack_path)
data = PackData.from_file(BytesIO(raw), SHA1, len(raw))
else:
# Cast because LazyFile implements the four methods dulwich
# actually calls on pack data (read, seek, tell, close) and
# not the rest of IO[bytes], most of which is writing and
# means nothing for a read-only window.
window = cast(IO[bytes], LazyFile(dispatch, pack_path, size, loop))
data = PackData.from_file(window, SHA1, size)
packs.append(Pack.from_objects(data, index))
return packs
async def load_object_store(dispatch: Callable[..., Any],
gitdir: str) -> VfsObjectStore:
"""Assemble the object database for one repository.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of the ``.git`` directory.
"""
loop = asyncio.get_running_loop()
return VfsObjectStore(LooseObjects(dispatch, gitdir, loop), await
load_packs(dispatch, gitdir, loop))
def abbrev_for(repo: BaseRepo) -> int:
"""How many hex digits this repository abbreviates an id to.
Args:
repo (BaseRepo): an opened repository.
"""
store = repo.object_store
packed = store.packed_count if isinstance(store, VfsObjectStore) else 0
return abbrev_length(packed)
@@ -0,0 +1,82 @@
# ========= 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 posixpath
from mirage.commands.cli.builtin.git.errors import OutsideRepositoryError
from mirage.commands.cli.builtin.git.types import RepoLocation
def absolute_operand(start: str, operand: str) -> str:
"""The virtual path a path operand names.
Resolved against the directory git was told to run in, not the
session's, because ``-C`` moves before anything else happens and a
pathspec is read from where git ended up. Resolving against the
session cwd instead would make ``git -C /repo add letters.txt``
reach for a file beside the shell rather than inside the repository.
Args:
start (str): absolute virtual path git is running in.
operand (str): the operand as the user spelled it.
"""
if operand.startswith("/"):
return posixpath.normpath(operand)
return posixpath.normpath(posixpath.join(start, operand))
def repo_relative(location: RepoLocation, start: str, operand: str) -> str:
"""A path operand as a repository-relative path.
Empty string for the working tree root itself, which is what
``git add .`` from the top resolves to and means "everything".
Args:
location (RepoLocation): the discovered repository.
start (str): absolute virtual path git is running in.
operand (str): the operand as the user spelled it.
"""
absolute = absolute_operand(start, operand)
root = location.worktree.rstrip("/") or "/"
if absolute == root:
return ""
prefix = root if root.endswith("/") else f"{root}/"
if not absolute.startswith(prefix):
raise OutsideRepositoryError(operand, root)
return absolute[len(prefix):]
def under(path: str, directory: str) -> bool:
"""Whether a repository-relative path sits inside a directory.
An empty directory is the working tree root, which everything is
under.
Args:
path (str): repository-relative path.
directory (str): repository-relative directory.
"""
return not directory or path.startswith(f"{directory}/")
def matched(paths: set[str], target: str) -> set[str]:
"""Every path a single operand selects: itself, or a whole subtree.
Args:
paths (set[str]): the candidate paths, repository-relative.
target (str): the operand, repository-relative.
"""
if target in paths:
return {target}
return {path for path in paths if under(path, target)}
@@ -0,0 +1,68 @@
# ========= 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. =========
from dulwich.diff_tree import tree_changes
from dulwich.object_store import BaseObjectStore
from dulwich.objects import Blob, Commit, ObjectID
EMPTY_TREE = None
def _occurrences(store: BaseObjectStore, sha: ObjectID | None,
needle: bytes) -> int:
"""How many times a string appears in one blob.
Args:
store (BaseObjectStore): object database holding the blob.
sha (ObjectID | None): blob id, None when the side does not
exist.
needle (bytes): the string being counted.
"""
if sha is None:
return 0
obj = store[sha]
if not isinstance(obj, Blob):
return 0
return obj.data.count(needle)
def touches(store: BaseObjectStore, commit: Commit, needle: bytes) -> bool:
"""Whether a commit changed the number of occurrences of a string.
This is git's ``-S`` (pickaxe), and it is deliberately not a grep: a
commit that merely moves a line containing the string does not
change how many times the string appears, so it is not reported. The
commit that *introduced* the string is, which is what makes
``-S <name> --reverse`` answer "where did this come from".
Compared against the first parent, or against nothing for a root
commit, so the objects a root commit adds all count as introduced.
Args:
store (BaseObjectStore): object database holding the trees.
commit (Commit): the commit to test.
needle (bytes): the string being counted.
"""
parent_tree = EMPTY_TREE
if commit.parents:
parent = store[commit.parents[0]]
assert isinstance(parent, Commit)
parent_tree = parent.tree
for change in tree_changes(store, parent_tree, commit.tree):
old = change.old.sha if change.old is not None else None
new = change.new.sha if change.new is not None else None
if _occurrences(store, old,
needle) != _occurrences(store, new, needle):
return True
return False
@@ -0,0 +1,91 @@
# ========= 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 posixpath
from typing import Any, Callable
from mirage.commands.cli.builtin.git.io import read_optional, write_file
LOGS_DIR = "logs"
HEAD_LOG = "logs/HEAD"
ZERO = b"0" * 40
def entry(before: bytes, after: bytes, who: bytes, when: int,
message: str) -> bytes:
"""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.
Args:
before (bytes): the id the ref held, zeroes when it held none.
after (bytes): the id it now holds.
who (bytes): the identity, ``Name <email>``.
when (int): epoch seconds.
message (str): what happened, e.g. ``commit: add delta``.
"""
return (b"%s %s %s %d +0000\t%s\n" %
(before, after, who, when, message.encode()))
async def append(dispatch: Callable[..., Any], gitdir: str, path: str,
line: bytes) -> None:
"""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)``.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of the git directory owning
the log.
path (str): log path relative to it, e.g. ``logs/HEAD``.
line (bytes): the line to add, newline included.
"""
target = posixpath.join(gitdir, path)
existing = await read_optional(dispatch, target)
await write_file(dispatch, target, (existing or b"") + line)
async def record(dispatch: Callable[..., Any], gitdir: str, ref: str | None,
before: bytes | None, after: bytes, who: bytes, when: int,
message: str) -> None:
"""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.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of this checkout's git
directory, which owns the logs.
ref (str | None): the branch ref that also moved, None when
HEAD is detached.
before (bytes | None): the id HEAD held, None when it held none.
after (bytes): the id it now holds.
who (bytes): the identity to record.
when (int): epoch seconds.
message (str): what happened.
"""
line = entry(before or ZERO, after, who, when, message)
await append(dispatch, gitdir, HEAD_LOG, line)
if ref is not None:
await append(dispatch, gitdir, posixpath.join(LOGS_DIR, ref), line)
@@ -0,0 +1,201 @@
# ========= 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 posixpath
from io import BytesIO
from typing import Any, Callable
from dulwich.refs import DictRefsContainer, Ref, read_packed_refs_with_peeled
from mirage.commands.cli.builtin.git.io import (read_file, read_names,
read_optional, remove_file,
write_file)
from mirage.commands.cli.builtin.git.types import HeadRef
HEAD_FILE = "HEAD"
PACKED_REFS = "packed-refs"
REFS_DIR = "refs"
SYMREF_PREFIX = "ref: "
BRANCH_PREFIX = "refs/heads/"
HEAD_REF = Ref(b"HEAD")
async def read_head(dispatch: Callable[..., Any], gitdir: str) -> HeadRef:
"""Resolve ``.git/HEAD`` to a branch name or a detached commit.
HEAD holds either a symbolic ref (``ref: refs/heads/main``) or a raw
object id when the checkout is detached. A ref outside ``refs/heads``
keeps its full name, which is what git shows for a checked-out tag or
remote-tracking ref.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of the ``.git`` directory.
"""
raw = await read_file(dispatch, posixpath.join(gitdir, HEAD_FILE))
text = raw.decode("utf-8", errors="replace").strip()
if not text.startswith(SYMREF_PREFIX):
return HeadRef(branch=None, ref=None, commit=text or None)
ref = text[len(SYMREF_PREFIX):].strip()
branch = (ref[len(BRANCH_PREFIX):]
if ref.startswith(BRANCH_PREFIX) else ref)
return HeadRef(branch=branch, ref=ref, commit=None)
async def _walk_loose_refs(dispatch: Callable[..., Any], root: str,
prefix: str, refs: dict[Ref, bytes]) -> None:
"""Collect loose refs under one directory into the ref table.
Ref names nest arbitrarily (``refs/heads/feat/git-cli``,
``refs/remotes/origin/main``), so the walk recurses rather than
listing one level.
Args:
dispatch (Callable): workspace op dispatcher.
root (str): absolute virtual path of the directory to walk.
prefix (str): ref-name prefix accumulated so far.
refs (dict[Ref, bytes]): ref table, updated in place.
"""
for entry in await read_names(dispatch, root):
name = entry.rstrip("/").rsplit("/", 1)[-1]
if not name:
continue
child = posixpath.join(root, name)
data = await read_optional(dispatch, child)
if data is None:
await _walk_loose_refs(dispatch, child, f"{prefix}/{name}", refs)
continue
value = data.strip()
if value:
refs[Ref(f"{prefix}/{name}".encode())] = value
async def write_ref(dispatch: Callable[..., Any], commondir: str, ref: str,
sha: bytes) -> None:
"""Point one ref at an object id, as a loose ref file.
Always written loose, never into ``packed-refs``: git does the same
for any ref it updates, and a loose file takes precedence over the
packed copy, so a branch that was packed is correctly overridden
rather than duplicated.
Refs live in the common directory, so a branch made from a linked
worktree is visible to the repository it was cut from, which is what
makes ``git worktree`` share branches at all.
Args:
dispatch (Callable): workspace op dispatcher.
commondir (str): absolute virtual path of the shared git
directory.
ref (str): full ref name, e.g. ``refs/heads/main``.
sha (bytes): hex object id the ref should name.
"""
await write_file(dispatch, posixpath.join(commondir, ref), sha + b"\n")
async def delete_ref(dispatch: Callable[..., Any], commondir: str,
ref: str) -> None:
"""Remove a loose ref file.
Only the loose copy is removed. A ref that also sits in
``packed-refs`` would come back, which is a real gap rather than a
silent one: ``branch -d`` refuses below unless the loose file is
what actually holds the branch.
Args:
dispatch (Callable): workspace op dispatcher.
commondir (str): absolute virtual path of the shared git
directory.
ref (str): full ref name.
"""
await remove_file(dispatch, posixpath.join(commondir, ref))
async def set_head(dispatch: Callable[..., Any], gitdir: str,
ref: str) -> None:
"""Point HEAD at a branch, symbolically.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of this checkout's git
directory, which owns HEAD.
ref (str): full ref name to attach to.
"""
await write_file(dispatch, posixpath.join(gitdir, HEAD_FILE),
f"{SYMREF_PREFIX}{ref}\n".encode())
async def detach_head(dispatch: Callable[..., Any], gitdir: str,
sha: bytes) -> None:
"""Point HEAD straight at a commit, detaching it from any branch.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of this checkout's git
directory.
sha (bytes): hex object id to check out.
"""
await write_file(dispatch, posixpath.join(gitdir, HEAD_FILE), sha + b"\n")
async def load_refs(dispatch: Callable[..., Any],
gitdir: str,
commondir: str | None = None) -> DictRefsContainer:
"""Read every ref a repository publishes, packed and loose.
Both sources are needed and neither is optional: a freshly cloned
repository keeps ``refs/remotes/origin/main`` only in
``packed-refs``, while a branch committed to since the last pack
exists only as a loose file. Loose wins on a collision, which is
git's own precedence.
``packed-refs`` records an annotated tag twice: the tag object's own
id, then a ``^`` line holding the commit it points at. The peeled id
is a lookup shortcut, not a separate ref, so it is read and
discarded; resolving a tag loads the tag object and follows it. The
reader that ignores peeled lines rejects the file outright, which is
most real repositories.
Refs come from two directories when the two differ. A linked
worktree shares its branches with the repository it was cut from and
keeps only its own HEAD and per-checkout refs (``refs/bisect``,
``refs/worktree``), so the shared table is read first and the
worktree's own overrides it, then HEAD last of all.
Args:
dispatch (Callable): workspace op dispatcher.
gitdir (str): absolute virtual path of this checkout's git
directory, which owns HEAD.
commondir (str | None): absolute virtual path of the shared git
directory, which owns the branches. None means it is the
same directory, which is every ordinary checkout.
"""
shared = commondir or gitdir
refs: dict[Ref, bytes] = {}
packed = await read_optional(dispatch, posixpath.join(shared, PACKED_REFS))
if packed is not None:
for sha, name, _peeled in read_packed_refs_with_peeled(
BytesIO(packed)):
refs[name] = sha
await _walk_loose_refs(dispatch, posixpath.join(shared, REFS_DIR),
REFS_DIR, refs)
if gitdir != shared:
await _walk_loose_refs(dispatch, posixpath.join(gitdir, REFS_DIR),
REFS_DIR, refs)
head = await read_head(dispatch, gitdir)
if head.ref is not None:
refs[HEAD_REF] = f"{SYMREF_PREFIX}{head.ref}".encode()
elif head.commit is not None:
refs[HEAD_REF] = head.commit.encode()
return DictRefsContainer(refs)
@@ -0,0 +1,355 @@
# ========= 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. =========
from mirage.commands.cli.builtin.git.types import StatusEntry
UNCHANGED = " "
UNTRACKED = "?"
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).
LABEL_WIDTH = 12
CONFLICT_WIDTH = 17
STAGED_LABELS = {
"M": "modified:",
"A": "new file:",
"D": "deleted:",
"R": "renamed:",
"C": "copied:",
"T": "typechange:",
}
WORK_LABELS = {
"M": "modified:",
"D": "deleted:",
"T": "typechange:",
}
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.
ESCAPES = {
0x07: "\\a",
0x08: "\\b",
0x0C: "\\f",
0x0A: "\\n",
0x0D: "\\r",
0x09: "\\t",
0x0B: "\\v",
0x22: '\\"',
0x5C: "\\\\",
}
ON_BRANCH = "On branch "
DETACHED = "HEAD detached at "
NO_COMMITS = "No commits yet"
BRANCH_MARK = "## "
NO_COMMITS_BRANCH = "No commits yet on "
STAGED_HEADER = "Changes to be committed:"
UNMERGED_HEADER = "Unmerged paths:"
WORK_HEADER = "Changes not staged for commit:"
UNTRACKED_HEADER = "Untracked files:"
UNSTAGE_HINT = ' (use "git restore --staged <file>..." to unstage)'
UNCACHE_HINT = ' (use "git rm --cached <file>..." to unstage)'
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.
WORK_HINT = ' (use "git add <file>..." to update what will be committed)'
WORK_HINT_DELETED = (' (use "git add/rm <file>..." to update what will be '
'committed)')
DISCARD_HINT = (' (use "git restore <file>..." to discard changes in '
'working directory)')
UNTRACKED_HINT = (' (use "git add <file>..." to include in what will be '
'committed)')
CONFLICT_HEADER = ("You have unmerged paths.",
' (fix conflicts and run "git commit")',
' (use "git merge --abort" to abort the merge)')
RESOLVED_HEADER = ("All conflicts fixed but you are still merging.",
' (use "git commit" to conclude merge)')
CLEAN = "nothing to commit, working tree clean"
CLEAN_INITIAL = 'nothing to commit (create/copy files and use "git add" to '\
'track)'
UNSTAGED_ONLY = 'no changes added to commit (use "git add" and/or "git '\
'commit -a")'
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.
UNTRACKED_HIDDEN = "Untracked files not listed (use -u option to show "\
"untracked files)"
CLEAN_UNSCANNED = "nothing to commit (use -u to show untracked files)"
def quote_path(path: str, porcelain: bool) -> str:
"""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.
Args:
path (str): repository-relative path.
porcelain (bool): whether a space alone forces quoting.
"""
raw = path.encode("utf-8", errors="surrogateescape")
special = any(byte in ESCAPES or byte < 0x20 or byte >= 0x7F
for byte in raw)
if not special and not (porcelain and b" " in raw):
return path
if not special:
return f'"{path}"'
out = []
for byte in raw:
escape = ESCAPES.get(byte)
if escape is not None:
out.append(escape)
elif byte < 0x20 or byte >= 0x7F:
out.append(f"\\{byte:03o}")
else:
out.append(chr(byte))
body = "".join(out)
return f'"{body}"'
def short_line(entry: StatusEntry) -> str:
"""One row of ``--short`` / ``--porcelain`` output.
Args:
entry (StatusEntry): the row.
"""
path = quote_path(entry.path, True)
if entry.original is not None:
path = f"{quote_path(entry.original, True)} -> {path}"
return f"{entry.index_status}{entry.tree_status} {path}"
def branch_line(branch: str | None, commit: str | None,
no_commits: bool) -> str:
"""The ``## `` header ``--branch`` prepends to the short formats.
Args:
branch (str | None): the branch HEAD names, None when detached.
commit (str | None): the abbreviated commit HEAD holds, set only
when detached.
no_commits (bool): whether HEAD resolves to nothing yet.
"""
if branch is None:
return f"{BRANCH_MARK}HEAD (no branch)"
if no_commits:
return f"{BRANCH_MARK}{NO_COMMITS_BRANCH}{branch}"
return f"{BRANCH_MARK}{branch}"
def short_format(rows: list[StatusEntry], header: str | None) -> str:
"""The whole of ``--short`` / ``--porcelain`` output.
Args:
rows (list[StatusEntry]): every row, already in git's order.
header (str | None): the ``## `` line, or None without
``--branch``.
"""
lines = [] if header is None else [header]
lines.extend(short_line(row) for row in rows)
return "".join(f"{line}\n" for line in lines)
def _section(header: str, hints: tuple[str, ...],
entries: list[str]) -> list[str]:
"""One block of the long format, or nothing when it has no entries.
Args:
header (str): the section's own line.
hints (tuple[str, ...]): the parenthesised advice under it.
entries (list[str]): already-rendered entry lines.
"""
if not entries:
return []
return [header, *hints, *entries, ""]
def _labelled(label: str, path: str, width: int) -> str:
"""One entry line: a tab, a padded label, then the path.
Args:
label (str): the label including its colon.
path (str): the path as it should read.
width (int): the column the path starts at.
"""
return f"\t{label:<{width}}{path}"
def _staged_entries(rows: list[StatusEntry]) -> list[str]:
"""The entry lines of the "Changes to be committed" section.
Args:
rows (list[StatusEntry]): every row.
"""
lines = []
for row in rows:
if row.index_status in (UNCHANGED, UNTRACKED, UNMERGED_COLUMN):
continue
label = STAGED_LABELS.get(row.index_status, "modified:")
path = quote_path(row.path, False)
if row.original is not None:
path = f"{quote_path(row.original, False)} -> {path}"
lines.append(_labelled(label, path, LABEL_WIDTH))
return lines
def _work_entries(rows: list[StatusEntry]) -> list[str]:
"""The entry lines of the "Changes not staged for commit" section.
Args:
rows (list[StatusEntry]): every row.
"""
lines = []
for row in rows:
if row.index_status == UNMERGED_COLUMN or row.tree_status in (
UNCHANGED, UNTRACKED):
continue
label = WORK_LABELS.get(row.tree_status, "modified:")
lines.append(_labelled(label, quote_path(row.path, False),
LABEL_WIDTH))
return lines
def _unmerged_entries(rows: list[StatusEntry]) -> list[str]:
"""The entry lines of the "Unmerged paths" section.
Args:
rows (list[StatusEntry]): every row.
"""
lines = []
for row in rows:
code = f"{row.index_status}{row.tree_status}"
if code not in CONFLICT_LABELS:
continue
lines.append(
_labelled(CONFLICT_LABELS[code], quote_path(row.path, False),
CONFLICT_WIDTH))
return lines
def _untracked_entries(rows: list[StatusEntry]) -> list[str]:
"""The entry lines of the "Untracked files" section.
Args:
rows (list[StatusEntry]): every row.
"""
return [
f"\t{quote_path(row.path, False)}" for row in rows
if row.index_status == UNTRACKED
]
def _trailer(staged: list[str], work: list[str], unmerged: list[str],
untracked: list[str], no_commits: bool,
hide_untracked: bool) -> list[str]:
"""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.
Args:
staged (list[str]): rendered staged entries.
work (list[str]): rendered unstaged entries.
unmerged (list[str]): rendered unmerged entries.
untracked (list[str]): rendered untracked entries.
no_commits (bool): whether HEAD resolves to nothing yet.
hide_untracked (bool): whether ``-uno`` suppressed the scan.
"""
if staged:
return [UNTRACKED_HIDDEN] if hide_untracked else []
if work or unmerged:
return [UNSTAGED_ONLY]
if untracked:
return [UNTRACKED_ONLY]
if no_commits:
return [CLEAN_INITIAL]
return [CLEAN_UNSCANNED if hide_untracked else CLEAN]
def long_format(rows: list[StatusEntry], branch: str | None,
commit: str | None, no_commits: bool, merging: bool,
hide_untracked: bool) -> str:
"""The default, human-readable status report.
Args:
rows (list[StatusEntry]): every row, already in git's order.
branch (str | None): the branch HEAD names, None when detached.
commit (str | None): the abbreviated commit HEAD holds, set only
when detached.
no_commits (bool): whether HEAD resolves to nothing yet.
merging (bool): whether a merge is in progress.
hide_untracked (bool): whether ``-uno`` suppressed the scan.
"""
staged = _staged_entries(rows)
unmerged = _unmerged_entries(rows)
work = _work_entries(rows)
untracked = _untracked_entries(rows)
lines = [
f"{ON_BRANCH}{branch}"
if branch is not None else f"{DETACHED}{commit or ''}"
]
if no_commits:
lines.extend(["", NO_COMMITS, ""])
if merging:
lines.extend(CONFLICT_HEADER if unmerged else RESOLVED_HEADER)
lines.append("")
# A merge in progress removes the unstage hint, because unstaging is
# not what resolving a conflict means and git stops offering it.
if merging:
staged_hints: tuple[str, ...] = ()
else:
staged_hints = (UNCACHE_HINT if no_commits else UNSTAGE_HINT, )
lines.extend(_section(STAGED_HEADER, staged_hints, staged))
lines.extend(_section(UNMERGED_HEADER, (RESOLVE_HINT, ), unmerged))
# 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.
deleted = any(line.startswith(f"\t{WORK_LABELS['D']}") for line in work)
lines.extend(
_section(WORK_HEADER,
(WORK_HINT_DELETED if deleted else WORK_HINT, DISCARD_HINT),
work))
lines.extend(_section(UNTRACKED_HEADER, (UNTRACKED_HINT, ), untracked))
lines.extend(
_trailer(staged, work, unmerged, untracked, no_commits,
hide_untracked))
return "".join(f"{line}\n" for line in lines)
@@ -0,0 +1,49 @@
# ========= 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. =========
from typing import Any, Callable
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.objects import load_object_store
from mirage.commands.cli.builtin.git.refs import load_refs
from mirage.commands.cli.builtin.git.types import RepoLocation
async def open_repo(dispatch: Callable[..., Any],
location: RepoLocation) -> BaseRepo:
"""Open a repository living in a mount as a dulwich repository.
This is the async-to-sync boundary the whole design turns on. Every
byte is fetched here, through the dispatcher; what comes back is an
ordinary `BaseRepo`, so dulwich's own algorithms (the history
walker, tree diff, three-way merge) run against a mount without ever
learning that one exists. `BaseRepo` is the pluggable half of
dulwich: `Repo` is the one that insists on a real filesystem.
No working tree and no index are attached. Those are the parts
dulwich hardwires to disk, and the parts mirage has to own.
Objects come from the common directory and refs from both: a linked
worktree shares the object database and the branches of the
repository it was cut from, and owns only HEAD and whatever refs
are per-checkout.
Args:
dispatch (Callable): workspace op dispatcher.
location (RepoLocation): the discovered repository.
"""
store = await load_object_store(dispatch, location.commondir)
refs = await load_refs(dispatch, location.gitdir, location.commondir)
return BaseRepo(store, refs)
@@ -0,0 +1,162 @@
# ========= 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 asyncio
from typing import Any, Callable
from dulwich.index import IndexEntry
from dulwich.objects import ObjectID
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.changes import head_entries, work_changes
from mirage.commands.cli.builtin.git.errors import ( # yapf: disable
AmbiguousArgumentError, GitError, NoWorkspaceError, RevisionResetError,
UnknownSwitchError)
from mirage.commands.cli.builtin.git.index import read_index, write_index
from mirage.commands.cli.builtin.git.pathspec import matched, repo_relative
from mirage.commands.cli.builtin.git.revparse import resolve_commit
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.util import (check_operands, fatal,
start_point)
from mirage.commands.cli.builtin.git.worktree import UNTRACKED_NO, scan
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import PathSpec
UNSTAGED_HEADER = "Unstaged changes after reset:"
def _unmatched(repo: BaseRepo, operand: str) -> GitError:
"""Which fatal an operand that selected no path deserves.
Two different mistakes reach here and git words them differently. A
typo names neither a revision nor a path, and git calls that
ambiguous. A revision names something real that this build cannot
reset to, and answering "unknown revision" for a revision it can
resolve perfectly well would send the caller looking for the wrong
problem.
Args:
repo (BaseRepo): the opened repository, for the revision lookup.
operand (str): the operand as the user spelled it.
"""
try:
resolve_commit(repo, operand)
except GitError:
return AmbiguousArgumentError(operand)
return RevisionResetError(operand)
def restored(sha: ObjectID, mode: int) -> IndexEntry:
"""An index entry putting a path back to what HEAD records.
The stat fields are zeroed for the same reason staging zeroes them:
a mount serves none of them meaningfully, and git reads a zeroed
entry as one whose cache it should not trust rather than as a
corrupt one.
Args:
sha (bytes): the blob id HEAD holds for the path.
mode (int): the mode HEAD holds for it.
"""
return IndexEntry(ctime=0,
mtime=0,
dev=0,
ino=0,
mode=mode,
uid=0,
gid=0,
size=0,
sha=sha)
async def reset(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""Put the index back to what HEAD records, staging nothing.
The working tree is never touched: this is ``git reset`` in its
default mixed mode, which unstages. ``--hard`` is deliberately not
offered, because it destroys uncommitted work and there is no
reflog here to recover it from.
A pathspec limits the reset to those paths, which is how a single
file is unstaged.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): path operands, unused; pathspecs arrive
as text so they can be resolved against ``-C``.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
try:
if stat_path is None or mount_root is None or dispatch is None:
raise NoWorkspaceError()
check_operands(texts, UnknownSwitchError)
repo, location = await opened(fl, stat_path, mount_root, dispatch)
state = await read_index(dispatch, location.gitdir)
tree = await asyncio.to_thread(head_entries, repo) or {}
start = start_point(fl)
names = {path.decode("utf-8", errors="replace") for path in tree}
names |= {
path.decode("utf-8", errors="replace")
for path in state.entries
}
if texts:
selected: set[str] = set()
for operand in texts:
hits = matched(names, repo_relative(location, start, operand))
if not hits:
raise _unmatched(repo, operand)
selected |= hits
else:
selected = names
for name in selected:
key = name.encode()
recorded = tree.get(key)
if recorded is None:
state.entries.pop(key, None)
else:
state.entries[key] = restored(ObjectID(recorded[1]),
recorded[0])
if not texts:
state.conflicts.clear()
await write_index(dispatch, location.gitdir, state)
found = await scan(
dispatch, stat_path, location,
{path.decode("utf-8", errors="replace")
for path in state.entries}, UNTRACKED_NO)
unstaged = await work_changes(dispatch, location.worktree,
state.entries, found)
except GitError as exc:
return fatal(exc)
if not unstaged:
return None, IOResult()
lines = [UNSTAGED_HEADER]
lines.extend(f"{letter}\t{path}"
for path, letter in sorted(unstaged.items()))
return yield_bytes("".join(f"{line}\n"
for line in lines).encode()), IOResult()
@@ -0,0 +1,122 @@
# ========= 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. =========
from dulwich.objects import Commit, ObjectID
from dulwich.objectspec import parse_commit
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.errors import AmbiguousArgumentError
from mirage.commands.cli.builtin.git.types import AncestryStep
HEAD = "HEAD"
ANCESTOR = "~"
PARENT = "^"
SUFFIXES = (ANCESTOR, PARENT)
def split_revision(revision: str) -> tuple[str, tuple[AncestryStep, ...]]:
"""Split a revision into its base and its ancestry suffixes.
``HEAD~2^2`` is a base plus two steps. Splitting on the first ``~``
or ``^`` is safe because git forbids both characters in ref names,
so neither can belong to the base.
Args:
revision (str): revision as the user spelled it.
"""
index = next(
(i for i, ch in enumerate(revision) if ch in SUFFIXES),
len(revision),
)
base, rest = revision[:index], revision[index:]
steps: list[AncestryStep] = []
position = 0
while position < len(rest):
kind = rest[position]
position += 1
digits = ""
while position < len(rest) and rest[position].isdigit():
digits += rest[position]
position += 1
if digits == "":
count = 1
else:
count = int(digits)
steps.append(AncestryStep(first_parent=kind == ANCESTOR, count=count))
return base or HEAD, tuple(steps)
def _step(repo: BaseRepo, commit: Commit, step: AncestryStep,
revision: str) -> Commit:
"""Apply one ancestry suffix to a commit.
``~n`` walks n generations along first parents; ``^n`` takes the
n-th parent of this commit, and ``^0`` is the commit itself (git's
way of spelling "the commit a tag points at").
Args:
repo (BaseRepo): repository to resolve parents against.
commit (Commit): the commit the previous step produced.
step (AncestryStep): the suffix to apply.
revision (str): the whole revision, for error attribution.
"""
if step.first_parent:
for _ in range(step.count):
if not commit.parents:
raise AmbiguousArgumentError(revision)
commit = _commit_at(repo, commit.parents[0], revision)
return commit
if step.count == 0:
return commit
if step.count > len(commit.parents):
raise AmbiguousArgumentError(revision)
return _commit_at(repo, commit.parents[step.count - 1], revision)
def _commit_at(repo: BaseRepo, sha: ObjectID, revision: str) -> Commit:
"""Load one commit by object id, or report the revision as unknown.
Args:
repo (BaseRepo): repository holding the object.
sha (ObjectID): hex object id.
revision (str): the whole revision, for error attribution.
"""
try:
obj = repo.object_store[sha]
except KeyError as exc:
raise AmbiguousArgumentError(revision) from exc
if not isinstance(obj, Commit):
raise AmbiguousArgumentError(revision)
return obj
def resolve_commit(repo: BaseRepo, revision: str) -> Commit:
"""Resolve a revision to a commit, ancestry suffixes included.
dulwich resolves refs, full ids and unambiguous short ids, but knows
nothing about ``~`` and ``^``; those are applied here, on top of
whatever its own parser returns.
Args:
repo (BaseRepo): repository to resolve against.
revision (str): revision as the user spelled it.
"""
base, steps = split_revision(revision)
try:
commit = parse_commit(repo, base)
except (KeyError, ValueError) as exc:
raise AmbiguousArgumentError(revision) from exc
for step in steps:
commit = _step(repo, commit, step, revision)
return commit
@@ -0,0 +1,51 @@
# ========= 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. =========
from typing import Any, Callable
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.discover import discover
from mirage.commands.cli.builtin.git.errors import NoWorkspaceError
from mirage.commands.cli.builtin.git.repo import open_repo
from mirage.commands.cli.builtin.git.types import RepoLocation
from mirage.commands.cli.builtin.git.util import start_point
from mirage.commands.spec.types import FlagView
from mirage.ops.types import MountRoot, StatPath
async def opened(
fl: FlagView,
stat_path: StatPath | None,
mount_root: MountRoot | None,
dispatch: Callable[..., Any] | None,
) -> tuple[BaseRepo, RepoLocation]:
"""Discover and open the repository a verb was invoked against.
Every verb starts the same way: honor ``-C``, walk up to the mount
root looking for a ``.git``, then pull the object database across
the dispatcher. Kept in one place so a new verb inherits the
discovery rules rather than restating them.
Args:
fl (FlagView): the leaf's flag bag, read for ``-C``.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
if stat_path is None or mount_root is None or dispatch is None:
raise NoWorkspaceError()
location = await discover(dispatch, stat_path, mount_root, start_point(fl))
return await open_repo(dispatch, location), location
@@ -0,0 +1,100 @@
# ========= 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 asyncio
from io import BytesIO
from typing import Any, Callable
from dulwich.objects import Commit
from dulwich.patch import write_tree_diff
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.errors import GitError
from mirage.commands.cli.builtin.git.format import entry
from mirage.commands.cli.builtin.git.objects import abbrev_for
from mirage.commands.cli.builtin.git.revparse import resolve_commit
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.util import (check_operands, fatal,
revision_arg)
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import PathSpec
MERGE_PARENTS = 1
# A merge prints no ordinary diff. git renders one against every parent
# at once (`--cc`, the combined format with two prefix columns and
# `@@@` ranges), which comes out empty whenever the merge result matches
# a parent exactly, so the common merge shows only its header. Combined
# diffs are not implemented, so a merge that resolved a conflict shows
# its header and nothing else rather than a patch git would never print.
def _render(repo: BaseRepo, revision: str) -> bytes:
"""Resolve a revision and render its entry and patch, synchronously.
Runs on a worker thread: resolving, walking the tree and reading
blobs all fetch through the dispatcher, so this must not sit on the
loop that answers those fetches.
Args:
repo (BaseRepo): repository to read.
revision (str): the revision to show.
"""
commit = resolve_commit(repo, revision)
header = ("\n".join(entry(commit, abbrev_for(repo))) + "\n").encode()
if len(commit.parents) > MERGE_PARENTS:
return header
parent_tree = None
if commit.parents:
parent = repo.object_store[commit.parents[0]]
assert isinstance(parent, Commit)
parent_tree = parent.tree
patch = BytesIO()
write_tree_diff(patch, repo.object_store, parent_tree, commit.tree)
body = patch.getvalue()
if not body:
return header
return header + b"\n" + body
async def show(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""Show one commit: its log entry, then its diff against its parent.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): path operands.
stat_path (StatPath | None): dispatcher-backed stat, both
channels.
mount_root (MountRoot | None): the mount prefix serving a path.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
try:
check_operands(texts)
repo, _location = await opened(fl, stat_path, mount_root, dispatch)
rendered = await asyncio.to_thread(_render, repo, revision_arg(texts))
except GitError as exc:
return fatal(exc)
return yield_bytes(rendered), IOResult()
@@ -0,0 +1,145 @@
# ========= 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. =========
from dataclasses import dataclass
from typing import Any, Callable
from dulwich.repo import BaseRepo
from mirage.commands.cli.builtin.git.changes import collect
from mirage.commands.cli.builtin.git.errors import GitError, NoWorkspaceError
from mirage.commands.cli.builtin.git.format import short
from mirage.commands.cli.builtin.git.objects import abbrev_for
from mirage.commands.cli.builtin.git.refs import read_head
from mirage.commands.cli.builtin.git.render import (branch_line, long_format,
short_format)
from mirage.commands.cli.builtin.git.session import opened
from mirage.commands.cli.builtin.git.types import HeadRef, RepoLocation
from mirage.commands.cli.builtin.git.util import fatal
from mirage.commands.cli.builtin.git.worktree import (UNTRACKED_ALL,
UNTRACKED_NO,
UNTRACKED_NORMAL)
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountRoot, StatPath
from mirage.types import PathSpec
@dataclass(frozen=True, slots=True)
class StatusFlags:
"""The parsed shape of a ``git status`` invocation.
Args:
porcelain (bool): ``--porcelain``, the stable machine format.
short (bool): ``-s``, the same rows meant for a person.
branch (bool): ``-b``, prepend the ``##`` branch line.
untracked (str): ``-u``, which untracked files to report.
"""
porcelain: bool
short: bool
branch: bool
untracked: str
def parse_flags(fl: FlagView) -> StatusFlags:
"""Read the raw status flag kwargs into a frozen struct.
``-u`` carries its mode attached or not at all, and a bare one means
``all``, which is why the value is read as a string first and only
then as a boolean.
Args:
fl (FlagView): spec-validated view over the raw flag kwargs.
"""
mode = fl.as_str("untracked_files")
if mode is None:
mode = UNTRACKED_ALL if fl.as_bool(
"untracked_files") else UNTRACKED_NORMAL
return StatusFlags(porcelain=fl.as_bool("porcelain"),
short=fl.as_bool("short"),
branch=fl.as_bool("branch"),
untracked=mode)
async def render_report(dispatch: Callable[..., Any], stat_path: StatPath,
repo: BaseRepo, location: RepoLocation,
head: HeadRef) -> str:
"""The default status report, as a string.
Split out so ``commit`` can print it when it has nothing to commit:
git shows the whole status there rather than a one-line refusal, and
two renderings of the same thing would drift.
Args:
dispatch (Callable): workspace op dispatcher.
stat_path (StatPath): dispatcher-backed stat, both channels.
repo (BaseRepo): the opened repository.
location (RepoLocation): the discovered repository.
head (HeadRef): what HEAD points at.
"""
rows, state, no_commits = await collect(dispatch, stat_path, repo,
location, UNTRACKED_NORMAL)
commit = None if head.commit is None else short(head.commit.encode(),
abbrev_for(repo))
return long_format(rows, head.branch, commit, no_commits, state.merging,
False)
async def status(
config: None,
paths: list[PathSpec],
*texts: str,
stat_path: StatPath | None = None,
mount_root: MountRoot | None = None,
dispatch: Callable[..., Any] | None = None,
**flags: object,
) -> tuple[ByteSource | None, IOResult]:
"""Show the working tree status.
Three sources, compared pairwise: HEAD's tree against the index says
what a commit would record, and the index against the working tree
says what it would leave behind. Everything the report prints is one
of those two answers, or a path neither side knows about.
Args:
config (None): git declares no config_model.
paths (list[PathSpec]): pathspec operands.
stat_path (StatPath | None): dispatcher-backed stat asking both
channels, for repository discovery and the working-tree walk.
mount_root (MountRoot | None): the mount prefix serving a path,
which bounds the discovery walk.
dispatch (Callable | None): workspace op dispatcher.
"""
fl = FlagView(flags)
try:
if stat_path is None or mount_root is None or dispatch is None:
raise NoWorkspaceError()
parsed = parse_flags(fl)
repo, location = await opened(fl, stat_path, mount_root, dispatch)
head = await read_head(dispatch, location.gitdir)
rows, state, no_commits = await collect(dispatch, stat_path, repo,
location, parsed.untracked)
except GitError as exc:
return fatal(exc)
commit = None if head.commit is None else short(head.commit.encode(),
abbrev_for(repo))
if parsed.porcelain or parsed.short:
header = branch_line(head.branch, commit,
no_commits) if parsed.branch else None
body = short_format(rows, header)
else:
body = long_format(rows, head.branch, commit, no_commits,
state.merging, parsed.untracked == UNTRACKED_NO)
return yield_bytes(body.encode()), IOResult()
@@ -0,0 +1,156 @@
# ========= 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. =========
from difflib import SequenceMatcher
from dulwich.object_store import BaseObjectStore
from dulwich.objects import Blob, Commit, ObjectID
from mirage.commands.cli.builtin.git.format import short
ROOT_COMMIT = "(root-commit) "
CREATE = "create"
DELETE = "delete"
def _lines(store: BaseObjectStore, sha: bytes | None) -> list[bytes]:
"""A blob's lines, empty when there is no blob on that side.
Args:
store (BaseObjectStore): the object database.
sha (ObjectID | None): the blob id, None when the file is being
created or removed.
"""
if sha is None:
return []
obj = store[ObjectID(sha)]
if not isinstance(obj, Blob):
return []
return obj.data.splitlines(keepends=True)
def count_changes(store: BaseObjectStore, before: dict[bytes, tuple[int,
bytes]],
after: dict[bytes, tuple[int, bytes]]) -> tuple[int, int]:
"""How many lines a commit added and removed, over every path.
Counted with the same longest-common-subsequence that ``diff`` uses,
so the totals agree with what git prints. They are line counts, not
hunk counts: a rewritten line is one insertion and one deletion.
Args:
store (BaseObjectStore): the object database.
before (dict): the parent tree, path to (mode, blob id).
after (dict): the new tree, path to (mode, blob id).
"""
insertions = 0
deletions = 0
for path in set(before) | set(after):
old = before.get(path)
new = after.get(path)
if old is not None and new is not None and old[1] == new[1]:
continue
old_lines = _lines(store, None if old is None else old[1])
new_lines = _lines(store, None if new is None else new[1])
matcher = SequenceMatcher(a=old_lines, b=new_lines, autojunk=False)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
deletions += i2 - i1
insertions += j2 - j1
return insertions, deletions
def _plural(count: int, noun: str) -> str:
"""``N noun`` with the noun pluralised the way git pluralises it.
Args:
count (int): how many.
noun (str): the singular noun.
"""
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
def stat_line(files: int, insertions: int, deletions: int) -> str:
"""git's one-line diffstat.
A clause that would read zero is dropped, unless both would, in
which case both are kept: a commit that changed a file without
changing a line still has to say something about the lines, and
" 1 file changed" alone reads like a truncation. Pinned against git
2.47 by committing an empty file.
Args:
files (int): how many paths changed.
insertions (int): lines added.
deletions (int): lines removed.
"""
parts = [f" {_plural(files, 'file')} changed"]
if insertions or not deletions:
parts.append(_plural(insertions, "insertion") + "(+)")
if deletions or not insertions:
parts.append(_plural(deletions, "deletion") + "(-)")
return ", ".join(parts)
def mode_lines(before: dict[bytes, tuple[int, bytes]],
after: dict[bytes, tuple[int, bytes]]) -> list[str]:
"""The ``create mode`` / ``delete mode`` lines, in git's order.
Args:
before (dict): the parent tree, path to (mode, blob id).
after (dict): the new tree, path to (mode, blob id).
"""
lines = []
for path in sorted(set(after) - set(before)):
mode = after[path][0]
lines.append(f" {CREATE} mode {mode:06o} "
f"{path.decode('utf-8', errors='replace')}")
for path in sorted(set(before) - set(after)):
mode = before[path][0]
lines.append(f" {DELETE} mode {mode:06o} "
f"{path.decode('utf-8', errors='replace')}")
return lines
def report(store: BaseObjectStore, commit: Commit, branch: str | None,
before: dict[bytes, tuple[int, bytes]],
after: dict[bytes, tuple[int,
bytes]], width: int, root: bool) -> bytes:
"""What ``git commit`` prints once the commit exists.
Args:
store (BaseObjectStore): the object database.
commit (Commit): the commit just written.
branch (str | None): the branch it landed on, None when
detached.
before (dict): the parent tree, path to (mode, blob id).
after (dict): the new tree, path to (mode, blob id).
width (int): how many hex digits to abbreviate the id to.
root (bool): whether this is the repository's first commit.
"""
changed = {
path
for path in set(before) | set(after)
if before.get(path) != after.get(path)
}
title = commit.message.decode("utf-8", errors="replace").split("\n")[0]
where = branch if branch is not None else "detached HEAD"
marker = ROOT_COMMIT if root else ""
lines = [f"[{where} {marker}{short(commit.id, width)}] {title}"]
if changed:
insertions, deletions = count_changes(store, before, after)
lines.append(stat_line(len(changed), insertions, deletions))
lines.extend(mode_lines(before, after))
return "".join(f"{line}\n" for line in lines).encode()
@@ -0,0 +1,141 @@
# ========= 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. =========
from dataclasses import dataclass, field
from dulwich.index import ConflictedIndexEntry, IndexEntry
from mirage.types import FileStat
@dataclass(frozen=True, slots=True)
class RepoLocation:
"""A resolved repository: where the objects live, and over what.
``gitdir`` and ``worktree`` are separate for the same reason they are
in git: the two need not be nested, and a bare repository has no
worktree at all.
``commondir`` is the third: a linked worktree (``git worktree add``)
gets its own gitdir holding HEAD and the index, while the object
database, packed-refs and branches stay in the repository it was cut
from. The two are the same directory for an ordinary checkout, which
is why one field carried both until worktrees turned up.
Args:
gitdir (str): absolute virtual path of this checkout's git
directory, which holds HEAD and the index.
commondir (str): absolute virtual path of the shared git
directory, which holds objects and branches. Equal to
``gitdir`` unless this is a linked worktree.
worktree (str): absolute virtual path of the working tree root.
mount_root (str): the mount prefix both live under, which
bounded the discovery walk.
"""
gitdir: str
commondir: str
worktree: str
mount_root: str
@dataclass(frozen=True, slots=True)
class HeadRef:
"""What HEAD points at: a branch, some other ref, or a raw commit.
Args:
branch (str | None): short branch name when HEAD is a symbolic
ref under ``refs/heads``, None when detached.
ref (str | None): the full ref name HEAD names, None when
detached.
commit (str | None): the object id HEAD holds directly, set only
on a detached HEAD.
"""
branch: str | None
ref: str | None
commit: str | None
@dataclass(frozen=True, slots=True)
class AncestryStep:
"""One ``~`` or ``^`` suffix of a revision.
Args:
first_parent (bool): True for ``~n`` (walk n generations along
first parents), False for ``^n`` (take the n-th parent).
count (int): the number after the suffix, 1 when it was bare.
"""
first_parent: bool
count: int
@dataclass(frozen=True, slots=True)
class IndexState:
"""What ``.git/index`` says, split by whether a path is in conflict.
Conflicted paths are carried apart rather than dropped, because a
dropped one reads as unmodified: the file would compare equal to
nothing and vanish from the report while git is refusing to commit
because of it.
Args:
entries (dict[bytes, IndexEntry]): staged content, keyed by
repository-relative path.
conflicts (dict[bytes, ConflictedIndexEntry]): paths left
unmerged, each holding whichever of the three stages exist.
merging (bool): whether ``MERGE_HEAD`` is present, which is what
distinguishes a merge in progress from its leftovers.
"""
entries: dict[bytes, IndexEntry]
conflicts: dict[bytes, ConflictedIndexEntry]
merging: bool
@dataclass(frozen=True, slots=True)
class StatusEntry:
"""One path's status, in the two columns git reports it in.
The pair is git's own model, not a convenience: the left column is
HEAD against the index and the right is the index against the
working tree, so a file edited, staged, then edited again is ``MM``
and appears in both sections of the long format. Collapsing the two
into one verdict is what makes a status report unable to say that.
Args:
path (str): repository-relative path.
index_status (str): the left column, one character.
tree_status (str): the right column, one character.
original (str | None): the path renamed from, set only for
``R``.
"""
path: str
index_status: str
tree_status: str
original: str | None = None
@dataclass(frozen=True, slots=True)
class WorkTree:
"""What one walk of the working tree found.
Args:
files (dict[str, FileStat]): every non-ignored file that is not
under an untracked collapsed directory, mapped to what the
mount said about it. The whole stat is kept rather than the
size alone because the comparison reads the mode too, and
the walk has already paid for it.
untracked (list[str]): paths to report as untracked, already
collapsed to ``dir/`` where git would collapse them.
"""
files: dict[str, FileStat] = field(default_factory=dict)
untracked: list[str] = field(default_factory=list)
@@ -0,0 +1,105 @@
# ========= 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. =========
from mirage.commands.cli.builtin.git.errors import (GitError,
UnrecognizedArgumentError)
from mirage.commands.spec.types import FlagView
from mirage.io.stream import yield_bytes
from mirage.io.types import ByteSource, IOResult
ROOT = "/"
HEAD = "HEAD"
STDOUT = "stdout"
def start_point(fl: FlagView) -> str:
"""Where repository discovery begins for this invocation.
``-C`` changes directory before anything else happens, git's own
reading of the option. It needs no separate session-cwd fact: the
option is declared with a ``"."`` default, and a PATH default lands
as if typed, so an absent ``-C`` resolves to the session cwd and a
relative ``-C build`` is already absolute by the time it arrives.
Read as a string, not a PathSpec: group-level values are resolved by
the walk and reach a leaf as absolute virtual paths, while a leaf's
own PATH flags are recovered as PathSpec by ``parse_flags``.
Args:
fl (FlagView): spec-validated view over the leaf's flag bag.
"""
return fl.as_str("C") or ROOT
def revision_arg(texts: tuple[str, ...], default: str = HEAD) -> str:
"""The revision operand a verb was given, or git's own default.
Args:
texts (tuple[str, ...]): positional text operands.
default (str): what an absent operand means.
"""
return texts[0] if texts else default
def check_operands(texts: tuple[str, ...],
error: type[GitError] = UnrecognizedArgumentError) -> None:
"""Refuse an operand that is really an option this build lacks.
A verb taking a revision accepts free text, so every flag mirage
does not declare reaches it as one. Resolving it as a revision is
the wrong answer twice over: it fails, and it fails saying the
repository has no such commit, when what happened is that mirage
has no such flag. Refused here, before any object is read, so the
message names the real problem.
A pathspec is not checked for and cannot be: the shared parser
consumes ``--`` as its end-of-options marker, so ``log -- a.txt``
and ``log a.txt`` reach a leaf identically. Both resolve the operand
as a revision and fail with git's own "unknown revision or path"
wording, which is exactly right for an untracked path and a
deliberate divergence for a tracked one, where git would narrow the
walk instead. Erring is the safe half of that trade: limiting by
nothing would print every commit and look like an answer.
Which refusal to raise is the caller's, because git words this
differently per verb and means each one: see ``UnknownSwitchError``
for the three.
Args:
texts (tuple[str, ...]): positional text operands, as typed.
error (type[GitError]): the refusal this verb words it with.
"""
for text in texts:
if text.startswith("-"):
raise error(text)
def fatal(exc: GitError) -> tuple[ByteSource | None, IOResult]:
"""Render a git error: ``<prefix>: <message>``, on its own stream.
git uses 128 for a fatal, which is neither the dispatcher's usage
exit (2) nor its generic handler-error exit (1), so leaves return
the code rather than raising into the catch-all. A refused option
carries its own prefix and code instead, which is git's own split,
and a refusal that is really a report ("nothing to commit") carries
no prefix and goes to stdout.
Args:
exc (GitError): the error to render.
"""
body = f"{exc}\n" if exc.prefix is None else f"{exc.prefix}: {exc}\n"
data = body.encode()
if exc.stream == STDOUT:
return yield_bytes(data), IOResult(exit_code=exc.code)
return None, IOResult(exit_code=exc.code, stderr=data)
@@ -0,0 +1,220 @@
# ========= 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 posixpath
from typing import Any, Callable
from mirage.commands.cli.builtin.git.ignore import (GITIGNORE, IgnoreStack,
load_ignores)
from mirage.commands.cli.builtin.git.io import read_names, read_optional
from mirage.commands.cli.builtin.git.types import RepoLocation, WorkTree
from mirage.ops.types import StatPath
from mirage.types import FileType
GIT_DIR = ".git"
# git's three untracked modes. "normal" names an untracked directory
# once instead of everything inside it, "all" names every file, and "no"
# leaves them out and is the reason the mode is threaded into the walk
# rather than filtered afterwards: not reporting them means not looking
# for them, which is the whole saving.
UNTRACKED_NO = "no"
UNTRACKED_NORMAL = "normal"
UNTRACKED_ALL = "all"
def _name_of(entry: str) -> str:
"""The final segment of a readdir entry.
Args:
entry (str): one entry as the backend reported it, which may be a
bare name or a path and may carry a trailing slash.
"""
return entry.rstrip("/").rsplit("/", 1)[-1]
def tracked_directories(tracked: set[str]) -> set[str]:
"""Every directory that holds a tracked path, at any depth.
Answering "does this directory contain anything tracked" per
directory would rescan the index once per directory walked; the same
question asked of a prepared set is a lookup.
Args:
tracked (set[str]): repository-relative paths the index holds.
"""
directories: set[str] = set()
for path in tracked:
parts = path.split("/")[:-1]
for depth in range(len(parts)):
directories.add("/".join(parts[:depth + 1]))
return directories
class Scanner:
"""One walk of the working tree, carrying what the walk needs.
Args:
dispatch (Callable): workspace op dispatcher.
stat_path (StatPath): dispatcher-backed stat, both channels.
worktree (str): absolute virtual path of the working tree root.
tracked (set[str]): repository-relative paths the index holds.
mode (str): which untracked files to report, one of
``UNTRACKED_NO`` / ``UNTRACKED_NORMAL`` / ``UNTRACKED_ALL``.
"""
def __init__(self, dispatch: Callable[..., Any], stat_path: StatPath,
worktree: str, tracked: set[str], mode: str) -> None:
self._dispatch = dispatch
self._stat_path = stat_path
self._worktree = worktree
self._tracked = tracked
self._directories = tracked_directories(tracked)
self._mode = mode
self.found = WorkTree()
def _absolute(self, relative: str) -> str:
"""The virtual path a repository-relative path names.
Args:
relative (str): repository-relative path, empty at the root.
"""
return posixpath.join(self._worktree,
relative) if relative else self._worktree
async def _holds_a_file(self, relative: str, ignores: IgnoreStack) -> bool:
"""Whether a directory holds anything git would call untracked.
git lists a directory only when something inside it would be
reported, so a directory holding nothing but ignored files, or
nothing at all, is not mentioned. Stops at the first find.
Args:
relative (str): repository-relative path of the directory.
ignores (IgnoreStack): the rules governing it.
"""
rules = await self._descend(relative, ignores)
for entry in await read_names(self._dispatch,
self._absolute(relative)):
name = _name_of(entry)
if not name:
continue
child = f"{relative}/{name}" if relative else name
info = await self._stat_path(self._absolute(child))
if info is None:
continue
directory = info.type is FileType.DIRECTORY
if rules.is_ignored(child, directory):
continue
if not directory:
return True
if await self._holds_a_file(child, rules):
return True
return False
async def _descend(self, relative: str,
ignores: IgnoreStack) -> IgnoreStack:
"""The ignore rules inside a directory, given the ones outside.
Args:
relative (str): repository-relative path of the directory.
ignores (IgnoreStack): the rules governing its parent.
"""
if not relative:
return ignores
local = await read_optional(
self._dispatch, posixpath.join(self._absolute(relative),
GITIGNORE))
return ignores if local is None else ignores.push(relative, local)
async def _visit_directory(self, relative: str, ignored: bool,
ignores: IgnoreStack) -> None:
"""Decide what a subdirectory contributes, then walk it or not.
An ignored directory is still walked when the index holds
something inside it. Ignore rules govern untracked files only, so
a tracked file under an ignored directory is still compared, and
skipping the directory outright would report it deleted.
Args:
relative (str): repository-relative path of the directory.
ignored (bool): whether an ignore rule already caught it.
ignores (IgnoreStack): the rules governing its parent.
"""
holds_tracked = relative in self._directories
if ignored:
if holds_tracked:
await self.walk(relative, True, ignores)
return
if holds_tracked or self._mode == UNTRACKED_ALL:
await self.walk(relative, False, ignores)
return
if self._mode == UNTRACKED_NORMAL and await self._holds_a_file(
relative, ignores):
self.found.untracked.append(f"{relative}/")
async def walk(self, relative: str, ignored: bool,
ignores: IgnoreStack) -> None:
"""Walk one directory, recording files and untracked entries.
Args:
relative (str): repository-relative path, empty at the root.
ignored (bool): whether this directory is itself ignored, in
which case nothing inside it is reported untracked.
ignores (IgnoreStack): the rules governing its parent.
"""
rules = await self._descend(relative, ignores)
for entry in sorted(await read_names(self._dispatch,
self._absolute(relative))):
name = _name_of(entry)
if not name or (not relative and name == GIT_DIR):
continue
child = f"{relative}/{name}" if relative else name
info = await self._stat_path(self._absolute(child))
if info is None:
continue
if info.type is FileType.DIRECTORY:
await self._visit_directory(
child, ignored or rules.is_ignored(child, True), rules)
continue
self.found.files[child] = info
if child in self._tracked or self._mode == UNTRACKED_NO:
continue
if not ignored and not rules.is_ignored(child, False):
self.found.untracked.append(child)
async def scan(dispatch: Callable[..., Any], stat_path: StatPath,
location: RepoLocation, tracked: set[str],
mode: str) -> WorkTree:
"""Walk a working tree once, for both halves of a status report.
One walk answers two questions, which is why they are not asked
separately: which tracked files still exist and how big they are,
and which untracked ones git would mention. Statting each index path
instead would miss every untracked file, and listing untracked files
alone would leave the deletions unfound.
Args:
dispatch (Callable): workspace op dispatcher.
stat_path (StatPath): dispatcher-backed stat, both channels.
location (RepoLocation): the discovered repository.
tracked (set[str]): repository-relative paths the index holds.
mode (str): which untracked files to report, one of
``UNTRACKED_NO`` / ``UNTRACKED_NORMAL`` / ``UNTRACKED_ALL``.
"""
ignores = await load_ignores(dispatch, location.gitdir, location.worktree)
scanner = Scanner(dispatch, stat_path, location.worktree, tracked, mode)
await scanner.walk("", False, ignores)
return scanner.found
+60
View File
@@ -0,0 +1,60 @@
# ========= 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. =========
from mirage.commands.cli.constants import USAGE_EXIT
from mirage.commands.cli.types import UsageStyle
from mirage.workspace.executor.command.types import ParsedCommand
ARGPARSE_EXIT = 2
LONG_PREFIX = "--"
def git_unknown_option(token: str) -> bytes:
"""git's refusal for an option it does not know.
Two nouns and no program name, pinned against git 2.50.1: a long
option is an "option" and a short one is a "switch", both named
without their dashes and quoted with a backquote-apostrophe pair.
git follows this with the verb's usage block, which is omitted the
same way GNU's is elsewhere in the spec machinery.
Args:
token (str): the offending token ('--nosuch') or cluster
character ('Z'), as the flat parser reports it.
"""
noun = "option" if token.startswith(LONG_PREFIX) else "switch"
return f"error: unknown {noun} `{token.lstrip('-')}'\n".encode()
def leaf_refusal(style: UsageStyle, argparse_message: bytes,
parsed: ParsedCommand) -> tuple[bytes, int]:
"""The message and exit code a leaf answers a bad option with.
A leaf usage error exits 2 under argparse's style regardless of the
GNU USAGE_EXIT table, because an installed CLI name is never a GNU
tool with its own pinned exit. git exits 129 for the same mistake,
which is neither that nor its own 128 for a fatal.
Args:
style (UsageStyle): the dialect the CLI's root declares.
argparse_message (bytes): the message the spec machinery built,
used as-is for argparse and for anything git words the same.
parsed (ParsedCommand): parse result, read for the offending
token when the style rewrites the message.
"""
if style is not UsageStyle.GIT:
return argparse_message, ARGPARSE_EXIT
if parsed.invalid_options:
return git_unknown_option(parsed.invalid_options[0]), USAGE_EXIT
return argparse_message, USAGE_EXIT
+1
View File
@@ -26,6 +26,7 @@ CLI_SPECS: dict[str, CLISpec] = {}
# they must not import until the name is actually requested.
BUILTIN_CLI_SPECS: dict[str, str] = {
"discord": "mirage.commands.cli.builtin.discord:DISCORD",
"git": "mirage.commands.cli.builtin.git:GIT",
"gws": "mirage.commands.cli.builtin.gws:GWS",
"himalaya": "mirage.commands.cli.builtin.himalaya:HIMALAYA",
"linear": "mirage.commands.cli.builtin.linear:LINEAR",
+30
View File
@@ -13,6 +13,7 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Literal
from pydantic import BaseModel
@@ -21,6 +22,28 @@ from mirage.commands.cli.compile import validate_cli
from mirage.commands.spec.types import CommandSpec
from mirage.types import Limit
class UsageStyle(Enum):
"""Which program's usage-error dialect a CLI speaks.
An installed CLI is not a GNU tool, so a leaf that refuses an option
it does not declare answers in argparse's shape and exit code by
default. A CLI that mimics an existing program has to answer in that
program's shape instead: mirage implements a subset of git, so most
of git's real options arrive undeclared, and an agent that reads the
refusal should see what git would have said rather than learn that
it is talking to a reimplementation.
Covers the unknown-option refusal and the exit code, which is what
an undeclared flag produces. Every other usage error (a missing
value, an unparseable int) stays in argparse's shape for both
styles, because those only happen for options a CLI does declare.
"""
ARGPARSE = "argparse"
GIT = "git"
# The group-level flag bag the walk accumulates, keyed by canonical
# dashed spelling like ParsedArgs.flags.
FlagBag = dict[str, str | bool | int | list[str]]
@@ -69,12 +92,19 @@ class CLISpec(CommandSpec):
config_model (type[BaseModel] | None): root only. Pydantic model
validating an installation's config from YAML ``clis:`` or
``register_cli``; also the redaction schema for snapshots.
usage_style (UsageStyle): root only. How a leaf refuses an option
it does not declare. Defaults to argparse, which is right for
a CLI mirage invented; a CLI that mimics an existing program
sets the style that program uses, so an agent reading the
message and the exit code sees what it would from the real
one.
"""
name: str = ""
aliases: tuple[str, ...] = ()
fn: Callable[..., Any] | None = None
subcommands: tuple["CLISpec", ...] = ()
write: bool = False
usage_style: UsageStyle = UsageStyle.ARGPARSE
# hash=False: Limit is a mutable dataclass, and the
# frozen CLISpec must stay hashable for compile_spec's per-spec
# cache. Equality still compares the field; only the hash skips it
+45 -8
View File
@@ -22,6 +22,7 @@ from mirage.commands.spec.compile import (CompiledSpec, compile_spec,
expand_long)
from mirage.commands.spec.constants import FLOAT_VALUE, INT_VALUE
from mirage.commands.spec.help import render_help
from mirage.utils.path import resolve_path
def _verb_display(child: CLISpec) -> str:
@@ -228,19 +229,49 @@ def _expand_group_long(node: CLISpec, cs: CompiledSpec,
return candidates
def _finish_node(name: str, node: CLISpec, cs: CompiledSpec,
flags: FlagBag) -> WalkResult | None:
def _resolve_group_paths(cs: CompiledSpec, flags: FlagBag, cwd: str) -> None:
"""Resolve PATH-typed group values against the working directory.
A group option declared ``type="path"`` has to mean what it means on
a leaf, or the type is a lie at exactly one level of the tree. The
flat parser resolves PATH values right after defaults land, so a
``-C`` that defaults to ``"."`` becomes the session cwd and a
relative ``-C build`` becomes absolute; do the same here rather than
handing a leaf a raw relative string it has no cwd to interpret.
Resolved to absolute strings, not PathSpec: a group flag never picks
a mount (CLI dispatch consults none), so the routing half of the
leaf's PATH recovery has nothing to do here.
Args:
cs (CompiledSpec): the node's compiled tables.
flags (FlagBag): accumulated group flags, updated in place.
cwd (str): current working directory.
"""
for dest, kind in cs.kind_by_dest.items():
if kind != "path" or dest not in flags:
continue
value = flags[dest]
if isinstance(value, list):
flags[dest] = [resolve_path(part, cwd) for part in value]
elif isinstance(value, str):
flags[dest] = resolve_path(value, cwd)
def _finish_node(name: str, node: CLISpec, cs: CompiledSpec, flags: FlagBag,
cwd: str) -> WalkResult | None:
"""Apply a node's declarative option rules after its scan.
Defaults land as if typed, then choices and required are enforced,
the same order the flat parser uses. Returns a rendered refusal or
None when the node is satisfied.
Defaults land as if typed and PATH values resolve, then choices and
required are enforced, the same order the flat parser uses. Returns
a rendered refusal or None when the node is satisfied.
Args:
name (str): display path walked so far.
node (CLISpec): the group node just scanned.
cs (CompiledSpec): the node's compiled tables.
flags (FlagBag): accumulated group flags.
cwd (str): current working directory, for PATH values.
"""
for dest, default in cs.defaults.items():
if dest not in flags:
@@ -248,6 +279,7 @@ def _finish_node(name: str, node: CLISpec, cs: CompiledSpec,
flags[dest] = [default]
else:
flags[dest] = default
_resolve_group_paths(cs, flags, cwd)
# Numeric-typed values before choices, argparse's order; wording is
# git's parse-options refusal (`--depth` on a non-integer), one
# phrase for int and float alike.
@@ -278,7 +310,10 @@ def _finish_node(name: str, node: CLISpec, cs: CompiledSpec,
return None
def walk(head: str, spec: CLISpec, argv: Sequence[str]) -> WalkResult:
def walk(head: str,
spec: CLISpec,
argv: Sequence[str],
cwd: str = "/") -> WalkResult:
"""Resolve one command line against a CLI tree.
Each level consumes its own options in POSIX order (stop at the
@@ -295,6 +330,8 @@ def walk(head: str, spec: CLISpec, argv: Sequence[str]) -> WalkResult:
renamed install prints its own name.
spec (CLISpec): the root of the tree.
argv (Sequence[str]): the words after the head.
cwd (str): working directory for PATH-typed group values, so a
group option resolves the way a leaf option does.
"""
node = spec
path: tuple[str, ...] = ()
@@ -404,7 +441,7 @@ def walk(head: str, spec: CLISpec, argv: Sequence[str]) -> WalkResult:
return _usage_error(name, node, error)
i += 1
continue
refused = _finish_node(name, node, cs, flags)
refused = _finish_node(name, node, cs, flags, cwd)
if refused is not None:
return refused
# An alias resolves to its canonical node; the path records
@@ -420,7 +457,7 @@ def walk(head: str, spec: CLISpec, argv: Sequence[str]) -> WalkResult:
break
if descended:
continue
refused = _finish_node(name, node, cs, flags)
refused = _finish_node(name, node, cs, flags, cwd)
if refused is not None:
return refused
return WalkResult(output=node_help(name, node).encode(),
@@ -29,6 +29,9 @@ SPECS: dict[str, CommandSpec] = {
Option(short="-w"),
Option(short="-F"),
Option(short="-E"),
# -G asks for the basic expressions grep already reads by
# default, so it is accepted and changes nothing.
Option(short="-G"),
Option(short="-o"),
Option(short="-q"),
Option(short="-H"),
@@ -224,6 +227,7 @@ SPECS: dict[str, CommandSpec] = {
Option(short="-e", type="str", multiple=True),
Option(short="-f", type="path", multiple=True),
Option(short="-E"),
Option(short="-G"),
Option(short="-F"),
Option(short="-H"),
Option(short="-h"),
+12
View File
@@ -225,8 +225,20 @@ async def box_get_bytes(
tm: BoxTokenManager,
url: str,
params: dict[str, Any] | None = None,
range_header: str | None = None,
) -> bytes:
"""GET a URL as raw bytes, optionally only a byte range of it.
Args:
tm (BoxTokenManager): token manager.
url (str): API URL.
params (dict | None): query parameters.
range_header (str | None): an HTTP ``Range`` value, or None for
the whole body.
"""
headers = await box_auth_headers(tm)
if range_header:
headers["Range"] = range_header
async with aiohttp.ClientSession() as session:
async with session.get(url,
headers=headers,
+14 -2
View File
@@ -65,8 +65,20 @@ async def get_folder_info(tm: BoxTokenManager,
return await box_get(tm, f"{tm.api_base}/folders/{folder_id}")
async def download_file(tm: BoxTokenManager, file_id: str) -> bytes:
return await box_get_bytes(tm, f"{tm.api_base}/files/{file_id}/content")
async def download_file(tm: BoxTokenManager,
file_id: str,
range_header: str | None = None) -> bytes:
"""Download a file's content, optionally only a byte range of it.
Args:
tm (BoxTokenManager): token manager.
file_id (str): Box file id.
range_header (str | None): an HTTP ``Range`` value, or None for
the whole file.
"""
return await box_get_bytes(tm,
f"{tm.api_base}/files/{file_id}/content",
range_header=range_header)
def download_file_stream(tm: BoxTokenManager,
+14 -1
View File
@@ -23,6 +23,7 @@ from mirage.core.box.readdir import readdir
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.key_prefix import mount_key, mount_prefix_of
from mirage.utils.ranges import range_header
logger = logging.getLogger(__name__)
@@ -63,9 +64,21 @@ async def read(
accessor: BoxAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
offset: int = 0,
size: int | None = None,
) -> bytes:
"""Read a file, optionally only a byte range of it.
Args:
accessor (BoxAccessor): Box accessor.
path (PathSpec): the path to read.
index (IndexCacheStore): listing cache, consulted for the file id.
offset (int): first byte to read.
size (int | None): how many bytes, or None for the rest.
"""
entry = await _resolve_entry(accessor, path, index)
return await download_file(accessor.token_manager, entry.id)
return await download_file(accessor.token_manager, entry.id,
range_header(offset, size))
async def stream(
+5 -16
View File
@@ -23,6 +23,7 @@ from mirage.core.databricks_volume.path import backend_path
from mirage.observe.context import record
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.ranges import range_header
def _read_response_bytes(response) -> bytes:
@@ -37,28 +38,16 @@ def _read_response_bytes(response) -> bytes:
return bytes(contents)
def _range_header(offset: int, size: int | None) -> str | None:
if offset < 0:
raise ValueError("offset must be non-negative")
if size is not None and size < 0:
raise ValueError("size must be non-negative")
if offset == 0 and size is None:
return None
if size is None:
return f"bytes={offset}-"
return f"bytes={offset}-{offset + size - 1}"
def _download_bytes_sync(
accessor: DatabricksVolumeAccessor,
remote_path: str,
range_header: str | None,
window: str | None,
) -> bytes:
if range_header is None:
if window is None:
return _read_response_bytes(accessor.files.download(remote_path))
headers = {
"Accept": "application/octet-stream",
"Range": range_header,
"Range": window,
}
cfg = getattr(accessor.client.api_client, "_cfg", None)
workspace_id = getattr(cfg, "workspace_id", None)
@@ -98,7 +87,7 @@ async def read_bytes(
_download_bytes_sync,
accessor,
remote_path,
_range_header(offset, size),
range_header(offset, size),
)
except Exception as exc:
if is_not_found(exc):
+2 -1
View File
@@ -19,7 +19,7 @@ from mirage.core.disk.du import size as du_size
from mirage.core.disk.exists import exists
from mirage.core.disk.find import find
from mirage.core.disk.mkdir import mkdir
from mirage.core.disk.read import read_bytes
from mirage.core.disk.read import read_bytes, read_range
from mirage.core.disk.readdir import readdir
from mirage.core.disk.rename import rename
from mirage.core.disk.rm import rm_r
@@ -39,6 +39,7 @@ __all__ = [
"find",
"mkdir",
"read_bytes",
"read_range",
"read_stream",
"readdir",
"rename",
+29
View File
@@ -45,3 +45,32 @@ async def read_bytes(accessor: DiskAccessor,
raise FileNotFoundError(virtual) from exc
record("read", path, "disk", len(data), start_ms)
return data
async def read_range(accessor: DiskAccessor,
path_spec: PathSpec,
index: IndexCacheStore = NULL_INDEX,
offset: int = 0,
size: int | None = None) -> bytes:
"""Read a byte range, seeking rather than reading the whole file.
Args:
accessor (DiskAccessor): the mount's disk handle.
path_spec (PathSpec): the path to read.
index (IndexCacheStore): listing cache, unused here.
offset (int): first byte to read.
size (int | None): how many bytes, or None for the rest.
"""
virtual = path_spec.virtual
path = path_spec.mount_path
root = accessor.root
start_ms = int(time.monotonic() * 1000)
p = _resolve(root, path)
try:
async with aiofiles.open(p, "rb") as f:
await f.seek(offset)
data = await (f.read() if size is None else f.read(size))
except FileNotFoundError as exc:
raise FileNotFoundError(virtual) from exc
record("read", path, "disk", len(data), start_ms)
return data
+13 -1
View File
@@ -143,9 +143,21 @@ async def dropbox_upload(tm: DropboxTokenManager, path: str,
resp.status, summary_of(text))
async def dropbox_download(tm: DropboxTokenManager, path: str) -> bytes:
async def dropbox_download(tm: DropboxTokenManager,
path: str,
range_header: str | None = None) -> bytes:
"""Download a file, optionally only a byte range of it.
Args:
tm (DropboxTokenManager): token manager.
path (str): Dropbox path of the file.
range_header (str | None): an HTTP ``Range`` value, or None for
the whole file.
"""
headers = await dropbox_auth_headers(tm)
headers["Dropbox-API-Arg"] = json.dumps({"path": path})
if range_header:
headers["Range"] = range_header
url = f"{tm.content_base}/files/download"
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers) as resp:
+16 -2
View File
@@ -24,6 +24,7 @@ from mirage.core.dropbox.readdir import readdir
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.key_prefix import mount_key, mount_prefix_of
from mirage.utils.ranges import range_header
logger = logging.getLogger(__name__)
@@ -75,7 +76,19 @@ async def read(
accessor: DropboxAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
offset: int = 0,
size: int | None = None,
) -> bytes:
"""Read a file, optionally only a byte range of it.
Args:
accessor (DropboxAccessor): Dropbox accessor.
path (PathSpec): the path to read.
index (IndexCacheStore): listing cache, consulted for the entry.
offset (int): first byte to read.
size (int | None): how many bytes, or None for the rest.
"""
window = range_header(offset, size)
if index is NULL_INDEX:
# Index-less callers (the ops factory's emulated truncate)
# download directly; the API 409s on missing paths and folders.
@@ -83,7 +96,8 @@ async def read(
dropbox_path = dropbox_path_from_virtual(accessor.root_path,
path.virtual, prefix)
try:
return await dropbox_download(accessor.token_manager, dropbox_path)
return await dropbox_download(accessor.token_manager, dropbox_path,
window)
except DropboxApiError as exc:
if exc.status == 409:
raise enoent(path.virtual) from exc
@@ -91,7 +105,7 @@ async def read(
_, virtual_key, prefix = await _resolve_entry(accessor, path, index)
dropbox_path = dropbox_path_from_virtual(accessor.root_path, virtual_key,
prefix)
return await dropbox_download(accessor.token_manager, dropbox_path)
return await dropbox_download(accessor.token_manager, dropbox_path, window)
async def stream(
+40 -12
View File
@@ -31,6 +31,7 @@ from mirage.observe.context import active_recorder, record, revision_for
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.key_prefix import mount_key, mount_prefix_of
from mirage.utils.ranges import range_header, slice_window
logger = logging.getLogger(__name__)
@@ -42,8 +43,12 @@ async def read_bytes(
return await download_file(token_manager, file_id)
async def read_file_versioned(token_manager: TokenManager, file_id: str,
virtual: str, label: str) -> bytes:
async def read_file_versioned(token_manager: TokenManager,
file_id: str,
virtual: str,
label: str,
offset: int = 0,
size: int | None = None) -> bytes:
"""Download a binary file honouring snapshot revision pins.
A pinned path reads that revision's content; an actively recorded read
@@ -55,19 +60,22 @@ async def read_file_versioned(token_manager: TokenManager, file_id: str,
file_id (str): file ID.
virtual (str): full virtual path (pin lookup key).
label (str): mount-relative path recorded with the read.
offset (int): first byte to read.
size (int | None): how many bytes, or None for the rest.
"""
pinned = revision_for(virtual)
window = range_header(offset, size)
start_ms = int(time.monotonic() * 1000)
fingerprint = None
revision = pinned
if pinned:
data = await download_revision(token_manager, file_id, pinned)
data = await download_revision(token_manager, file_id, pinned, window)
elif active_recorder() is not None:
fingerprint, revision = await capture_file_metadata(
token_manager, file_id)
data = await download_file(token_manager, file_id)
data = await download_file(token_manager, file_id, window)
else:
data = await download_file(token_manager, file_id)
data = await download_file(token_manager, file_id, window)
record("read",
label,
"gdrive",
@@ -82,7 +90,22 @@ async def read(
accessor: GDriveAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
offset: int = 0,
size: int | None = None,
) -> bytes:
"""Read a Drive file, optionally only a byte range of it.
Only a binary file has a remote range to ask for. A google-apps file
is rendered here into JSON, so its bytes do not exist until we make
them and the window can only be taken afterwards.
Args:
accessor (GDriveAccessor): Drive accessor.
path (PathSpec): the path to read.
index (IndexCacheStore): listing cache, consulted for the file id.
offset (int): first byte to read.
size (int | None): how many bytes, or None for the rest.
"""
virtual = path.virtual
prefix = mount_prefix_of(path.virtual, path.resource_path)
key = path.resource_path
@@ -106,10 +129,15 @@ async def read(
if result.entry.resource_type in DIRECTORY_RESOURCE_TYPES:
raise IsADirectoryError(virtual)
if result.entry.resource_type == "gdrive/gdoc":
return await read_doc(accessor.token_manager, result.entry.id)
if result.entry.resource_type == "gdrive/gsheet":
return await read_spreadsheet(accessor.token_manager, result.entry.id)
if result.entry.resource_type == "gdrive/gslide":
return await read_presentation(accessor.token_manager, result.entry.id)
return await read_file_versioned(accessor.token_manager, result.entry.id,
virtual, key)
rendered = await read_doc(accessor.token_manager, result.entry.id)
elif result.entry.resource_type == "gdrive/gsheet":
rendered = await read_spreadsheet(accessor.token_manager,
result.entry.id)
elif result.entry.resource_type == "gdrive/gslide":
rendered = await read_presentation(accessor.token_manager,
result.entry.id)
else:
return await read_file_versioned(accessor.token_manager,
result.entry.id, virtual, key, offset,
size)
return slice_window(rendered, offset, size)
+7 -3
View File
@@ -46,18 +46,22 @@ async def list_revisions(token_manager: TokenManager,
return revisions
async def download_revision(token_manager: TokenManager, file_id: str,
revision_id: str) -> bytes:
async def download_revision(token_manager: TokenManager,
file_id: str,
revision_id: str,
range_header: str | None = None) -> bytes:
"""Download a pinned revision's content (binary files only).
Args:
token_manager (TokenManager): OAuth2 token manager.
file_id (str): file ID.
revision_id (str): revision ID to read.
range_header (str | None): an HTTP ``Range`` value to fetch only
part of the revision, or None for all of it.
"""
url = (f"{drive_base(token_manager)}/files/{file_id}"
f"/revisions/{revision_id}?alt=media")
return await google_get_bytes(token_manager, url)
return await google_get_bytes(token_manager, url, range_header)
async def capture_file_metadata(token_manager: TokenManager,
+11
View File
@@ -209,8 +209,19 @@ async def google_delete(
async def google_get_bytes(
token_manager: TokenManager,
url: str,
range_header: str | None = None,
) -> bytes:
"""GET a URL as raw bytes, optionally only a byte range of it.
Args:
token_manager (TokenManager): OAuth2 token manager.
url (str): API URL.
range_header (str | None): an HTTP ``Range`` value, or None for
the whole body.
"""
headers = await google_headers(token_manager)
if range_header:
headers["Range"] = range_header
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as resp:
resp.raise_for_status()
+4 -1
View File
@@ -250,19 +250,22 @@ async def delete_file(
async def download_file(
token_manager: TokenManager,
file_id: str,
range_header: str | None = None,
) -> bytes:
"""Download a regular file from Drive.
Args:
token_manager (TokenManager): OAuth2 token manager.
file_id (str): file ID.
range_header (str | None): an HTTP ``Range`` value to fetch only
part of the file, or None for all of it.
Returns:
bytes: file content.
"""
url = (f"{drive_base(token_manager)}/files/{file_id}"
"?alt=media&supportsAllDrives=true")
return await google_get_bytes(token_manager, url)
return await google_get_bytes(token_manager, url, range_header)
FOLDER_MIME = "application/vnd.google-apps.folder"
+11 -15
View File
@@ -12,7 +12,6 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import time
from collections.abc import AsyncIterator
from opendal.exceptions import NotFound
@@ -20,26 +19,23 @@ from opendal.exceptions import NotFound
from mirage.accessor.hf_buckets import HfBucketsAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.hf_buckets.constants import DEFAULT_CHUNK_SIZE
from mirage.observe.context import record, record_stream
from mirage.core.hf_buckets.read import read_bytes
from mirage.observe.context import record_stream
from mirage.types import PathSpec
from mirage.utils.errors import enoent
async def range_read(accessor: HfBucketsAccessor, path: PathSpec, start: int,
end: int) -> bytes:
raw = path.mount_path
key = raw.lstrip("/")
op = accessor.operator()
start_ms = int(time.monotonic() * 1000)
try:
async with await op.open(key, "rb") as f:
if start:
await f.seek(start)
data = await f.read(end - start)
except NotFound as exc:
raise enoent(path) from exc
record("read", raw, accessor.RESOURCE_NAME, len(data), start_ms)
return data
"""Read a byte range, in the resource API's end-exclusive spelling.
Args:
accessor (HfBucketsAccessor): bucket accessor.
path (PathSpec): the path to read.
start (int): first byte to read.
end (int): one past the last byte to read.
"""
return await read_bytes(accessor, path, offset=start, size=end - start)
async def read_stream(
+6 -14
View File
@@ -37,6 +37,7 @@ from mirage.observe.context import (active_recorder, record, record_stream,
from mirage.types import FileStat, FileType, PathSpec
from mirage.utils.errors import enoent
from mirage.utils.filetype import guess_type
from mirage.utils.ranges import range_header
SIMPLE_UPLOAD_MAX = 4 * 1024 * 1024
UPLOAD_CHUNK = 10 * 327680
@@ -232,13 +233,6 @@ async def upload_session_write(config: MsGraphConfig, session_url: str,
start += len(chunk)
def _range_header(offset: int, size: int | None) -> str | None:
if not offset and size is None:
return None
end = (offset + size - 1) if size is not None else ""
return f"bytes={offset}-{end}"
def folder_child_count(item: dict[str, Any]) -> int | None:
"""Child count from a driveItem's folder facet.
@@ -309,29 +303,27 @@ async def read_item(config: MsGraphConfig,
offset: int = 0,
size: int | None = None) -> bytes:
pinned = revision_for(virtual)
range_header = _range_header(offset, size)
window = range_header(offset, size)
start_ms = int(time.monotonic() * 1000)
fingerprint = None
revision = pinned
try:
if pinned:
action = f"/versions/{quote(pinned, safe='')}/content"
data = await graph_get_bytes(config, loc.item(action),
range_header)
data = await graph_get_bytes(config, loc.item(action), window)
elif active_recorder() is not None:
fingerprint, revision, download_url = await capture_item_metadata(
config, loc)
if download_url:
data = await graph_get_bytes(config,
download_url,
range_header,
window,
auth=False)
else:
data = await graph_get_bytes(config, loc.item("/content"),
range_header)
window)
else:
data = await graph_get_bytes(config, loc.item("/content"),
range_header)
data = await graph_get_bytes(config, loc.item("/content"), window)
except GraphError as exc:
if exc.status == 404:
raise enoent(virtual)
+4 -3
View File
@@ -21,6 +21,7 @@ from mirage.core.s3._client import _client_kwargs, _key, async_session
from mirage.observe.context import record, revision_for
from mirage.types import PathSpec
from mirage.utils.errors import enoent
from mirage.utils.ranges import range_header
def _fp_rev_from_response(
@@ -65,9 +66,9 @@ async def read_bytes(accessor: S3Accessor,
pinned_revision = revision_for(virtual)
if pinned_revision is not None:
kwargs["VersionId"] = pinned_revision
if offset or size is not None:
end = (offset + size - 1) if size is not None else ""
kwargs["Range"] = f"bytes={offset}-{end}"
window = range_header(offset, size)
if window is not None:
kwargs["Range"] = window
session = async_session(config)
start_ms = int(time.monotonic() * 1000)
try:
+9 -36
View File
@@ -12,7 +12,6 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import time
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack
from typing import Any
@@ -20,8 +19,8 @@ from typing import Any
from mirage.accessor.s3 import S3Accessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.s3._client import _client_kwargs, _key, async_session
from mirage.core.s3.read import _fp_rev_from_response
from mirage.observe.context import record, record_stream, revision_for
from mirage.core.s3.read import _fp_rev_from_response, read_bytes
from mirage.observe.context import record_stream, revision_for
from mirage.types import PathSpec
from mirage.utils.errors import enoent
@@ -86,41 +85,15 @@ async def read_stream(
async def range_read(accessor: S3Accessor, path_spec: PathSpec, start: int,
end: int) -> bytes:
"""Read a byte range from an S3 object.
"""Read a byte range, in the resource API's end-exclusive spelling.
Args:
accessor (S3Accessor): S3 accessor.
path_spec (PathSpec): Object path_spec.
start (int): Start byte offset.
end (int): End byte offset (exclusive).
start (int): first byte to read.
end (int): one past the last byte to read.
"""
virtual = path_spec.virtual
path = path_spec.mount_path
config = accessor.config
start_ms = int(time.monotonic() * 1000)
session = async_session(config)
async with session.client(**_client_kwargs(config)) as client:
kwargs: dict[str, Any] = {
"Bucket": config.bucket,
"Key": _key(path, config),
"Range": f"bytes={start}-{end - 1}",
}
pinned_revision = revision_for(virtual)
if pinned_revision is not None:
kwargs["VersionId"] = pinned_revision
try:
response = await client.get_object(**kwargs)
except Exception as exc:
if _is_not_found(exc):
raise enoent(virtual) from exc
raise
data = await response["Body"].read()
fingerprint, revision = _fp_rev_from_response(response)
record("read",
path,
"s3",
len(data),
start_ms,
fingerprint=fingerprint,
revision=revision)
return data
return await read_bytes(accessor,
path_spec,
offset=start,
size=end - start)
+10 -9
View File
@@ -16,6 +16,7 @@ import asyncssh
from mirage.accessor.ssh import SSHAccessor
from mirage.core.ssh._client import _abs
from mirage.core.ssh.read import read_bytes
from mirage.types import PathSpec
from mirage.utils.errors import enoent
@@ -42,12 +43,12 @@ async def read_stream(accessor: SSHAccessor,
async def range_read(accessor: SSHAccessor, path: PathSpec, start: int,
end: int) -> bytes:
config = accessor.config
sftp = await accessor.sftp()
try:
remote_path = _abs(config, path.mount_path)
async with sftp.open(remote_path, "rb") as f:
await f.seek(start)
return await f.read(end - start)
except asyncssh.SFTPNoSuchFile:
raise enoent(path.virtual)
"""Read a byte range, in the resource API's end-exclusive spelling.
Args:
accessor (SSHAccessor): SSH accessor.
path (PathSpec): the path to read.
start (int): first byte to read.
end (int): one past the last byte to read.
"""
return await read_bytes(accessor, path, offset=start, size=end - start)
+40 -2
View File
@@ -17,6 +17,7 @@ from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.ops.generic.table import OpFn, OpsTable
from mirage.ops.registry import RegisteredOp
from mirage.types import PathSpec
from mirage.utils.ranges import slice_window
def _make_read(fn: OpFn) -> OpFn:
@@ -31,6 +32,44 @@ def _make_read(fn: OpFn) -> OpFn:
return read
def _make_ranged_read(table: OpsTable) -> OpFn:
"""Build the ``read`` op, honoring a byte range when one is asked for.
A backend that can fetch a range natively does so, which is the whole
point on an object store: one ranged GET instead of the whole file.
Every other backend falls back to reading and slicing, which is the
same answer at the same cost as before, and is the only meaningful
behavior for a backend that renders its content rather than storing
it, since there is no remote range to ask for.
A zero-length read is answered here rather than sent anywhere: no
store can express an empty range, and the answer is known.
Args:
table (OpsTable): the backend's op table.
"""
async def read(accessor: Accessor,
path: PathSpec,
*,
index: IndexCacheStore | None = None,
offset: int = 0,
size: int | None = None,
**kwargs) -> bytes:
if size == 0:
return b""
whole = not offset and size is None
native = table.read_range
if native is not None and not whole:
return await native(accessor, path, index, offset, size)
data = await table.read_bytes(accessor, path, index)
if whole:
return data
return slice_window(data, offset, size)
return read
def _make_data_write(fn: OpFn) -> OpFn:
async def write(accessor: Accessor, path: PathSpec, data: bytes,
@@ -172,8 +211,7 @@ def make_generic_ops(
skip = overrides or set()
ops: list[RegisteredOp] = []
_emit(ops, resources, "read", _make_read(table.read_bytes), False, None,
skip)
_emit(ops, resources, "read", _make_ranged_read(table), False, None, skip)
_emit(ops, resources, "readdir", _make_read(table.readdir), False, None,
skip)
_emit(ops, resources, "stat", _make_read(table.stat), False, None, skip)
+4
View File
@@ -42,6 +42,10 @@ class OpsTable(Protocol):
def stat(self) -> OpFn:
...
@property
def read_range(self) -> OpFn | None:
...
@property
def write(self) -> OpFn | None:
...
+1 -1
View File
@@ -273,7 +273,7 @@ class Ops:
bytes: File content.
"""
if offset or size is not None:
return await self._call("read", path, offset, size)
return await self._call("read", path, offset=offset, size=size)
return await self._call("read", path)
async def write(self, path: str, data: bytes) -> None:
+5
View File
@@ -23,6 +23,11 @@ StatOverlay = Callable[[str, FileStat], FileStat]
# What a traversal command asks about its own start point, which decides
# whether a walk is possible at all.
StatPath = Callable[[str], Awaitable["FileStat | None"]]
# The mount prefix serving a virtual path. A mount boundary is a
# filesystem boundary, which is where git stops looking for a repository
# (GIT_DISCOVERY_ACROSS_FILESYSTEM); crossing it would probe an
# unrelated backend.
MountRoot = Callable[[str], str]
# lstat for one path: the link's own stat, None when not a link.
LinkStat = Callable[[str], "FileStat | None"]
# Stat rows for the links directly under a directory, for listings.
+140
View File
@@ -0,0 +1,140 @@
# ========= 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. =========
# In a basic regular expression these are ordinary characters, and the
# backslashed spellings are the operators. That is the whole inversion:
# Python's engine reads them the other way round, so an untranslated BRE
# silently matches the wrong lines rather than failing.
BRE_LITERALS = "+?|(){}"
BACKREFERENCES = "123456789"
# A bracket expression takes its own rules: every character inside is
# ordinary, so the span is copied out untouched.
CLASS_INTRODUCERS = ":.="
def _bracket_end(pattern: str, start: int) -> int:
"""Index just past a bracket expression opening at ``start``.
POSIX puts a literal ``]`` first, after an optional ``^``, and
``[:alpha:]``-style classes nest their own closing bracket, so
neither ends the span.
Args:
pattern (str): the whole pattern.
start (int): index of the opening ``[``.
"""
i = start + 1
n = len(pattern)
if i < n and pattern[i] == "^":
i += 1
if i < n and pattern[i] == "]":
i += 1
while i < n and pattern[i] != "]":
if (pattern[i] == "[" and i + 1 < n
and pattern[i + 1] in CLASS_INTRODUCERS):
closing = pattern[i + 1] + "]"
end = pattern.find(closing, i + 2)
i = i + 2 if end == -1 else end + 2
else:
i += 1
return i + 1 if i < n else n
def _dollar_anchors(pattern: str, index: int) -> bool:
"""Whether a ``$`` at ``index`` is the end-of-line assertion.
Only at the very end of the pattern or immediately before ``\\)``
or ``\\|``; anywhere else it is a literal dollar sign. Pinned
against GNU grep 3.x: ``a$b`` matches the three characters ``a$b``
while ``\\(a$\\)`` anchors.
Args:
pattern (str): the whole pattern.
index (int): index of the ``$``.
"""
rest = pattern[index + 1:]
return rest == "" or rest.startswith("\\)") or rest.startswith("\\|")
def bre_to_python(pattern: str) -> str:
"""Translate a POSIX basic regular expression to Python's dialect.
grep, sed and expr read basic expressions unless told otherwise, and
Python's ``re`` reads something close to an extended one, so handing
a pattern straight over inverts every operator in it. ``a+b`` looks
for a literal plus to grep and for a repeated ``a`` to Python, and
both find something, which is why this went unnoticed: the failure
is a wrong answer, not an error.
Every rule below is pinned against GNU grep 3.x rather than taken
from a specification, because the edge cases are where the two
dialects differ most:
- ``+ ? | ( ) { }`` are ordinary; ``\\+ \\? \\| \\( \\) \\{ \\}``
are the operators (a GNU extension for the first three).
- ``*`` is ordinary where nothing precedes it to repeat: at the
start of the pattern, and after ``^``, ``\\(`` or ``\\|``.
``^*abc`` matches a literal asterisk.
- ``^`` anchors only at those same starting positions, and ``$``
only at the end or before ``\\)`` / ``\\|``. ``a^b`` and ``a$b``
are three literal characters each.
- A bracket expression is copied out whole: everything inside it is
already ordinary in both dialects.
Args:
pattern (str): the expression as the user typed it.
"""
out: list[str] = []
index = 0
length = len(pattern)
# True where no expression precedes, so `*` cannot repeat anything
# and `^` still anchors: the pattern start and just inside `\(`/`\|`.
fresh = True
while index < length:
char = pattern[index]
if char == "[":
end = _bracket_end(pattern, index)
out.append(pattern[index:end])
index = end
fresh = False
continue
if char == "\\" and index + 1 < length:
following = pattern[index + 1]
if following in BRE_LITERALS:
out.append(following)
fresh = following in "(|"
elif following in BACKREFERENCES:
out.append(f"\\{following}")
fresh = False
else:
out.append(f"\\{following}")
fresh = False
index += 2
continue
if char in BRE_LITERALS:
out.append(f"\\{char}")
fresh = False
elif char == "^":
out.append("^" if fresh else "\\^")
elif char == "$":
out.append("$" if _dollar_anchors(pattern, index) else "\\$")
fresh = False
elif char == "*":
out.append("*" if not fresh else "\\*")
fresh = False
else:
out.append(char)
fresh = False
index += 1
return "".join(out)

Some files were not shown because too many files have changed in this diff Show More