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:
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Executable
+64
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,6 +42,10 @@ class OpsTable(Protocol):
|
||||
def stat(self) -> OpFn:
|
||||
...
|
||||
|
||||
@property
|
||||
def read_range(self) -> OpFn | None:
|
||||
...
|
||||
|
||||
@property
|
||||
def write(self) -> OpFn | None:
|
||||
...
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user