67945abf63
* 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.
190 lines
6.5 KiB
Python
190 lines
6.5 KiB
Python
# ========= 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,
|
|
),
|
|
),
|
|
)
|