diff --git a/.gitignore b/.gitignore index 7bf286f..3428e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,164 +1,29 @@ -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] -*$py.class - -# C extensions -*.so - -*.code-workspace - -# Distribution / packaging -.Python -build/ -develop-eggs/ +.venv/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ +build/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ +# Tooling caches .pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy .mypy_cache/ -.dmypy.json -dmypy.json +.ruff_cache/ -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Legacy artifacts kept locally but not on this branch (live on `main`) +master_roethemeyer_featureorientedgit.pdf +target/ -.vscode/ \ No newline at end of file +# Local dev working docs (not part of the repo) +/DESIGN.md +/CLAUDE.md +plan.md +evaluation-plan.md +evaluation/ +report.md +HOW-IT-WORKS.md +migration.md + +# Claude Code local tooling state, not shared project config +.claude/ diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 69cee0f..0000000 --- a/.pylintrc +++ /dev/null @@ -1,644 +0,0 @@ -[MAIN] - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Clear in-memory caches upon conclusion of linting. Useful if running pylint -# in a server-like mode. -clear-cache-post-run=no - -# Load and enable all available extensions. Use --list-extensions to see a list -# all available extensions. -#enable-all-extensions= - -# In error mode, messages with a category besides ERROR or FATAL are -# suppressed, and no reports are done by default. Error mode is compatible with -# disabling specific errors. -#errors-only= - -# Always return a 0 (non-error) status code, even if lint errors are found. -# This is primarily useful in continuous integration scripts. -#exit-zero= - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-allow-list= - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. (This is an alternative name to extension-pkg-allow-list -# for backward compatibility.) -extension-pkg-whitelist= - -# Return non-zero exit code if any of these messages/categories are detected, -# even if score is above --fail-under value. Syntax same as enable. Messages -# specified are enabled, while categories only check already-enabled messages. -fail-on= - -# Specify a score threshold under which the program will exit with error. -fail-under=10 - -# Interpret the stdin as a python script, whose filename needs to be passed as -# the module_or_package argument. -#from-stdin= - -# Files or directories to be skipped. They should be base names, not paths. -ignore=CVS - -# Add files or directories matching the regular expressions patterns to the -# ignore-list. The regex matches against paths and can be in Posix or Windows -# format. Because '\\' represents the directory delimiter on Windows systems, -# it can't be used as an escape character. -ignore-paths= - -# Files or directories matching the regular expression patterns are skipped. -# The regex matches against base names, not paths. The default value ignores -# Emacs file locks -ignore-patterns=^\.# - -# List of module names for which member attributes should not be checked and -# will not be imported (useful for modules/projects where namespaces are -# manipulated during runtime and thus existing member attributes cannot be -# deduced by static analysis). It supports qualified module names, as well as -# Unix pattern matching. -ignored-modules= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use, and will cap the count on Windows to -# avoid hangs. -jobs=1 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python module names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Resolve imports to .pyi stubs if available. May reduce no-member messages and -# increase not-an-iterable messages. -#prefer-stubs=no - -# Minimum Python version to use for version dependent checks. Will default to -# the version used to run pylint. -py-version=3.10 - -# Discover python modules and packages in the file system subtree. -recursive=no - -# Add paths to the list of the source roots. Supports globbing patterns. The -# source root is an absolute path or a path relative to the current working -# directory used to determine a package namespace for modules located under the -# source root. -source-roots= - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - -# In verbose mode, extra non-checker-related info will be displayed. -#verbose= - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. If left empty, argument names will be checked with the set -# naming style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. If left empty, attribute names will be checked with the set naming -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Bad variable names regexes, separated by a comma. If names match any regex, -# they will always be refused -bad-names-rgxs= - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. If left empty, class attribute names will be checked -# with the set naming style. -#class-attribute-rgx= - -# Naming style matching correct class constant names. -class-const-naming-style=UPPER_CASE - -# Regular expression matching correct class constant names. Overrides class- -# const-naming-style. If left empty, class constant names will be checked with -# the set naming style. -#class-const-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. If left empty, class names will be checked with the set naming style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. If left empty, constant names will be checked with the set naming -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. If left empty, function names will be checked with the set -# naming style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Good variable names regexes, separated by a comma. If names match any regex, -# they will always be accepted -good-names-rgxs= - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. If left empty, inline iteration names will be checked -# with the set naming style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. If left empty, method names will be checked with the set naming style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. If left empty, module names will be checked with the set naming style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Regular expression matching correct type alias names. If left empty, type -# alias names will be checked with the set naming style. -#typealias-rgx= - -# Regular expression matching correct type variable names. If left empty, type -# variable names will be checked with the set naming style. -#typevar-rgx= - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. If left empty, variable names will be checked with the set -# naming style. -#variable-rgx= - - -[CLASSES] - -# Warn about protected attribute access inside special methods -check-protected-access-in-special-methods=no - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp, - asyncSetUp, - __post_init__ - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - - -[DESIGN] - -# List of regular expressions of class ancestor names to ignore when counting -# public methods (see R0903) -exclude-too-few-public-methods= - -# List of qualified class names to ignore when counting class parents (see -# R0901) -ignored-parents= - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement (see R0916). -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when caught. -overgeneral-exceptions=builtins.BaseException,builtins.Exception - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[IMPORTS] - -# List of modules that can be imported at any level, not just the top level -# one. -allow-any-import-level= - -# Allow explicit reexports by alias from a package __init__. -allow-reexport-from-package=no - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules= - -# Output a graph (.gv or any supported image format) of external dependencies -# to the given file (report RP0402 must not be disabled). -ext-import-graph= - -# Output a graph (.gv or any supported image format) of all (i.e. internal and -# external) dependencies to the given file (report RP0402 must not be -# disabled). -import-graph= - -# Output a graph (.gv or any supported image format) of internal dependencies -# to the given file (report RP0402 must not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - -# Couples of modules and preferred modules, separated by a comma. -preferred-modules= - - -[LOGGING] - -# The type of string formatting that logging methods do. `old` means using % -# formatting, `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, -# UNDEFINED. -confidence=HIGH, - CONTROL_FLOW, - INFERENCE, - INFERENCE_FAILURE, - UNDEFINED - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then re-enable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=raw-checker-failed, - bad-inline-option, - locally-disabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - use-symbolic-message-instead, - use-implicit-booleaness-not-comparison-to-string, - use-implicit-booleaness-not-comparison-to-zero - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable= - - -[METHOD_ARGS] - -# List of qualified names (i.e., library.method) which require a timeout -# parameter e.g. 'requests.api.get,requests.api.post' -timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - -# Regular expression of note tags to take in consideration. -notes-rgx= - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit,argparse.parse_error - -# Let 'consider-using-join' be raised when the separator to join on would be -# non-empty (resulting in expected fixes of the type: ``"- " + " - -# ".join(items)``) -# suggest-join-with-non-empty-separator=yes - - -[REPORTS] - -# Python expression which should return a score less than or equal to 10. You -# have access to the variables 'fatal', 'error', 'warning', 'refactor', -# 'convention', and 'info' which contain the number of messages in each -# category, as well as 'statement' which is the total number of statements -# analyzed. This score is used by the global evaluation report (RP0004). -evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -msg-template= - -# Set the output format. Available formats are: text, parseable, colorized, -# json2 (improved json format), json (old json format) and msvs (visual -# studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -#output-format= - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[SIMILARITIES] - -# Comments are removed from the similarity computation -ignore-comments=yes - -# Docstrings are removed from the similarity computation -ignore-docstrings=yes - -# Imports are removed from the similarity computation -ignore-imports=yes - -# Signatures are removed from the similarity computation -ignore-signatures=yes - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. No available dictionaries : You need to install -# both the python package and the system dependency for enchant to work. -spelling-dict= - -# List of comma separated words that should be considered directives if they -# appear at the beginning of a comment and should not be checked. -spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains the private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to the private dictionary (see the -# --spelling-private-dict-file option) instead of raising a message. -spelling-store-unknown-words=no - - -[STRING] - -# This flag controls whether inconsistent-quotes generates a warning when the -# character used as a quote delimiter is used inconsistently within a module. -check-quote-consistency=no - -# This flag controls whether the implicit-str-concat should generate a warning -# on implicit string concatenation in sequences defined over several lines. -check-str-concat-over-line-jumps=no - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of symbolic message names to ignore for Mixin members. -ignored-checks-for-mixins=no-member, - not-async-context-manager, - not-context-manager, - attribute-defined-outside-init - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - -# Regex pattern to define which classes are considered mixins. -mixin-class-rgx=.*[Mm]ixin - -# List of decorators that change the signature of a decorated function. -signature-mutators= - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of names allowed to shadow builtins -allowed-redefined-builtins= - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..98bfdb3 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: check fmt lint type test + +check: fmt lint type test + +fmt: + uv run ruff format --check . + +lint: + uv run ruff check . + +type: + uv run mypy git_feature + +test: + uv run pytest -q diff --git a/README.md b/README.md index 4df1e3c..b837dde 100644 --- a/README.md +++ b/README.md @@ -1,162 +1,670 @@ +# git-feature -# Feature-oriented Git +Feature-oriented version control on top of git: label code changes with the +features they implement, keep those labels accurate through rebases and +cherry-picks, query feature history, and derive product variants containing +only a chosen set of features. -This tool provides advanced Git feature management, allowing you to associate feature information with commits and files. +## Development setup -## Installation + Preparation +Requires [uv](https://docs.astral.sh/uv/). -1. Install the tool using pip: - 1. Download the latest release file (currently, the project is not stored in the PIP Index) - 1. Install the tool using pip - ```bash - pip install /git-tool--py3-none-any.whl - ``` -1. In each Git repository where you want to track feature information, add the hooks provided by this project. This step is essential to ensure the internal state remains up-to-date. ***Each hook can only exist once. If you already have hooks, you need to manually copy the file contents instead*** +```sh +uv sync # create venv + install deps +uv run git-feature --help +make check # format check + lint + types + tests +``` + +## Quickstart + +**0. Build the tool and put it on `PATH`.** Run this once, from a clone of +this repository: + +```sh +uv sync +export PATH="$PWD/.venv/bin:$PATH" +git-feature --version +``` + +**1. Create a throwaway sandbox and start an ordinary repo there.** +Everything below happens in a scratch directory. Nothing here touches this +repository, and you can delete the sandbox when you're done: + +```sh +cd "$(mktemp -d)" +mkdir myapp && cd myapp +git init +cat > app.py <<'EOF' +def hello(): + print("hi") +EOF +git add app.py && git commit -m "add hello" +``` + +**2. Adopt the tool retroactively.** `init` never touches existing history. +It creates the metadata store on its own ref, installs chained git hooks, and +drops a starter `model.cfr` for you to edit: + +```sh +git feature init +``` + +```text +created feature store at refs/feature/store +created starter model.cfr +installed post-commit dispatcher +installed post-commit.d/50-git-feature +installed post-rewrite dispatcher +installed post-rewrite.d/50-git-feature +``` + +`git feature doctor` re-checks this at any point (store readable, hooks +chained, every annotation's change-id still known). + +
+Peek inside: what init actually created + +Nothing above touched `main`'s history. `init` wrote one commit onto its own +ref, `refs/feature/store`. It's a real, ordinary commit history; plain git +plumbing sees it like any other: + +```sh +git log refs/feature/store --oneline +git ls-tree -r --name-only refs/feature/store +git cat-file -p refs/feature/store:meta.json +``` + +```text +ee2ad46 initialize feature store + +meta.json + +{ + "schema_version": 1, + "tool_version": "0.1.0" +} +``` + +That's the entire store so far: a schema version, nothing else yet. +`git branch`/`git log`/`git status` on `main` never show any of this. + +
+ +**3. Describe an initial feature model, and commit it like any other file.** +`model.cfr` (a small [Clafer](https://www.clafer.org/) subset) starts with +just the one feature that exists so far: + +``` +abstract App { + core +} + +Minimal : App {} +``` + +```sh +git add model.cfr && git commit -m "describe the feature model" +``` + +
+Peek inside: this ordinary commit just changed the store too + +This commit landed *after* `init`, so the chained `post-commit` hook fired +and minted it a change-id right away, before anything has even been +annotated. That's the difference between this commit and the `add hello` +one from step 1, which predates the hooks entirely: + +```sh +git log refs/feature/store --oneline +git ls-tree -r --name-only refs/feature/store +git cat-file -p refs/feature/store:changeids/map-21.json +``` + +```text +3b99706 mint change-id for 217f6ca05f87 +ee2ad46 initialize feature store + +changeids/map-21.json +meta.json + +{ + "217f6ca05f87aaabaa03b944e2089bb92f5a2035": { + "sha": "217f6ca05f87aaabaa03b944e2089bb92f5a2035", + "change_id": "01KYPVNRPKW5BNQPEERY2PP86V", + "patch_id": "7289c31690c6a128d4f54cfe274e56806f93214e" + } +} +``` + +The map is sharded by the first two hex characters of the commit sha +(`changeids/map-.json`) so no single file grows without bound as history +grows. No annotation exists yet, just an id, ready for one. + +
+ +**4. Annotate the pre-existing commit.** This is the retroactive part: the +`add hello` commit predates `git feature init`, but it can still be annotated. +A new change-id is minted for it lazily, the moment you run: + +```sh +git feature annotate --feature core +``` + +```text +annotated 1 region(s) across 1 commit(s) +minted 1 change-id(s) for older commits +created feature 'core' (auto) +``` + +
+Peek inside: the annotation itself, in the store + +In the run these snippets are captured from, `add hello` was `d3523422b41d`. +This one command both minted its change-id (lazily, since it predates the +hooks) and wrote the annotation, in a single store commit: + +```sh +git log refs/feature/store --oneline +git ls-tree -r --name-only refs/feature/store +git cat-file -p refs/feature/store:features/core.json +git cat-file -p refs/feature/store:annotations/01KYPVPA4NRNNSEJA951YEEHM0.json +``` + +```text +27b3c8a annotate 1 regions +3b99706 mint change-id for 217f6ca05f87 +ee2ad46 initialize feature store + +annotations/01KYPVPA4NRNNSEJA951YEEHM0.json +changeids/map-21.json +changeids/map-d3.json +features/core.json +meta.json + +{ + "name": "core", + "description": "", + "owners": [], + "tags": [], + "links": [], + "auto_created": true +} + +[ + { + "anchor": { + "change_id": "01KYPVPA4NRNNSEJA951YEEHM0", + "path": "app.py", + "old_span": [0, 0], + "new_span": [1, 2], + "fingerprint": "7fa8eae0365e947e" + }, + "presence": "core", + "author": "Dev", + "ts": "2026-07-29T11:55:51.829972Z", + "note": null + } +] +``` + +`features/core.json` is the auto-created feature definition. It's bare-bones +since it was never described in `model.cfr` by hand. The annotation is filed +under `annotations/.json`, keyed by the change-id from the map, +never by the sha directly. That's what lets it survive a rebase or amend of +this commit later on. + +
+ +**5. Keep developing: new commits, annotated as they land.** This is the +steady-state workflow from here on: write code, commit normally, annotate +right after: - 1. **Find the tool's installation path**: - ```bash - pip show git-feature-tool - ``` - Output example: - ``` - Location: /path/to/your/python/site-packages/git_tool - ``` - 1. **Set the hooks path**: - ```bash - git config core.hooksPath /path/to/your/python/site-packages/git_tool/hooks - ``` - This sets up Git to use the hooks from the specified directory. +```sh +cat >> app.py <<'EOF' -## Commands Overview +def auth(): + return "token" +EOF +git commit -am "auth: add auth" +git feature annotate HEAD --feature auth +``` + +```sh +cat >> app.py <<'EOF' + +def stats(): + return {"calls": 0} +EOF +git commit -am "stats: add stats" +git feature annotate HEAD --feature stats +``` + +`git feature list` shows the catalog built up so far, `git feature blame +` the per-line result: + +```sh +git feature list +``` + +```text +FEATURE ANNOTATIONS CHANGES +auth 1 1 (auto) +core 1 1 (auto) +stats 1 1 (auto) +``` + +```sh +git feature blame app.py +``` + +```text + 1 core def hello(): + 2 core print("hi") + 3 auth + 4 auth def auth(): + 5 auth return "token" + 6 stats + 7 stats def stats(): + 8 stats return {"calls": 0} +``` + +
+Peek inside: the store after two more rounds of commit + annotate + +Each `git commit` above triggered the hook's automatic mint; each +`git feature annotate` added one more store commit on top of that. The +store's own history now has one entry per write, in order: + +```sh +git log refs/feature/store --oneline +git ls-tree -r --name-only refs/feature/store +``` + +```text +8e35c15 annotate 1 regions +e3ccbd7 mint change-id for 2e8f75a886b1 +55aef20 annotate 1 regions +a5ef64b mint change-id for f204a9a5b5a2 +27b3c8a annotate 1 regions +3b99706 mint change-id for 217f6ca05f87 +ee2ad46 initialize feature store + +annotations/01KYPVPA4NRNNSEJA951YEEHM0.json +annotations/01KYPVPGCTC80RZBCYT2BDBMBM.json +annotations/01KYPVPGRW2VXHWEFYQBVE8C43.json +changeids/map-21.json +changeids/map-2e.json +changeids/map-d3.json +changeids/map-f2.json +features/auth.json +features/core.json +features/stats.json +meta.json +``` + +One `annotations/.json` file per annotated commit, one +`features/.json` per feature, `changeids/` sharded by sha prefix. +`model.cfr` itself is never in here. It's a normal file, versioned in `main` +like any other; the store only ever holds the tool's own metadata. + +
+ +**6. Not every change needs annotating.** A region that's never annotated is +simply never tracked. It survives every future derivation untouched, no +matter which features are on or off. Here's a commit that's deliberately +left alone, followed by one that isn't: + +```sh +sed -i '/^def stats():/i # --- stats extras ---' app.py +git commit -am "stats: add a helper comment" +# left unannotated on purpose +``` + +```sh +cat >> app.py <<'EOF' + +def reset_stats(): + return {"calls": 0} +EOF +git commit -am "stats: add reset_stats" +git feature annotate HEAD --feature stats +``` -### `git feature status` +```sh +git feature blame app.py +``` + +```text + 1 core def hello(): + 2 core print("hi") + 3 auth + 4 auth def auth(): + 5 auth return "token" + 6 stats + 7 - # --- stats extras --- + 8 stats def stats(): + 9 stats return {"calls": 0} + 10 stats + 11 stats def reset_stats(): + 12 stats return {"calls": 0} +``` + +Line 7's `-` is the never-annotated comment commit. `blame` correctly shows +it as unowned by any feature rather than silently attaching it to whatever +surrounds it. It also landed in the middle of the previously-annotated +`stats()` region, which matters again in step 9. -Displays the current feature status, including staged, unstaged, and untracked files with their associated features. +**7. Attach a presence *condition*, not just a feature.** `--feature` is +shorthand for "present when this one feature is on." `--presence` accepts a +full boolean expression over existing features, useful when a region only +makes sense with several features together: + +```sh +cat >> app.py <<'EOF' + +def report(): + return {"user": auth(), "usage": stats()} +EOF +git commit -am "add combined report" +git feature annotate HEAD --presence 'auth & stats' +``` -**Usage**: -```bash -git feature status +```text +annotated 1 region(s) across 1 commit(s) ``` -### `git feature add` -This command helps to associate feature information with a commit that does not yet exist. You can either add the information while adding the files or add features to the staging area. -If you prefer to keep your workflows as usual and add feature information solely to commits that you already created, you don't need the git hooks and can jump to `git feature-commit`. -#### `git feature add` +`git feature list` now counts this annotation against *both* features it +mentions, and `git feature blame` prints the expression itself for those +lines: -Associates specified features with staged files. You can stage specific files or all tracked changes. +```sh +git feature list +``` -**Options**: -- `--all`, `-a`: Stages all tracked changes. -- `--files`, `-f`: Specifies a list of files to stage and associate with features. +```text +FEATURE ANNOTATIONS CHANGES +auth 2 2 (auto) +core 1 1 (auto) +stats 3 3 (auto) +``` -**Usage**: -```bash -git feature add --all -git feature add --files ... +```sh +git feature blame app.py ``` -#### `git feature add-from-staged` +```text + 1 core def hello(): + 2 core print("hi") + 3 auth + 4 auth def auth(): + 5 auth return "token" + 6 stats + 7 - # --- stats extras --- + 8 stats def stats(): + 9 stats return {"calls": 0} + 10 stats + 11 stats def reset_stats(): + 12 stats return {"calls": 0} + 13 auth & stats + 14 auth & stats def report(): + 15 auth & stats return {"user": auth(), "usage": stats()} +``` -Uses staged files to associate them with feature information. +**8. Extend the model now that `auth` and `stats` are real, and add named +configurations.** These are the variant specs `checkout` reads by name: -**Usage**: -```bash -git feature add-from-staged +``` +abstract App { + core + auth ? + stats ? +} + +Minimal : App { + [ no auth ] + [ no stats ] +} + +Full : App { + [ auth ] + [ stats ] +} ``` +```sh +git add model.cfr && git commit -m "extend the feature model with auth and stats" +git feature model validate +``` -### `git feature commit` -Assign features to a commit retroactively. To find all commits that have not yet features assigned, see --- -**Usage**: -```bash -git feature commit +```text +model ok ``` -### `git feature blame` +**9. Derive variants.** `checkout ` resolves the model, removes +every region whose presence condition evaluates to false, and refuses to move +the branch unless the derived tree verifies. `--dry-run` shows the plan +first: -Displays the feature associations for each line of a specified file, similar to `git blame`. +```sh +git feature checkout Minimal --dry-run +``` -**Usage**: -```bash -git feature blame +```text +variant Minimal from 93c626a252ef (dry run) +disabled features: auth, stats +would remove: + app.py: lines 3-5 [auth] (exact) + app.py: lines 6-6, lines 8-9 [stats] (moved) + app.py: lines 10-12 [stats] (exact) + app.py: lines 13-15 [auth & stats] (exact) +kept regions: 1 +no conflicts ``` -### `git feature info` +The `stats` region shows `(moved)`, split across two line runs. That's the +never-annotated comment from step 6 sitting in the middle of what used to be +one contiguous block. Blame-forward still finds all of it (nothing was +lost), but not as a single unbroken window, so the resolver honestly reports +lower confidence instead of claiming `exact`. Line 13's `[auth & stats]` is +the presence condition from step 7: with both `auth` and `stats` disabled, +the expression evaluates false, so the region is removed here too. A +presence condition doesn't need a feature of its own to gate a derivation. + +```sh +git feature checkout Full --dry-run +``` -Displays detailed information about a feature, including associated commits, files, authors, and branches. +```text +variant Full from 93c626a252ef (dry run) +disabled features: (none) +would remove: nothing +kept regions: 5 +no conflicts +``` -**Options**: -- `--authors`: Lists the authors who contributed to the feature. -- `--files`: Lists the files associated with the feature. -- `--branches`: Lists the branches where the feature is present. -- `--updatable`: Check if the feature has updates available on other branches and list the update options. -- `--branch `: Specify a branch for checking updates (used with `--updatable`). +With every feature enabled, `auth & stats` evaluates true and every region, +including the presence-conditioned one, stays. Then derive both for real: -**Usage**: -```bash -git feature info -git feature info --authors --files --branches +```sh +git feature checkout Minimal +cat app.py ``` -### `git feature commits` +```text +variant Minimal: refs/heads/variant/Minimal -> 5b7ae588223d (4 region(s) removed, verified) +switched to variant/Minimal -Lists all commits associated with a feature or shows commits that are missing feature associations. +def hello(): + print("hi") +# --- stats extras --- +``` -**Commands**: -- `missing`: Lists commits that do not have any associated feature. -- `list`: List commits that are associated to features. +That leftover `# --- stats extras ---` comment is the honest consequence of +step 6: it was never annotated, so `checkout` has no basis to remove it. +Leaving a change unannotated really does mean "leave this alone forever," +not "figure it out later." -**Usage**: -```bash -git feature commits list -git feature commits missing +```sh +git checkout main +git feature checkout Full +git feature variant list ``` ---- +```text +variant Full: refs/heads/variant/Full -> e35379915600 (0 region(s) removed, verified) +switched to variant/Full -## Example Usage +Full: variant/Full from 93c626a252ef (disabled: (none)) [current] +Minimal: variant/Minimal from 93c626a252ef (disabled: auth, stats) [current] +``` -1. **Check Feature Status**: - ```bash - git feature status - ``` +
+Peek inside: the derivation manifest and the variant branches -2. **Add Features to All Files**: - ```bash - git feature add --all "new-feature" - ``` +Unlike everything above, `variant/Minimal` and `variant/Full` are ordinary +branches (`refs/heads/variant/`). They show up in `git branch` and can +be inspected with plain git. What's *not* ordinary is that each derivation +also records a manifest in the store: the resolved feature assignment plus a +decision (included/excluded, resolved position, confidence) for every region +it considered, including *why* the `auth & stats` region was excluded, not +just that it was: -3. **Add Features to Specific Files**: - ```bash - git feature add --files src/main.py "feature-x" - ``` +```sh +git for-each-ref refs/heads/variant +git cat-file -p refs/feature/store:variants/Minimal.json +``` -4. **Use Staged Files for Feature Information**: - ```bash - git feature add-from-staged - ``` +```text +e3537991560003ae47d1e66c06124f4591b1d501 commit refs/heads/variant/Full +5b7ae588223dc54f27099477ed97f44fb079aa35 commit refs/heads/variant/Minimal + +{ + "instance": "Minimal", + "base_commit": "93c626a252efcadfa6e85f28a754d7f4e3e0f8cd", + "assignment": { "auth": false, "core": true, "stats": false, "App": true }, + "decisions": [ + { + "anchor": { "change_id": "01KYPVPA4NRNNSEJA951YEEHM0", "path": "app.py", + "old_span": [0, 0], "new_span": [1, 2], + "fingerprint": "7fa8eae0365e947e" }, + "presence": "core", "included": true, + "resolved_span": [1, 2], "resolved_runs": null, + "resolved_path": null, "confidence": "exact" + }, + { + "anchor": { "change_id": "01KYPVPGCTC80RZBCYT2BDBMBM", "path": "app.py", + "old_span": [2, 0], "new_span": [3, 3], + "fingerprint": "f0dba3d7bc95b14b" }, + "presence": "auth", "included": false, + "resolved_span": [3, 3], "resolved_runs": null, + "resolved_path": null, "confidence": "exact" + }, + { + "anchor": { "change_id": "01KYPVPGRW2VXHWEFYQBVE8C43", "path": "app.py", + "old_span": [5, 0], "new_span": [6, 3], + "fingerprint": "26a897eb2dd8f5a2" }, + "presence": "stats", "included": false, + "resolved_span": [6, 4], "resolved_runs": [[6, 1], [8, 2]], + "resolved_path": null, "confidence": "moved" + }, + { + "anchor": { "change_id": "01KYPVPXDSHJVJ085PT5SVJ1V8", "path": "app.py", + "old_span": [9, 0], "new_span": [10, 3], + "fingerprint": "73ebc2e1bf5f77c8" }, + "presence": "stats", "included": false, + "resolved_span": [10, 3], "resolved_runs": null, + "resolved_path": null, "confidence": "exact" + }, + { + "anchor": { "change_id": "01KYPVQ40H5G6D8ETW9M3EKS0C", "path": "app.py", + "old_span": [12, 0], "new_span": [13, 3], + "fingerprint": "08445be43edadfc4" }, + "presence": "auth & stats", "included": false, + "resolved_span": [13, 3], "resolved_runs": null, + "resolved_path": null, "confidence": "exact" + } + ], + "created_at": "2026-07-29T11:56:41.641428Z" +} +``` -5. **Show Feature Associations for a File**: - ```bash - git feature blame src/main.py - ``` +That `"moved"` decision is the one from the dry-run above. Notice +`resolved_runs: [[6, 1], [8, 2]]` records the *exact* two surviving pieces +(1 line, then 2 lines), while `resolved_span: [6, 4]` is just their overall +outer extent. The last decision is the `auth & stats` region: `presence` +records the raw expression, not a feature name, and `included: false` is +what evaluating that expression against `assignment` actually produced. +This is the same record whether a region's presence is one feature or a full +boolean condition. `checkout --refresh`/`variant list` read this back later +to know what a variant actually contains and whether its base has gone +stale. The branch's tree alone wouldn't say *why* a region was removed or +how confidently it was located. + +
+ +**10. Edit a variant directly, then put the edit back onto the branch it came +from.** `view ` derives the variant like `checkout` does, but also +opens a session that `putback` later reads to map your edits back through +the derivation — translating line numbers from the stripped-down variant +back onto the original file: + +```sh +git checkout main +git feature view Full +``` -6. **Display Feature Information**: - ```bash - git feature info feature-x --authors --files --branches - ``` +```text +view of Full: editing 8014fe9076c8 (from main @ e62c9133c0cc) — put edits back with 'git feature putback' +switched to variant/Full +``` -7. **List All Commits With and Without Features**: - ```bash - git feature commits list - git feature commits missing - ``` +Add a new function right there in the view, as an ordinary uncommitted edit: ---- +```sh +cat >> app.py <<'EOF' -For further details or to explore more usage options, refer to the [Typer Documentation](https://typer.tiangolo.com/tutorial/). +def version(): + return "1.0" +EOF +git feature putback +``` + +```text +main -> 43a9e6a7b52f (1 file(s) transplanted) +view of Full refreshed onto the new source commit +``` +`putback` created one new commit on `main`, containing just that addition, +and fast-forwarded `main` onto it. It also refreshed the `Full` view in +place so it stays usable for further edits without re-running `view` by +hand. +```sh +git checkout main +git feature blame app.py +``` + +```text + 1 core def hello(): + 2 core print("hi") + 3 auth + 4 auth def auth(): + 5 auth return "token" + 6 stats + 7 - # --- stats extras --- + 8 stats def stats(): + 9 stats return {"calls": 0} + 10 stats + 11 stats def reset_stats(): + 12 stats return {"calls": 0} + 13 auth & stats + 14 auth & stats def report(): + 15 auth & stats return {"user": auth(), "usage": stats()} + 16 - + 17 - def version(): + 18 - return "1.0" +``` -## Development -1. Create a virtual environment and install both requirement-files. +`version()` lands on `main` unannotated, same `-` as the step 6 comment: +`putback` transplants code, it doesn't guess a presence condition for it. +Annotate it the normal way (step 5) if it should belong to a feature. diff --git a/docs/Design.md b/docs/Design.md new file mode 100644 index 0000000..6a95203 --- /dev/null +++ b/docs/Design.md @@ -0,0 +1,127 @@ +# Design + +This project has three parts: describing what features exist and which +combinations of them are valid (**modeling**), recording which code belongs +to which feature (**storage**), and producing a working build that contains +only a chosen set of features (**derivation**). The sections below cover +each in turn: what it does, and why it is built the way it is. + +Where useful, this is compared against the original tool on the `main` +branch. Some of this project is a direct evolution of that tool (storage, in +particular, is), and some of it, modeling and derivation, did not exist in +the original at all and is new here, so those sections explain the reasoning +on their own rather than forcing a comparison that would not mean anything. + +## Modeling: describing what features exist + +Before code can be tagged with a feature, there has to be somewhere to say +what features exist in the first place, how they relate to each other, and +which combinations actually make sense as a product. This project uses a +small feature-modeling file at the root of the project, written in a +deliberately small subset of a modeling language called Clafer, popular in +software product line research. It lets you describe a tree of features +(some mandatory, some optional, some mutually exclusive), rules between +them ("feature B requires feature A"), and named, concrete configurations, +e.g. "Minimal" or "Full", that later commands can refer to by name. + +This did not exist in the original tool at all: it only ever recorded a flat +feature name per commit, with no concept of how features relate to each +other or which combinations were legal. The closest thing to this idea was +explored in the Rust rewrite, but only as an internal detail of producing a +build, not as something a user described up front. Making it an explicit, +separate file was a deliberate choice: a feature model is a design decision +about the product, independent of any specific line of code, and it is +useful on its own (to validate that a requested combination is even legal) +before any code is involved at all. + +Because a partially specified configuration can leave some features +undetermined until its constraints are worked through, resolving a named +configuration is not a simple lookup: the model may need to propagate +mandatory features, group rules ("exactly one of these three"), and boolean +constraints until everything is settled one way or the other, and refuses +early with a specific reason if it cannot be made consistent. + +## Storage: recording which code belongs to which feature + +This is where the project is most directly a response to the original tool, +which kept a second, ordinary git branch purely for feature records, with +each record filed under the hash of the commit it described. That worked, +and importantly it let you retroactively tag commits that already existed +before the tool was installed. But keying a record to a commit's hash is +fragile: rebasing, amending, or cherry-picking a commit produces a new hash, +and the original design had no way to notice or fix that. The record just +silently stops meaning anything. The branch was also an ordinary, visible +branch, sitting in `git branch` next to real work. + +This project keeps the part that worked (metadata kept separate from real +history, and retroactive tagging of pre-existing commits) and tries to fix +the rest: + +- The records live on a git ref outside the normal branch namespace, so + they do not show up in `git branch` or clutter everyday git commands. It + behaves more like a build artifact than a branch someone might check out + by mistake. +- A commit's feature record is no longer tied to its hash. The first time a + commit is tagged, it is given its own separate, small identifier, and a + pair of git hooks carries that identifier forward automatically whenever + the commit is rebased, amended, or cherry-picked. The tag is attached to + that identifier, not to the hash, so it survives the exact operations + that broke the original scheme. (If the hooks were not installed at the + time a commit was made, elsewhere on another clone, for instance, there + is a fallback that recovers the identifier after the fact by comparing + the commit's content.) +- A tag is not limited to naming a single feature. It can be a full + condition over several features at once (for example, "feature A and + feature B, but not C"), and it is attached to a specific region of a + change rather than to the whole commit, so two unrelated edits landing in + the same commit can belong to different features. + +One consequence of moving records off the normal branch namespace is that +they no longer travel automatically with a plain `git push` or `git fetch` +the way an ordinary branch would. That had to be built deliberately: +explicit commands to share just the metadata, and a way to combine two +people's independently added records without a person having to resolve +conflicts by hand. This is a real cost of the design, not a side benefit, +worth stating plainly alongside its advantages. + +## Derivation: building a product from a chosen configuration + +This is the payoff of tagging code with features in the first place, and it +did not exist in the original tool at all, so this section is about the +reasoning behind this design on its own terms. + +Given a named configuration from the feature model, derivation works out +which tagged regions of code should be present and which should be removed, +and produces an actual, working build containing only the result, as a real +git branch. A few decisions shape how this happens: + +- **It is computed fresh from history each time, not maintained as a + standing branch.** A permanently maintained "Minimal" branch would drift + out of sync the moment anything upstream changed, and someone would have + to remember to update it. Deriving it on demand from the current history + means it can never be stale; it simply reflects whatever the tagged + history says right now. +- **The result is checked before it is allowed to become visible.** Rather + than trusting that the derivation logic did the right thing, the tool + independently re-examines the produced code and confirms that everything + meant to stay is actually there and everything meant to go is actually + gone. If anything looks wrong, the derived branch is refused rather than + handed over in a possibly broken state. +- **A derived build can be edited, and the edits mapped back.** A reduced + build is not only useful for shipping; it is also a smaller, simpler + place to notice and fix a bug that would be harder to spot in the full + codebase. Rather than requiring that fix to be found and reapplied by + hand in the original source, an edit made on the reduced build can be + translated back automatically to the corresponding place in the full + history and committed there, keeping the two in sync. + +## Reading feature history back + +Everything above exists in service of being able to ask questions about a +project's history: which features touch a given file or line, which commits +implement a given feature, how much of the recent history has been tagged +at all, and whether the project's own hooks and records are still healthy. +These are all read-only views over the same stored records described above, +so they did not need separate design decisions of their own; they exist +mainly to make the stored information actually usable day to day, rather +than write-only bookkeeping nobody looks at again. diff --git a/docs/ZeroToProd.md b/docs/ZeroToProd.md new file mode 100644 index 0000000..dfe50c1 --- /dev/null +++ b/docs/ZeroToProd.md @@ -0,0 +1,707 @@ +# Git with Feature + +The general idea: attach additional metadata to a git commit of which lines +of code belong to which feature in a way that survives rebases, amends, and +cherry-picks, without ever touching the user's actual commits. On top of that, +use the metadata to derive product variants (a build containing only a chosen +subset of features) directly from history. + +# Git concepts needed for the project + +## Extending git with git-command binaries + +`git` is a dispatcher. When you run `git foo args...`, git checks its table of +built-in subcommands (`commit`, `diff`, `rebase`, ...); if `foo` isn't one of +them, it searches `PATH` for an executable literally named `git-foo` and +execs it, forwarding the remaining arguments and environment. This is how +third-party tooling (`git-lfs`, `git-flow`, and this project) gets `git`-native +UX without patching git itself. `git foo` and `git-foo` are the same call. + +This project's entry point is declared in `pyproject.toml`: + +```toml +[project.scripts] +git-feature = "git_feature.cli:app" +``` + +`uv sync` / `pip install` turns that into a real `git-feature` executable +(installed via `pipx install git-feature` or `uv tool install git-feature` +for end users). Once `git-feature` is anywhere on `PATH`, both of these are +identical: + +```sh +git-feature list +git feature list # git found git-feature on PATH and executed it +``` + +During development there's no installed package on `PATH`, so either run +through `uv` directly or put the dev build there yourself: + +```sh +uv run git-feature --help +export PATH="$(pwd)/.venv/bin:$PATH" # now `git feature …` works too +``` + +Inside the binary, `git_feature/cli.py` is a `typer` app; each `git feature +` maps to one `@app.command()` that thin-wraps a module in `commands/`: + +```python +# git_feature/cli.py +app = typer.Typer(name="git-feature", no_args_is_help=True) + +@app.command() +def init(ctx: typer.Context, backfill: bool = False, hooks: bool = True) -> None: + """Initialize the feature store, hooks, and a starter model.cfr.""" + init_cmd.run(ctx, backfill=backfill, hooks=hooks) +``` + +One command is deliberately `hidden=True` as it is not meant for users, only +for the git hooks installed below, which invoke the very same binary as a +private subcommand rather than shelling out to anything else: + +```python +@app.command(name="hook-run", hidden=True) +def hook_run(ctx: typer.Context, hook: Annotated[HookName, typer.Argument()]) -> None: + """Internal: entry point invoked by the installed git hooks.""" + hook_run_cmd.run(ctx, hook=hook.value) +``` + +(See the [Git hooks](#git-hooks) section. The installed hook scripts run +`git-feature hook-run post-commit`, exercising this exact mechanism.) + +## Git object storage + +Everything git tracks, meaning file contents, directory listings, and +commits, is stored as one of a handful of object types in `.git/objects`, +addressed by the SHA hash of their own content. Objects are immutable: +change one byte and you get a new object at a new address; nothing is ever +edited in place. + +- **blob**: raw file contents, no filename, no metadata attached to it. +- **tree**: a directory listing: a sorted list of `(mode, name, oid)` + entries, each pointing at a blob (a file) or another tree (a subdirectory). +- **commit**: a pointer to one tree (the full snapshot at that point) plus + parent commit(s), author/committer, and a message. + +You can inspect any object with the plumbing command `git cat-file -p +` (`-t` prints just its type): + +```sh +git cat-file -t HEAD # -> commit +git cat-file -p HEAD # tree , parent , author, message +git cat-file -p HEAD^{tree} # the root tree: mode/type/oid/name per entry +git ls-tree -r HEAD # same info, recursively, one line per blob +``` + +This project builds its entire metadata store this way through +`pygit2` (the libgit2 binding), never through the working directory or index. +`git_feature/store/gitref.py` writes the store's tree from a flat +`path → bytes` mapping, bottom-up, exactly like the object model above: + +```python +def _build_tree(repo: pygit2.Repository, files: dict[str, bytes]) -> pygit2.Oid: + builder = repo.TreeBuilder() + subdirs: dict[str, dict[str, bytes]] = {} + for path, data in files.items(): + if "/" in path: + head, rest = path.split("/", 1) + subdirs.setdefault(head, {})[rest] = data + else: + builder.insert(path, repo.create_blob(data), pygit2.enums.FileMode.BLOB) + for name, sub in sorted(subdirs.items()): + builder.insert(name, _build_tree(repo, sub), pygit2.enums.FileMode.TREE) + return builder.write() +``` + +`repo.create_blob(data)` writes a blob object and returns its oid; +`TreeBuilder.insert` adds an entry for a blob or a nested tree; `.write()` +writes the tree object. The commit is created the same deliberate way. +Notice the first argument to `create_commit` is `None`: this writes a commit +*object* without moving any ref, so the caller decides separately, and +atomically, when (and whether) to point a reference at it: + +```python +new_oid = self._repo.create_commit(None, sig, sig, message, tree_oid, parents) +compare_and_swap_ref(self._repo, self._ref_name, str(new_oid), expected_old=self._base_sha) +``` + +The upshot: the feature store (`refs/feature/store`, see next section) is a +normal, inspectable commit history that you can run the same plumbing commands +against it that you'd run against any branch: + +```sh +git cat-file -p refs/feature/store # tree + parent + message +git ls-tree -r --name-only refs/feature/store # meta.json, features/auth.json, ... +git cat-file -p refs/feature/store:meta.json # the JSON blob's raw content +git log refs/feature/store --oneline # the store's own audit history +``` + +Content-addressing also means unrelated writes share storage for free: +`materialize.py`'s tree rewriting (building a variant with some regions +removed) reuses the *same blob oid* for every file it doesn't touch. No +copy, no duplication, git's usual deduplication just applies. + +## Git references + +A **reference** ("ref") is nothing but a name pointing at an object id, +typically a commit. Concretely it's either a small file under `.git/refs/...` +containing the target oid (or another ref, for symbolic refs like `HEAD`), or +a line in `.git/packed-refs` once git compacts them. That's the whole +mechanism: `refs/heads/main` *is* your `main` branch; there is no other branch +object. Deleting the ref deletes the branch; nothing under it is touched. + +```sh +cat .git/refs/heads/main # a single line: the commit oid +git symbolic-ref HEAD # -> refs/heads/main +git for-each-ref # every ref, its oid, and its type +git update-ref -d refs/heads/foo # delete a branch by deleting its ref, no different from git branch -d +``` + +Git only treats a few namespaces specially by convention: +`refs/heads/*` (local branches), `refs/tags/*`, `refs/remotes//*` +(remote-tracking branches). Nothing stops you from inventing your own +namespace. Most git commands that iterate refs either don't look outside the +conventional ones, or need to be told explicitly to. That's exactly what this +project does: `refs/feature/store` holds the entire metadata history, but it +never shows up in `git branch`, is never checked out, and is invisible unless +you ask for it by name. + +`git_feature/gitio/refio.py` wraps the two operations everything else needs: + +```python +def read_ref(repo: pygit2.Repository, name: str) -> str | None: + ref = repo.references.get(name) + return None if ref is None else str(ref.peel(pygit2.Commit).id) + +def compare_and_swap_ref(repo, name, target, expected_old) -> None: + """Update a ref only if it currently points at `expected_old`.""" + current = read_ref(repo, name) + if current != expected_old: + raise RefUpdateConflict(f"{name}: expected {expected_old}, found {current}") + ... +``` + +`compare_and_swap_ref` is the same idea as `git push --force-with-lease`: read +the current target, only move the ref if it's still what you last saw. Every +write to the store (or a variant branch) goes through this, so two processes +racing to update the same ref fail loudly instead of silently clobbering each +other. The caller (e.g. `store fetch`, see below) decides how to recover. + +**Custom refs must be synced manually.** The default fetch/push refspec +(`+refs/heads/*:refs/remotes/origin/*`) only mirrors branches, so +`refs/feature/store` is invisible to a plain `git push` / `git fetch` unless +something tells git about it explicitly. This project does that in two ways: + +1. `git feature store push` / `store fetch` move the ref by name directly, via + the same push/fetch APIs git itself uses: + + ```python + # git_feature/commands/store.py + remote.push([f"{STORE_REF}:{STORE_REF}"], callbacks=status) + ... + remote.fetch([f"+{STORE_REF}:{incoming_ref(remote_name)}"]) + ``` + + Fetch lands the remote's copy on a side ref + (`refs/feature/incoming/`) rather than overwriting the local store + outright, so a diverged store can be three-way merged before anything is + swapped in (`store/merge.py`). The fetch side only ever fast-forwards or + merges the *local* `refs/feature/store` after that's decided. + +2. `init` additionally registers a standing fetch refspec on the remote + config, so a plain `git fetch` also keeps a local mirror of the remote's + store current, the same way `refs/remotes/origin/*` stays current: + + ```python + # git_feature/commands/init.py + refspec = f"+{STORE_REF}:{incoming_ref(remote.name)}" + repo.remotes.add_fetch(remote.name, refspec) + ``` + +Because it's just a ref, walking away from the tool is just deleting one: +`git update-ref -d refs/feature/store` plus removing the hook files below +leaves zero trace in the actual commit history. + +## Git hooks + +Hooks are scripts git invokes at specific points in its own workflow, such as +before or after a commit, rebase, push, or merge. They live as plain +executable files under `.git/hooks/` (no extension, no +registration; git just checks whether the file exists and is executable, +then runs it with hook-specific arguments/stdin). Nothing about hooks is +versioned or cloned: `.git/hooks` is local per-clone state, so a fresh `git +clone` or a teammate's machine starts with none of your hooks installed. + +That local-only nature is a real design constraint here, not a footnote: it's +exactly why this project can't rely on hooks alone to track identity, and +needs `git feature reconcile` as a fallback for commits hooks never saw +(imported patches, cherry-picks made on another clone, or a repo where hooks +were never installed). + +Two hooks are used: + +- **`post-commit`**: fires after a commit object is created and `HEAD` moves. + No arguments; you inspect the new commit via `HEAD` yourself. Used here to + mint a change-id for the commit that was just made. +- **`post-rewrite`**: fires after a command that replaces commits with new + ones (`commit --amend`, `rebase`; notably *not* `reset`). Git feeds it + ` ` pairs, one per line, on **stdin**. Used here to carry + a commit's change-id forward from its old sha to its new one, so metadata + keyed by change-id survives the rewrite untouched. + +```python +# git_feature/commands/hook_run.py +def _post_commit(repo, store, cid_map) -> None: + sha = str(repo.head.target) + _, minted = cid_map.get_or_mint(sha, patch_id=patch_id(repo, sha)) + if minted: + store.commit(f"mint change-id for {sha[:12]}") + +def _post_rewrite(repo, store, cid_map) -> None: + migrated = 0 + for line in sys.stdin: # git writes " \n" per rewritten commit + old_sha, new_sha = line.split()[:2] + if cid_map.migrate(old_sha, new_sha, patch_id=patch_id(repo, new_sha)): + migrated += 1 + if migrated: + store.commit(f"migrate {migrated} change-ids after rewrite") +``` + +You can trigger `post-rewrite` by hand to see the contract for yourself: + +```sh +printf 'old-sha new-sha\n' | .git/hooks/post-rewrite amend +``` + +**Installation doesn't clobber.** A repo might already have its own +`post-commit` hook (from another tool, or the team's own convention), so +naively overwriting `.git/hooks/post-commit` would silently destroy it. This +project installs a small **chained dispatcher** as the actual hook file +instead: it runs every executable in a companion `post-commit.d/` directory, +in order, feeding each the same stdin the real hook received. Any hook that +existed before `git feature init` is preserved as `post-commit.d/00-preexisting` +so it still runs; this project's own logic lives alongside it as +`post-commit.d/50-git-feature`: + +```python +# git_feature/gitio/hooks.py +_DISPATCHER = """#!/bin/sh +# Runs every executable in .d/, feeding each the hook's stdin. +hook_name=$(basename "$0") +hook_dir="$(dirname "$0")/$hook_name.d" +... +for hook in "$hook_dir"/*; do + [ -x "$hook" ] || continue + "$hook" "$@" <"$stdin_file" || status=$? +done +exit $status +""" +``` + +`post-commit.d/50-git-feature` itself is a tiny shim that calls back into the +same `git-feature` binary from the [git-command binaries](#extending-git-with-git-command-binaries) +section, with the binary's absolute path baked in so an unrelated +`git-feature` elsewhere on `PATH` can never be invoked by mistake: + +```sh +"$GIT_FEATURE_BIN" hook-run post-commit || echo "git-feature: post-commit hook failed (ignored)" >&2 +``` + +That final `|| echo ... (ignored)` matters too: hooks must never block the +user's actual `git commit`/`git rebase`. `hook_run.py`'s `run()` wraps +everything in a catch-all and reports failures to stderr rather than raising. +Losing the mint/migrate step is recoverable (`reconcile`); breaking someone's +commit is not. + +You can check what's currently installed at any time: + +```sh +cat .git/hooks/post-commit # the dispatcher +ls .git/hooks/post-commit.d/ # 00-preexisting (if any), 50-git-feature +git feature doctor # reports whether both hooks are installed and chained +``` + +# Feature modeling and variant derivation + +Everything above is about storing and preserving metadata. This section is +about what that metadata is *for*: describing which features exist and what +combinations of them are legal (the model), tying code to those features (the +annotation), and turning a chosen combination into an actual reduced codebase +(derivation). + +## The feature model: a Clafer subset + +[Clafer](https://www.clafer.org/) is a modeling language purpose-built for +describing product-line feature models: a tree of features, cardinalities +saying how many of a group can be active together, boolean constraints +between features, and named configurations. Full Clafer is much bigger than +that (references, arithmetic, quantifiers, structural joins). This project +only ever needed the feature-modeling core, so `model.cfr`'s grammar +(`git_feature/domain/variability/clafer.lark`) is a deliberately small +subset, and says so up front: + +``` +// Clafer feature-modeling subset: feature tree, cardinalities, group +// cardinalities, abstract models + instances, boolean constraints. +// Anything beyond this (enums, references, arithmetic, quantifiers, joins) +// is deliberately not in the grammar and fails to parse. +``` + +That restraint is the point: a smaller grammar is easier to keep correct and +port later, and nothing beyond "which features exist, how they relate, which +combinations are valid" is needed to annotate code and derive variants. + +A `model.cfr` has two kinds of top-level declarations. First, **clafers**, +the feature tree itself, usually rooted in one `abstract` block: + +``` +abstract Demo { + core + auth ? +} +``` + +Each child has a **cardinality**: no marker means mandatory (must be on +whenever its parent is), `?` means optional (0 or 1), `+` means one-or-more, +`*` zero-or-more, and a **group cardinality** keyword placed before a set of +children (`xor`, `or`, `mux`, `opt`, or an explicit `m..n` range) constrains +how many of *those* children can be active together. For example, an `xor` +group means exactly one child may be selected. + +Second, **instances**: named, concrete configurations of an abstract tree, +with `[ ... ]` constraints selecting or excluding specific features. These +*are* the variant specifications the derivation pipeline reads by name: + +``` +Minimal : Demo { + [ no auth ] +} + +Full : Demo { + [ auth ] +} +``` + +Constraint expressions support `!`/`no`/`not`, `&&`, `||`, `=>`, `<=>`, and +the `xor` keyword between two expressions. Because a model can leave some +features undetermined until constraints are propagated, resolution is +**three-valued** (`True | False | None`) rather than plain boolean. +`git_feature/domain/variability/evaluator.py` propagates mandatory children, +group-cardinality bounds, and every constraint to a fixed point, and only +then collapses whatever is still unknown to excluded. Anything that can't be +made consistent (a violated constraint, a feature required both on and off, +a broken group cardinality) is rejected with the specific reason, never +silently guessed: + +```sh +git feature model validate # model well-formed? annotations reference real features? +git feature model validate Minimal +``` + +```text +instance Minimal: legal configuration + included: core + excluded: auth +``` + +## Presence conditions and region anchors + +A **presence condition** is the boolean expression attached to an annotated +region, e.g. `auth`, or `payments & premium`. It reuses feature names from +`model.cfr`, but is parsed by a smaller, separate grammar +(`domain/variability/presence.py`): just `name`, `!`, `&`/`&&`, `|`/`||`, and +parentheses. No `=>`/`<=>`/`xor`. A presence condition only ever needs to say +"on/off combinations", never the structural relationships a model expresses, +so the smaller language is a deliberate match to the smaller job. + +Presence conditions are attached to **regions**, not line numbers. A region +is a hunk *anchored to the immutable diff of the change that introduced it*. +The actual persisted record (captured by running `git feature annotate` on a +real commit) looks like this: + +```json +{ + "anchor": { + "change_id": "01KY1TCY6275J5K9Q03H7P8EGS", + "path": "app.py", + "old_span": [0, 0], + "new_span": [1, 2], + "fingerprint": "7fa8eae0365e947e" + }, + "presence": "core", + "author": "Tester", + "ts": "2026-07-21T07:49:13.026296Z", + "note": null +} +``` + +The anchor's `change_id` is exactly the stable identity from the identity +layer (survives rebase/amend, see the git-hooks section above); `old_span`/ +`new_span` are the hunk's position *in that one historical diff*; `fingerprint` +is a whitespace-insensitive hash of the added lines. None of that ever needs +updating: it describes a historical fact, and history doesn't change. + +"Where does this region live *right now*?" is answered on demand by +`RegionResolver` (`git_feature/identity/region.py`), in tiers, and it always +reports a confidence rather than guessing silently: + +```python +def resolve(self, anchor: RegionAnchor, target) -> Resolution: + return ( + self._blame_forward(anchor, commit) # exact | moved + or self._fingerprint_search(anchor, commit) # fuzzy + or Resolution(None, "lost") + ) +``` + +1. **Blame-forward**: blame the target file, translate each line's + originating commit to a change-id, and take the lines whose change-id + matches the anchor. If the exact fingerprinted window is found among + them, confidence is `exact`; if the change's lines are present but + shuffled/partial, `moved`. +2. **Fingerprint / similarity search**: if blame finds nothing (heavy + rename, squash, reformat), search file contents for a window matching the + anchor's normalized fingerprint, falling back to best-effort text + similarity. Confidence `fuzzy`. +3. Otherwise: `lost`. + +Every consumer (queries, derivation) surfaces this confidence instead of +hiding it. `git feature blame` is the direct view of the resolver's answer, +line by line: + +```sh +git feature blame app.py +``` + +```text + 1 core def hello(): + 2 core print("hi") + 3 auth + 4 auth def auth(): + 5 auth return "token" +``` + +## Variant derivation + +A **variant** is "this codebase, minus every region whose presence condition +evaluates to false under a chosen configuration": a disposable, derived +branch (`refs/heads/variant/`), never the codebase's real branches. +Derivation is a pipeline of four stages (`domain/derivation/pipeline.py` + +`materialize.py`), each with a checkable contract; `--dry-run` runs the first +two and just prints the report. + +**1. Configure**: resolve the requested instance against `model.cfr` **read +from the base commit's tree**, not the working directory, so deriving from an +old commit uses the model as it was back then: + +```python +def configure(repo, instance: str, base_rev: str) -> tuple[dict[str, bool], str]: + base_sha = str(resolve_commit(repo, base_rev).id) + text = model_text(repo, base_sha) # model.cfr from that commit's tree + module = parse_model(text) + assignment = Evaluator(module).resolve_assignment(instance) + return assignment, base_sha +``` + +Output: a total `feature → bool` assignment. An illegal instance is rejected +here, before anything else is touched. + +**2. Project**: for every stored annotation whose change is part of the base +commit's history, evaluate its presence condition under the assignment to get +included/excluded, and resolve the region against the base commit with +`RegionResolver`. Then run consistency checks that turn contradictions into +named `Conflict`s instead of resolving them silently: **overlap** (an +included and an excluded region cover the same lines), **dangling +dependency** (an included region sits inside a region that's about to be +removed), **lost region** (a region that must be removed can't be located, +confidence `lost`). `--dry-run` stops here: + +```sh +git feature checkout Minimal --dry-run +``` + +```text +variant Minimal from 0df2ee6707e6 (dry run) +disabled features: auth +would remove: + app.py: lines 3-5 [auth] (exact) +kept regions: 1 +no conflicts +``` + +**3. Materialize**: build the reduced tree *in memory*: for every file with +removed regions, splice the disabled line runs out byte-exact +(`materialize.splice_out`); every untouched file keeps its original blob oid +(shared, not copied: the object-storage dedup from earlier applies for +free). Commit the result onto `refs/heads/variant/`. + +**4. Verify**: before the branch ref is allowed to move, re-check the +*derived tree by content*: recover each region's original salient lines (from +the frozen diff, not from trusting the resolver) and confirm a kept region's +content survived and a removed region's content is actually gone. A mismatch +aborts, and the ref never moves, so a bad derivation never becomes visible: + +```sh +git feature checkout Minimal +``` + +```text +variant Minimal: refs/heads/variant/Minimal -> 50b3c06f3faa (1 region(s) removed, verified) +switched to variant/Minimal +``` + +```sh +cat app.py # the auth block is simply gone, no markers, valid code +``` + +## Round-trip editing: `view` and `putback` + +A plain `checkout` variant is meant to be *read*, not committed to. It's a +disposable projection, and commits made on it would be stranded the moment +the variant is rebuilt. `view` is the editable counterpart: the exact same +derivation pipeline, plus a recorded session that `putback` later uses to map +edits back to where they really belong on the source branch. + +```sh +git feature view Minimal +``` + +```text +view of Minimal: editing 7e723e87f0de (from auth-work @ 43478d8d3392) — put edits back with 'git feature putback' +switched to variant/Minimal +``` + +`view.py` runs `configure` → `project` → `materialize_variant`, stages 1-4 +from the pipeline above, unchanged, and then persists one extra record, a +`ViewSession`, to the store: + +```python +class ViewSession(BaseModel): + instance: str + source_branch: str # putback's target + base_commit: str # source commit the view was derived from + variant_commit: str # the materialized variant tree + manifest: DerivationManifest # exactly what checkout would have produced + created_at: datetime +``` + +The manifest is the part that matters: it already records, per file, which +1-based line runs of the *source* were removed to produce the *view*. That's +enough information to translate a position in one file into a position in +the other, in both directions: the same relationship a lens's `get`/`put` +maintain between a whole structure and a projected view of it. + +**Editing the view** works like editing any checked-out branch: the reduced +file is just sitting in the working tree: + +```sh +cat -n app.py +``` + +```text +1 def hello(): +2 print("hi") +3 +4 def goodbye(): +5 print("bye") +``` + +(The `auth` block that was on lines 3-5 of the *source* is gone; what was +source line 8 is now view line 5.) Edit it as if the hidden feature didn't +exist: + +```sh +sed -i 's/print("bye")/print("bye for now")/' app.py +git feature putback --dry-run +``` + +```text +would apply to auth-work @ 43478d8d3392: + app.py: view lines 5 -> source lines 8 +``` + +That `5 -> 8` is the whole point of `putback`. `domain/derivation/putback.py` +diffs the working tree against the unedited `variant_commit` to get the +edit's hunks in *view* coordinates, then remaps each hunk through the +manifest's removed runs: + +```python +# kept[k-1] = the base (source) line number that appears as view line k +removed: set[int] = set() +for start, count in runs: + removed.update(range(start, start + count)) +kept = [number for number in range(1, len(base_lines) + 1) if number not in removed] +... +positions = kept[old_start - 1 : old_start - 1 + old_count] +``` + +`kept` is the view-to-source line mapping implied by the manifest. Build it +once per file, then every hunk's view-space span is just an index into it. +An edit below a 3-line removed block lands 3 lines lower in the source, which +is exactly what happened above (view line 5 → source line 8). If the source +and view positions happen to coincide (an edit above every removed region, +or a variant with nothing removed from that file), the mapping is a no-op. + +Applying it plays out as a normal commit on the *source* branch: the full, +unreduced tree, with the mapped edit spliced in: + +```sh +git feature putback -m "friendlier goodbye" +``` + +```text +auth-work -> a836107bf02c (1 file(s) transplanted) +view of Minimal refreshed onto the new source commit +``` + +```sh +git show auth-work:app.py +``` + +```text +def hello(): + print("hi") + +def auth(): + return "token" + +def goodbye(): + print("bye for now") +``` + +The `auth` block, never visible in the view, is untouched, and the edit +landed in the right place in the full file. Mechanically: `rewrite_tree` +splices the mapped replacement into the *base* commit's tree (not the +view's), a normal commit is created on `source_ref` with `compare_and_swap_ref` +guarding the move, and the new commit is minted a change-id immediately. +From here on it's an ordinary commit, annotatable like any other. `putback` +then re-runs the same three pipeline stages on top of that new commit and +rewrites the `ViewSession` in place (`_refresh_view`), so the view stays open +against the branch's new tip and the edit/putback loop can repeat. + +**Refusing instead of guessing.** Several situations abort the whole putback +with nothing written, rather than applying a partial or wrong result: + +- an edit whose hunk doesn't fit inside one contiguous run of `kept` lines, + meaning it **crosses a removed region**, is rejected as a `removed-region` + conflict (there's no single source position that edit could map to); +- the changed file wasn't a plain edit (`status != "M"`, e.g. deletion, + binary, mode change): `unsupported-change`; +- the **source branch moved** since the view was opened (`source_tip != + session.base_commit`): putback would otherwise silently discard whatever + happened on the source branch meanwhile, so it asks for `git feature view + ` to be re-opened instead; +- putback is attempted from **anywhere other than an open view's branch**, or + the view's session and the branch's actual tip have drifted apart. + +`--dry-run` always reports the mapped plan (or the conflicts) without +touching anything, exactly like `checkout --dry-run` does for the derivation +itself. + +## Beyond this document + +`git feature sync` builds on the same identity layer covered earlier: it can +"propagate a feature's missing commits from one branch to another, +recognizing what's already present by change-id rather than SHA," but +doesn't touch the model/derivation machinery from this section. `--help` on +it (and on any other command) is a reasonable next stop. diff --git a/git_feature/__init__.py b/git_feature/__init__.py new file mode 100644 index 0000000..ded1328 --- /dev/null +++ b/git_feature/__init__.py @@ -0,0 +1 @@ +"""git-feature: feature-oriented version control on top of git.""" diff --git a/git_feature/cli.py b/git_feature/cli.py new file mode 100644 index 0000000..db21dd9 --- /dev/null +++ b/git_feature/cli.py @@ -0,0 +1,300 @@ +"""Command-line interface for git-feature.""" + +from enum import StrEnum +from importlib import metadata +from typing import Annotated + +import typer + +from .commands import annotate as annotate_cmd +from .commands import blame as blame_cmd +from .commands import checkout as checkout_cmd +from .commands import coverage as coverage_cmd +from .commands import doctor as doctor_cmd +from .commands import hook_run as hook_run_cmd +from .commands import info as info_cmd +from .commands import init as init_cmd +from .commands import list as list_cmd +from .commands import model as model_cmd +from .commands import putback as putback_cmd +from .commands import reconcile as reconcile_cmd +from .commands import status as status_cmd +from .commands import store as store_cmd +from .commands import sync as sync_cmd +from .commands import variant as variant_cmd +from .commands import view as view_cmd +from .commands import whatfeature as whatfeature_cmd + +app = typer.Typer(name="git-feature", no_args_is_help=True) + + +def _version(value: bool) -> None: + if value: + typer.echo(metadata.version("git-feature")) + raise typer.Exit() + + +@app.callback() +def main( + ctx: typer.Context, + as_json: Annotated[ + bool, typer.Option("--json", help="Emit machine-readable JSON output.") + ] = False, + version: Annotated[ + bool, + typer.Option("--version", callback=_version, is_eager=True, help="Show version and exit."), + ] = False, +) -> None: + """Feature-oriented version control on top of git.""" + ctx.obj = {"json": as_json} + + +@app.command() +def init( + ctx: typer.Context, + backfill: Annotated[ + bool, + typer.Option(help="Mint change-ids for all existing history upfront (default: lazy)."), + ] = False, + hooks: Annotated[bool, typer.Option(help="Install chained git hooks.")] = True, +) -> None: + """Initialize the feature store, hooks, and a starter model.cfr.""" + init_cmd.run(ctx, backfill=backfill, hooks=hooks) + + +@app.command() +def annotate( + ctx: typer.Context, + rev: Annotated[str | None, typer.Argument(help="Commit to annotate (default HEAD).")] = None, + features: Annotated[ + list[str] | None, typer.Option("--feature", help="Feature name (repeatable).") + ] = None, + presence: Annotated[ + str | None, typer.Option(help="Presence condition expression, e.g. 'a & !b'.") + ] = None, + paths: Annotated[ + list[str] | None, + typer.Option("--path", help="Pathspec glob to restrict hunks (repeatable)."), + ] = None, + rev_range: Annotated[ + str | None, typer.Option("--range", help="Annotate every commit in range A..B.") + ] = None, +) -> None: + """Associate diff hunks with features.""" + annotate_cmd.run( + ctx, + rev=rev, + features=tuple(features or ()), + presence=presence, + paths=tuple(paths or ()), + rev_range=rev_range, + ) + + +@app.command(name="list") +def list_(ctx: typer.Context) -> None: + """List the feature catalog with annotation counts.""" + list_cmd.run(ctx) + + +@app.command() +def info( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Feature name.")], + files: Annotated[bool, typer.Option("--files", help="List files the feature touches.")] = False, + commits: Annotated[bool, typer.Option("--commits", help="List the feature's commits.")] = False, + authors: Annotated[bool, typer.Option("--authors", help="List contributing authors.")] = False, + branches: Annotated[ + bool, typer.Option("--branches", help="Cross-branch presence view.") + ] = False, +) -> None: + """Show detailed information about a feature.""" + info_cmd.run(ctx, name=name, files=files, commits=commits, authors=authors, branches=branches) + + +@app.command() +def status(ctx: typer.Context) -> None: + """Show working-directory and staged changes grouped by feature.""" + status_cmd.run(ctx) + + +@app.command() +def blame( + ctx: typer.Context, + file: Annotated[str, typer.Argument(help="File to inspect.")], + line: Annotated[int | None, typer.Option(help="Show only this line number.")] = None, +) -> None: + """Show per-line feature presence conditions for a file.""" + blame_cmd.run(ctx, file=file, line=line) + + +@app.command() +def whatfeature( + ctx: typer.Context, + rev: Annotated[str, typer.Argument(help="Commit to inspect.")], +) -> None: + """Show which features a commit touches.""" + whatfeature_cmd.run(ctx, rev=rev) + + +@app.command() +def coverage( + ctx: typer.Context, + rev_range: Annotated[ + str | None, typer.Argument(help="Range A..B (default: all reachable commits).") + ] = None, + verbose: Annotated[bool, typer.Option("--verbose", help="Per-commit listing.")] = False, +) -> None: + """Report annotated vs unannotated changes over a range.""" + coverage_cmd.run(ctx, rev_range=rev_range, verbose=verbose) + + +model_app = typer.Typer(no_args_is_help=True) +app.add_typer(model_app, name="model", help="Feature model (model.cfr) operations.") + + +@model_app.command() +def validate( + ctx: typer.Context, + instance: Annotated[ + str | None, typer.Argument(help="Variant instance to check (default: whole model).") + ] = None, +) -> None: + """Validate the model, annotations, or a specific instance/configuration.""" + model_cmd.run(ctx, instance=instance) + + +@app.command() +def checkout( + ctx: typer.Context, + instance: Annotated[str, typer.Argument(help="Variant instance from model.cfr.")], + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Report the variant plan without materializing.") + ] = False, + refresh: Annotated[ + bool, typer.Option("--refresh", help="Update an existing variant branch.") + ] = False, + base: Annotated[ + str | None, typer.Option(help="Base revision to build the variant from (default HEAD).") + ] = None, + no_switch: Annotated[ + bool, + typer.Option("--no-switch", help="Create the variant branch without switching to it."), + ] = False, + verify_only: Annotated[ + bool, typer.Option("--verify-only", help="Re-verify an existing variant, don't rebuild.") + ] = False, +) -> None: + """Check out a product variant for a model instance.""" + checkout_cmd.run( + ctx, + instance=instance, + dry_run=dry_run, + refresh=refresh, + base=base, + no_switch=no_switch, + verify_only=verify_only, + ) + + +variant_app = typer.Typer(no_args_is_help=True) +app.add_typer(variant_app, name="variant", help="Manage derived variants.") + +store_app = typer.Typer(no_args_is_help=True) +app.add_typer(store_app, name="store", help="Share the feature store with remotes.") + + +@store_app.command(name="push") +def store_push( + ctx: typer.Context, + remote: Annotated[str, typer.Argument(help="Remote to push the store to.")] = "origin", +) -> None: + """Push the feature store ref to a remote.""" + store_cmd.run_push(ctx, remote_name=remote) + + +@store_app.command(name="fetch") +def store_fetch( + ctx: typer.Context, + remote: Annotated[str, typer.Argument(help="Remote to fetch the store from.")] = "origin", +) -> None: + """Fetch a remote's feature store and merge it if the stores diverged.""" + store_cmd.run_fetch(ctx, remote_name=remote) + + +@variant_app.command(name="list") +def variant_list(ctx: typer.Context) -> None: + """List derived variants, their base commit, configuration, and staleness.""" + variant_cmd.run(ctx) + + +@app.command() +def view( + ctx: typer.Context, + instance: Annotated[str, typer.Argument(help="Variant instance from model.cfr.")], +) -> None: + """Check out a variant as an editable view (edits go back with putback).""" + view_cmd.run(ctx, instance=instance) + + +@app.command() +def putback( + ctx: typer.Context, + message: Annotated[ + str | None, typer.Option("--message", "-m", help="Commit message for the source commit.") + ] = None, + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Report the mapped edits without committing.") + ] = False, +) -> None: + """Transplant edits from a variant view back to the source branch.""" + putback_cmd.run(ctx, dry_run=dry_run, message=message) + + +@app.command() +def sync( + ctx: typer.Context, + source: Annotated[str, typer.Argument(help="Branch to propagate commits from.")], + feature: Annotated[ + str | None, typer.Option("--feature", "-f", help="Restrict propagation to one feature.") + ] = None, + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Report what would be cherry-picked.") + ] = False, +) -> None: + """Propagate a feature's missing commits from SOURCE onto the current branch.""" + sync_cmd.run(ctx, source=source, feature=feature, dry_run=dry_run) + + +@app.command() +def reconcile( + ctx: typer.Context, + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Report planned links without writing.") + ] = False, + interactive: Annotated[ + bool, typer.Option("--interactive", help="Resolve ambiguous matches interactively.") + ] = False, +) -> None: + """Re-link commits that arrived outside the hooks (cherry-picks, imports).""" + reconcile_cmd.run(ctx, dry_run=dry_run, interactive=interactive) + + +@app.command() +def doctor(ctx: typer.Context) -> None: + """Health-check the store, hooks, and change-id map.""" + doctor_cmd.run(ctx) + + +class HookName(StrEnum): + POST_COMMIT = "post-commit" + POST_REWRITE = "post-rewrite" + + +@app.command(name="hook-run", hidden=True) +def hook_run( + ctx: typer.Context, + hook: Annotated[HookName, typer.Argument(help="Hook being dispatched.")], +) -> None: + """Internal: entry point invoked by the installed git hooks.""" + hook_run_cmd.run(ctx, hook=hook.value) diff --git a/git_feature/commands/__init__.py b/git_feature/commands/__init__.py new file mode 100644 index 0000000..4636fd3 --- /dev/null +++ b/git_feature/commands/__init__.py @@ -0,0 +1 @@ +"""Command implementations, one module per CLI subcommand.""" diff --git a/git_feature/commands/annotate.py b/git_feature/commands/annotate.py new file mode 100644 index 0000000..6a355c1 --- /dev/null +++ b/git_feature/commands/annotate.py @@ -0,0 +1,72 @@ +"""`git feature annotate` — associate diff hunks with features.""" + +import json + +import typer + +from ..domain.annotate.capture import ( + CaptureResult, + annotate_commit, + ensure_features, + signature_name, +) +from ..gitio import iter_range +from .common import json_mode, require_repo, require_store + + +def run( + ctx: typer.Context, + *, + rev: str | None, + features: tuple[str, ...], + presence: str | None, + paths: tuple[str, ...], + rev_range: str | None, +) -> None: + if features and presence: + typer.echo("error: use either --feature or --presence, not both", err=True) + raise typer.Exit(2) + if rev and rev_range: + typer.echo("error: use either a rev argument or --range, not both", err=True) + raise typer.Exit(2) + if not features and not presence: + typer.echo("error: pass --feature or --presence", err=True) + raise typer.Exit(2) + + repo = require_repo() + store = require_store(repo) + presences = [presence] if presence else list(features) + result = CaptureResult() + ensure_features(store, list(features), result) + author = signature_name(repo) + revs = list(iter_range(repo, rev_range)) if rev_range else [rev or "HEAD"] + for commit in revs: + annotate_commit(repo, store, commit, presences, paths, author, result) + + if result.written or result.created_features: + store.commit(f"annotate {result.written} regions") + _report(ctx, result) + + +def _report(ctx: typer.Context, result: CaptureResult) -> None: + if json_mode(ctx): + typer.echo( + json.dumps( + { + "written": result.written, + "duplicates": result.duplicates, + "annotated_commits": result.annotated_commits, + "minted_change_ids": result.minted_change_ids, + "created_features": result.created_features, + } + ) + ) + return + typer.echo( + f"annotated {result.written} region(s) across {result.annotated_commits} commit(s)" + + (f", {result.duplicates} duplicate(s) skipped" if result.duplicates else "") + ) + if result.minted_change_ids: + typer.echo(f"minted {result.minted_change_ids} change-id(s) for older commits") + for name in result.created_features: + typer.echo(f"created feature '{name}' (auto)") diff --git a/git_feature/commands/blame.py b/git_feature/commands/blame.py new file mode 100644 index 0000000..1b55482 --- /dev/null +++ b/git_feature/commands/blame.py @@ -0,0 +1,51 @@ +"""`git feature blame` — per-line feature presence conditions for a file.""" + +import json + +import typer + +from ..domain.query.catalog import iter_annotations +from ..gitio import blob_lines +from ..identity import ChangeIdMap, RegionResolver +from .common import json_mode, require_repo, require_store + + +def run(ctx: typer.Context, *, file: str, line: int | None) -> None: + repo = require_repo() + store = require_store(repo) + + lines = blob_lines(repo, "HEAD", file) + if lines is None: + typer.echo(f"error: '{file}' not found at HEAD (or binary)", err=True) + raise typer.Exit(1) + + conditions: dict[int, list[str]] = {} + resolver = RegionResolver(repo, ChangeIdMap(store)) + for _, annotation in iter_annotations(store): + if annotation.anchor.path != file: + continue + resolution = resolver.resolve(annotation.anchor, "HEAD") + for start, count in resolution.line_runs(): + for number in range(start, start + count): + conditions.setdefault(number, []).append(annotation.presence) + + rows = [ + { + "line": number, + "presence": " , ".join(sorted(set(conditions.get(number, [])))) or None, + "content": text, + } + for number, text in enumerate(lines, start=1) + if line is None or number == line + ] + if line is not None and not rows: + typer.echo(f"error: line {line} is out of range (file has {len(lines)} lines)", err=True) + raise typer.Exit(1) + + if json_mode(ctx): + typer.echo(json.dumps(rows)) + return + width = max((len(str(row["presence"] or "-")) for row in rows), default=1) + for row in rows: + presence = str(row["presence"] or "-") + typer.echo(f"{row['line']:>4} {presence:<{width}} {row['content']}") diff --git a/git_feature/commands/checkout.py b/git_feature/commands/checkout.py new file mode 100644 index 0000000..d07b2d0 --- /dev/null +++ b/git_feature/commands/checkout.py @@ -0,0 +1,173 @@ +"""`git feature checkout` — check out a product variant derived from the feature model.""" + +import json +from collections import defaultdict + +import pygit2 +import typer + +from ..domain.derivation.materialize import verify_tree +from ..domain.derivation.pipeline import DerivationError, Projection, configure, project +from ..domain.derivation.service import ( + VariantExists, + VerificationFailed, + materialize_variant, + variant_ref, +) +from ..gitio import read_ref, resolve_commit +from ..store import GitRefStore +from .common import json_mode, require_repo, require_store, switch_branch + + +def run( + ctx: typer.Context, + *, + instance: str, + dry_run: bool, + refresh: bool, + base: str | None, + no_switch: bool, + verify_only: bool, +) -> None: + repo = require_repo() + store = require_store(repo) + + if verify_only: + _verify_only(ctx, repo, store, instance) + return + + try: + assignment, base_sha = configure(repo, instance, base or _default_base(repo, store)) + except DerivationError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + projection = project(repo, store, instance, assignment, base_sha) + + if dry_run: + _report(ctx, projection, dry_run=True) + if projection.conflicts: + raise typer.Exit(1) + return + + if projection.conflicts: + _report(ctx, projection, dry_run=False) + raise typer.Exit(1) + + refname = variant_ref(instance) + try: + derivation = materialize_variant(repo, store, projection, refresh=refresh) + except VariantExists: + typer.echo( + f"error: variant {instance} already exists — use --refresh to rebuild it", + err=True, + ) + raise typer.Exit(1) from None + except VerificationFailed as exc: + for conflict in exc.conflicts: + typer.echo(f"!! {conflict.kind}: {conflict.detail}", err=True) + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + + if derivation.up_to_date: + typer.echo(f"variant {instance} is up to date at {derivation.sha[:12]}") + if not no_switch: + switch_branch(repo, refname) + return + + if json_mode(ctx): + payload = derivation.manifest.model_dump(mode="json") + payload["variant_commit"] = derivation.sha + typer.echo(json.dumps(payload)) + else: + removed = len(projection.removed) + typer.echo( + f"variant {instance}: {refname} -> {derivation.sha[:12]}" + f" ({removed} region(s) removed, verified)" + ) + if not no_switch: + switch_branch(repo, refname) + + +def _default_base(repo: pygit2.Repository, store: GitRefStore) -> str: + """HEAD — unless HEAD is a variant branch, whose recorded base is the real source.""" + if not repo.head_is_unborn and repo.head.shorthand.startswith("variant/"): + current = repo.head.shorthand.removeprefix("variant/") + manifest = store.read_manifest(current) + if manifest is not None: + typer.echo(f"on {repo.head.shorthand}: deriving from its base commit") + return manifest.base_commit + return "HEAD" + + +def _verify_only( + ctx: typer.Context, repo: pygit2.Repository, store: GitRefStore, instance: str +) -> None: + manifest = store.read_manifest(instance) + refname = variant_ref(instance) + tip = read_ref(repo, refname) + if manifest is None or tip is None: + typer.echo(f"error: variant {instance} has never been derived", err=True) + raise typer.Exit(1) + tip_commit = resolve_commit(repo, tip) + conflicts = verify_tree(repo, store, tip_commit, manifest.base_commit, manifest.decisions) + if json_mode(ctx): + typer.echo( + json.dumps( + { + "instance": instance, + "verified": not conflicts, + "problems": [c.detail for c in conflicts], + } + ) + ) + else: + for conflict in conflicts: + typer.echo(f"!! {conflict.detail}") + typer.echo( + f"variant {instance}: " + ("verified ok" if not conflicts else "verification FAILED") + ) + if conflicts: + raise typer.Exit(1) + + +def _report(ctx: typer.Context, projection: Projection, *, dry_run: bool) -> None: + if json_mode(ctx): + payload = projection.manifest().model_dump(mode="json") + payload["conflicts"] = [ + {"kind": c.kind, "detail": c.detail, "path": c.path} for c in projection.conflicts + ] + typer.echo(json.dumps(payload)) + return + label = "dry run" if dry_run else "plan" + typer.echo(f"variant {projection.instance} from {projection.base_sha[:12]} ({label})") + disabled = sorted(name for name, on in projection.assignment.items() if not on) + typer.echo(f"disabled features: {', '.join(disabled) or '(none)'}") + + removals = defaultdict(list) + for decision in projection.removed: + removals[decision.location()].append(decision) + if removals: + typer.echo("would remove:") + for path in sorted(removals): + for decision in removals[path]: + runs = decision.runs() + lines = ( + ", ".join(f"lines {start}-{start + max(count, 1) - 1}" for start, count in runs) + if runs + else "unlocated" + ) + origin = f", anchored at {decision.anchor.path}" if decision.resolved_path else "" + typer.echo( + f" {path}: {lines} [{decision.presence}] ({decision.confidence}{origin})" + ) + else: + typer.echo("would remove: nothing") + kept = len(projection.decisions) - len(projection.removed) + typer.echo(f"kept regions: {kept}") + + for conflict in projection.conflicts: + typer.echo(f"!! {conflict.kind}: {conflict.detail}") + if projection.conflicts: + typer.echo(f"{len(projection.conflicts)} conflict(s) — variant cannot be materialized") + else: + typer.echo("no conflicts") diff --git a/git_feature/commands/common.py b/git_feature/commands/common.py new file mode 100644 index 0000000..40139b3 --- /dev/null +++ b/git_feature/commands/common.py @@ -0,0 +1,39 @@ +"""Shared plumbing for command implementations.""" + +import pygit2 +import typer + +from ..gitio import RepositoryNotFound, checkout_branch, open_repository +from ..store import GitRefStore + + +def require_repo() -> pygit2.Repository: + """Open the repository from cwd or exit with an error.""" + try: + return open_repository() + except RepositoryNotFound as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + + +def require_store(repo: pygit2.Repository) -> GitRefStore: + """Open the feature store or exit if the tool was never initialized.""" + store = GitRefStore(repo) + if store.read_meta() is None: + typer.echo("error: not initialized (run 'git feature init')", err=True) + raise typer.Exit(1) + return store + + +def json_mode(ctx: typer.Context) -> bool: + """Whether the global --json flag was given.""" + return bool(ctx.obj and ctx.obj.get("json")) + + +def switch_branch(repo: pygit2.Repository, refname: str, force: bool = False) -> None: + """Switch to a branch, downgrading checkout failures to a warning.""" + try: + checkout_branch(repo, refname, force=force) + typer.echo(f"switched to {refname.removeprefix('refs/heads/')}") + except pygit2.GitError as exc: + typer.echo(f"warning: could not switch ({exc}); branch is ready anyway", err=True) diff --git a/git_feature/commands/coverage.py b/git_feature/commands/coverage.py new file mode 100644 index 0000000..6e383ac --- /dev/null +++ b/git_feature/commands/coverage.py @@ -0,0 +1,57 @@ +"""`git feature coverage` — annotated vs unannotated changes over a range.""" + +import json + +import typer + +from ..gitio import iter_all_commits, iter_range +from ..identity import ChangeIdMap +from .common import json_mode, require_repo, require_store + + +def run(ctx: typer.Context, *, rev_range: str | None, verbose: bool) -> None: + repo = require_repo() + store = require_store(repo) + cid_map = ChangeIdMap(store) + annotated_changes = set(store.list_annotated_changes()) + + try: + commits = iter_range(repo, rev_range) if rev_range else iter_all_commits(repo) + except ValueError as error: + typer.echo(f"error: {error}", err=True) + raise typer.Exit(1) from error + rows = [] + for commit in commits: + sha = str(commit.id) + record = cid_map.get(sha) + annotated = record is not None and record.change_id in annotated_changes + rows.append( + { + "sha": sha, + "subject": commit.message.splitlines()[0] if commit.message else "", + "annotated": annotated, + } + ) + + total = len(rows) + covered = sum(1 for row in rows if row["annotated"]) + unannotated = [str(row["sha"]) for row in rows if not row["annotated"]] + + if json_mode(ctx): + typer.echo( + json.dumps( + { + "total": total, + "annotated": covered, + "unannotated": unannotated, + } + ) + ) + return + + percent = (100 * covered // total) if total else 0 + typer.echo(f"{covered}/{total} commits annotated ({percent}%)") + if verbose: + for row in rows: + marker = "+" if row["annotated"] else "-" + typer.echo(f" {marker} {str(row['sha'])[:12]} {row['subject']}") diff --git a/git_feature/commands/doctor.py b/git_feature/commands/doctor.py new file mode 100644 index 0000000..0137e43 --- /dev/null +++ b/git_feature/commands/doctor.py @@ -0,0 +1,123 @@ +"""`git feature doctor` — health check for store, hooks, and change-id map.""" + +import json +from dataclasses import dataclass + +import pygit2 +import typer + +from ..gitio import RepositoryNotFound, iter_all_commits, open_repository, read_ref +from ..gitio.hooks import hooks_installed +from ..identity import ChangeIdMap +from ..store import STORE_REF, GitRefStore +from ..store.types import SCHEMA_VERSION + + +@dataclass(frozen=True) +class Check: + """One doctor finding: ok, info (fine under lazy minting), or problem.""" + + level: str # "ok" | "info" | "problem" + name: str + detail: str + hint: str | None = None + + +def run(ctx: typer.Context) -> None: + try: + repo = open_repository() + except RepositoryNotFound as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + + checks = _run_checks(repo) + problems = [c for c in checks if c.level == "problem"] + + if ctx.obj.get("json"): + payload = { + "healthy": not problems, + "checks": [ + {"level": c.level, "name": c.name, "detail": c.detail, "hint": c.hint} + for c in checks + ], + } + typer.echo(json.dumps(payload)) + else: + for check in checks: + marker = {"ok": "ok", "info": "--", "problem": "!!"}[check.level] + typer.echo(f"{marker} {check.name}: {check.detail}") + if check.hint and check.level != "ok": + typer.echo(f" hint: {check.hint}") + typer.echo("healthy" if not problems else f"{len(problems)} problem(s) found") + if problems: + raise typer.Exit(1) + + +def _run_checks(repo: pygit2.Repository) -> list[Check]: + checks = [] + if read_ref(repo, STORE_REF) is None: + checks.append( + Check("problem", "store", "refs/feature/store missing", "run 'git feature init'") + ) + return checks + + store = GitRefStore(repo) + meta = store.read_meta() + if meta is None: + checks.append( + Check("problem", "store", "meta.json missing or unreadable", "run 'git feature init'") + ) + return checks + if meta.schema_version > SCHEMA_VERSION: + checks.append( + Check( + "problem", + "store", + f"schema version {meta.schema_version} is newer than supported {SCHEMA_VERSION}", + "upgrade git-feature", + ) + ) + else: + checks.append(Check("ok", "store", f"readable, schema version {meta.schema_version}")) + + for name, installed in hooks_installed(repo).items(): + if installed: + checks.append(Check("ok", f"hook {name}", "installed and chained")) + else: + checks.append( + Check( + "problem", + f"hook {name}", + "missing or not chained", + "run 'git feature init' to reinstall hooks", + ) + ) + + cid_map = ChangeIdMap(store) + mapped = {record.change_id for record in cid_map.iter_records()} + dangling = [cid for cid in store.list_annotated_changes() if cid not in mapped] + if dangling: + checks.append( + Check( + "problem", + "annotations", + f"{len(dangling)} annotation file(s) reference unknown change-ids", + "run 'git feature reconcile'", + ) + ) + else: + checks.append(Check("ok", "annotations", "all reference known change-ids")) + + unmapped = sum(1 for c in iter_all_commits(repo) if cid_map.get(str(c.id)) is None) + if unmapped: + checks.append( + Check( + "info", + "change-id map", + f"{unmapped} commit(s) without change-ids (normal with lazy minting)", + "run 'git feature reconcile' to link imports, or 'init --backfill' to mint all", + ) + ) + else: + checks.append(Check("ok", "change-id map", "every reachable commit is mapped")) + return checks diff --git a/git_feature/commands/hook_run.py b/git_feature/commands/hook_run.py new file mode 100644 index 0000000..8938ef9 --- /dev/null +++ b/git_feature/commands/hook_run.py @@ -0,0 +1,49 @@ +"""`git feature hook-run` — internal entry point invoked by the installed git hooks.""" + +import sys + +import pygit2 +import typer + +from ..gitio import open_repository, patch_id +from ..identity import ChangeIdMap +from ..store import GitRefStore + + +def run(ctx: typer.Context, *, hook: str) -> None: + try: + _dispatch(hook) + except Exception as exc: # hooks must never block the user's git command + typer.echo(f"git-feature: {hook} hook skipped: {exc}", err=True) + + +def _dispatch(hook: str) -> None: + repo = open_repository() + store = GitRefStore(repo) + if store.read_meta() is None: + return + cid_map = ChangeIdMap(store) + if hook == "post-commit": + _post_commit(repo, store, cid_map) + elif hook == "post-rewrite": + _post_rewrite(repo, store, cid_map) + + +def _post_commit(repo: pygit2.Repository, store: GitRefStore, cid_map: ChangeIdMap) -> None: + sha = str(repo.head.target) + _, minted = cid_map.get_or_mint(sha, patch_id=patch_id(repo, sha)) + if minted: + store.commit(f"mint change-id for {sha[:12]}") + + +def _post_rewrite(repo: pygit2.Repository, store: GitRefStore, cid_map: ChangeIdMap) -> None: + migrated = 0 + for line in sys.stdin: + parts = line.split() + if len(parts) < 2: + continue + old_sha, new_sha = parts[0], parts[1] + if cid_map.migrate(old_sha, new_sha, patch_id=patch_id(repo, new_sha)): + migrated += 1 + if migrated: + store.commit(f"migrate {migrated} change-ids after rewrite") diff --git a/git_feature/commands/info.py b/git_feature/commands/info.py new file mode 100644 index 0000000..76f94ad --- /dev/null +++ b/git_feature/commands/info.py @@ -0,0 +1,111 @@ +"""`git feature info` — a feature's files, commits, authors, and branches.""" + +import json + +import pygit2 +import typer + +from ..domain.query.catalog import feature_annotations +from ..gitio import iter_all_commits +from ..identity import ChangeIdMap +from ..store import GitRefStore +from .common import json_mode, require_repo, require_store + + +def run( + ctx: typer.Context, + *, + name: str, + files: bool, + commits: bool, + authors: bool, + branches: bool, +) -> None: + repo = require_repo() + store = require_store(repo) + + pairs = feature_annotations(store, name) + if not pairs and store.read_feature(name) is None: + typer.echo(f"error: unknown feature '{name}'", err=True) + raise typer.Exit(1) + + change_ids = sorted({change_id for change_id, _ in pairs}) + file_list = sorted({annotation.anchor.path for _, annotation in pairs}) + commit_rows, author_list = _commits_and_authors(repo, store, change_ids) + branch_rows = _branches(repo, store, change_ids) + + show_all = not (files or commits or authors or branches) + payload: dict[str, object] = {"name": name, "changes": len(change_ids)} + if files or show_all: + payload["files"] = file_list + if commits or show_all: + payload["commits"] = commit_rows + if authors or show_all: + payload["authors"] = author_list + if branches or show_all: + payload["branches"] = branch_rows + + if json_mode(ctx): + typer.echo(json.dumps(payload)) + return + + typer.echo(f"feature {name}: {len(change_ids)} change(s), {len(pairs)} annotation(s)") + if "files" in payload: + typer.echo("files:") + for path in file_list: + typer.echo(f" {path}") + if "commits" in payload: + typer.echo("commits:") + for row in commit_rows: + typer.echo(f" {row['sha'][:12]} {row['subject']}") + if "authors" in payload: + typer.echo("authors:") + for author in author_list: + typer.echo(f" {author}") + if "branches" in payload: + typer.echo("branches:") + for branch_row in branch_rows: + missing = int(branch_row["total"]) - int(branch_row["present"]) # type: ignore[call-overload] + marker = f" [missing {missing}]" if missing else "" + typer.echo( + f" {branch_row['name']}: {branch_row['present']}/{branch_row['total']}" + f" change(s){marker}" + ) + + +def _commits_and_authors( + repo: pygit2.Repository, store: GitRefStore, change_ids: list[str] +) -> tuple[list[dict[str, str]], list[str]]: + cid_map = ChangeIdMap(store) + reachable = {str(commit.id) for commit in iter_all_commits(repo)} + rows = [] + authors = set() + for change_id in change_ids: + for sha in cid_map.shas_for(change_id): + if sha not in reachable: + continue + commit = repo[sha].peel(pygit2.Commit) + rows.append( + { + "sha": sha, + "change_id": change_id, + "subject": commit.message.splitlines()[0] if commit.message else "", + "author": commit.author.name, + } + ) + authors.add(commit.author.name) + return rows, sorted(authors) + + +def _branches( + repo: pygit2.Repository, store: GitRefStore, change_ids: list[str] +) -> list[dict[str, object]]: + cid_map = ChangeIdMap(store) + cid_shas = {change_id: set(cid_map.shas_for(change_id)) for change_id in change_ids} + rows: list[dict[str, object]] = [] + for branch_name in sorted(repo.branches.local): + branch = repo.branches.local[branch_name] + contained = {str(commit.id) for commit in repo.walk(branch.target)} + present = sum(1 for shas in cid_shas.values() if shas & contained) + rows.append({"name": branch_name, "present": present, "total": len(change_ids)}) + return rows diff --git a/git_feature/commands/init.py b/git_feature/commands/init.py new file mode 100644 index 0000000..84f85c2 --- /dev/null +++ b/git_feature/commands/init.py @@ -0,0 +1,92 @@ +"""`git feature init` — initialize the feature store, hooks, change-id backfill, and model.cfr.""" + +import json +from importlib import metadata +from pathlib import Path + +import pygit2 +import typer + +from ..gitio import RepositoryNotFound, open_repository +from ..gitio.hooks import install_hooks +from ..identity import ChangeIdMap +from ..identity import backfill as backfill_change_ids +from ..store import STORE_REF, GitRefStore, StoreMeta +from .store import incoming_ref + +MODEL_FILE = "model.cfr" + +_MODEL_TEMPLATE = """\ +// Feature model for this repository (git-feature). +// Declare an abstract feature tree, then named instances (= variant specs). +// +// abstract App { +// core +// extras ? +// +// [ extras => core ] +// } +// +// Minimal : App {} +// +// Full : App { +// [ extras ] +// } +""" + + +def run(ctx: typer.Context, *, backfill: bool, hooks: bool) -> None: + try: + repo = open_repository() + except RepositoryNotFound as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + + actions = [] + store = GitRefStore(repo) + already_initialized = store.read_meta() is not None + if not already_initialized: + store.write_meta(StoreMeta(tool_version=metadata.version("git-feature"))) + store.commit("initialize feature store") + actions.append("created feature store at refs/feature/store") + + if backfill: + minted = backfill_change_ids(repo, ChangeIdMap(store)) + if minted: + store.commit(f"backfill change-ids for {minted} commits") + actions.append(f"backfilled change-ids for {minted} commits") + + if repo.workdir is not None: + model_path = Path(repo.workdir) / MODEL_FILE + if not model_path.exists(): + model_path.write_text(_MODEL_TEMPLATE) + actions.append(f"created starter {MODEL_FILE}") + + if hooks: + actions.extend(install_hooks(repo)) + + actions.extend(_configure_store_refspecs(repo)) + + if ctx.obj.get("json"): + typer.echo(json.dumps({"initialized": not already_initialized, "actions": actions})) + return + if already_initialized and not actions: + typer.echo("already initialized, nothing to do") + return + if already_initialized: + typer.echo("already initialized, repaired missing pieces:") + for action in actions: + typer.echo(action) + + +def _configure_store_refspecs(repo: pygit2.Repository) -> list[str]: + """Make plain `git fetch` keep each remote's store copy up to date.""" + actions = [] + for remote in repo.remotes: + if remote.name is None: + continue + refspec = f"+{STORE_REF}:{incoming_ref(remote.name)}" + if refspec not in remote.fetch_refspecs: + repo.remotes.add_fetch(remote.name, refspec) + actions.append(f"added store fetch refspec to remote {remote.name}") + return actions diff --git a/git_feature/commands/list.py b/git_feature/commands/list.py new file mode 100644 index 0000000..f1dd5fc --- /dev/null +++ b/git_feature/commands/list.py @@ -0,0 +1,47 @@ +"""`git feature list` — the feature catalog with annotation counts.""" + +import json + +import typer + +from ..domain.query.catalog import iter_annotations, presence_features +from .common import json_mode, require_repo, require_store + + +def run(ctx: typer.Context) -> None: + repo = require_repo() + store = require_store(repo) + + annotation_counts: dict[str, int] = {} + change_sets: dict[str, set[str]] = {} + for change_id, annotation in iter_annotations(store): + for name in presence_features(annotation.presence): + annotation_counts[name] = annotation_counts.get(name, 0) + 1 + change_sets.setdefault(name, set()).add(change_id) + + rows = [] + for name in store.list_features(): + feature = store.read_feature(name) + rows.append( + { + "name": name, + "annotations": annotation_counts.get(name, 0), + "changes": len(change_sets.get(name, ())), + "auto_created": bool(feature and feature.auto_created), + "description": feature.description if feature else "", + } + ) + + if json_mode(ctx): + typer.echo(json.dumps(rows)) + return + if not rows: + typer.echo("no features defined yet (annotate something, or add them to model.cfr)") + return + name_width = max(len(str(row["name"])) for row in rows) + typer.echo(f"{'FEATURE':<{name_width}} ANNOTATIONS CHANGES") + for row in rows: + auto = " (auto)" if row["auto_created"] else "" + typer.echo( + f"{row['name']:<{name_width}} {row['annotations']:>11} {row['changes']:>7}{auto}" + ) diff --git a/git_feature/commands/model.py b/git_feature/commands/model.py new file mode 100644 index 0000000..9ed0097 --- /dev/null +++ b/git_feature/commands/model.py @@ -0,0 +1,91 @@ +"""`git feature model validate` — validate the feature model and configurations.""" + +import json + +import pygit2 +import typer + +from ..domain.query.catalog import iter_annotations +from ..domain.variability import ( + ConfigurationError, + Evaluator, + ModelError, + ModelParseError, + Module, + model_text, + parse_model, +) +from ..domain.variability.lint import lint_module, model_feature_names +from ..domain.variability.presence import lint_presence +from ..store import GitRefStore +from .common import json_mode, require_repo + + +def run(ctx: typer.Context, *, instance: str | None) -> None: + repo = require_repo() + text = model_text(repo) + if text is None: + typer.echo("error: no model.cfr found (run 'git feature init')", err=True) + raise typer.Exit(1) + try: + module = parse_model(text) + except ModelParseError as exc: + typer.echo(f"error: model.cfr: {exc}", err=True) + raise typer.Exit(1) from None + + if instance is not None: + _validate_instance(ctx, module, instance) + return + + problems = lint_module(module) + problems.extend(_lint_annotations(repo, module)) + if json_mode(ctx): + typer.echo(json.dumps({"valid": not problems, "problems": problems})) + else: + for problem in problems: + typer.echo(f"!! {problem}") + typer.echo("model ok" if not problems else f"{len(problems)} problem(s) found") + if problems: + raise typer.Exit(1) + + +def _validate_instance(ctx: typer.Context, module: Module, instance: str) -> None: + evaluator = Evaluator(module) + try: + config = evaluator.resolve_instance(instance) + assignment = evaluator.resolve_assignment(instance) + except (ModelError, ConfigurationError) as exc: + if json_mode(ctx): + typer.echo(json.dumps({"instance": instance, "valid": False, "error": str(exc)})) + else: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + + if json_mode(ctx): + typer.echo( + json.dumps( + { + "instance": instance, + "valid": True, + "included": sorted(config.included), + "excluded": sorted(config.excluded), + "assignment": assignment, + } + ) + ) + return + typer.echo(f"instance {instance}: legal configuration") + typer.echo(f" included: {', '.join(sorted(config.included)) or '(none)'}") + typer.echo(f" excluded: {', '.join(sorted(config.excluded)) or '(none)'}") + + +def _lint_annotations(repo: pygit2.Repository, module: Module) -> list[str]: + store = GitRefStore(repo) + if store.read_meta() is None: + return [] # store not initialized: nothing to cross-check + known = model_feature_names(module) + problems = [] + for change_id, annotation in iter_annotations(store): + for problem in lint_presence(annotation.presence, known): + problems.append(f"annotation on change {change_id[:8]}…: {problem}") + return problems diff --git a/git_feature/commands/putback.py b/git_feature/commands/putback.py new file mode 100644 index 0000000..cc3169c --- /dev/null +++ b/git_feature/commands/putback.py @@ -0,0 +1,210 @@ +"""`git feature putback` — transplant edits from a variant view back to the source branch.""" + +import json +from datetime import UTC, datetime +from pathlib import Path + +import pygit2 +import typer + +from ..domain.derivation.pipeline import DerivationError, configure, project +from ..domain.derivation.putback import PutbackPlan, plan_putback +from ..domain.derivation.service import ( + ProjectionConflicts, + VerificationFailed, + materialize_variant, + variant_ref, +) +from ..gitio import ( + blob_data, + checkout_branch, + compare_and_swap_ref, + create_commit, + diff_to_workdir, + patch_id, + read_ref, + rewrite_tree, +) +from ..identity import ChangeIdMap +from ..store import GitRefStore +from ..store.types import ViewSession +from .common import json_mode, require_repo, require_store + + +def run(ctx: typer.Context, *, dry_run: bool, message: str | None) -> None: + repo = require_repo() + store = require_store(repo) + session = _require_session(repo, store) + + source_ref = f"refs/heads/{session.source_branch}" + source_tip = read_ref(repo, source_ref) + if source_tip is None: + typer.echo(f"error: source branch {session.source_branch} no longer exists", err=True) + raise typer.Exit(1) + if source_tip != session.base_commit: + typer.echo( + f"error: {session.source_branch} has moved since the view was opened —" + f" re-open it with 'git feature view {session.instance}'", + err=True, + ) + raise typer.Exit(1) + + file_diffs = diff_to_workdir(repo, session.variant_commit) + workdir = Path(repo.workdir) if repo.workdir else None + if workdir is None: + typer.echo("error: bare repository has no view to put back", err=True) + raise typer.Exit(1) + + paths = [d.new_path or d.old_path for d in file_diffs] + base_content = {p: blob_data(repo, session.base_commit, p) for p in paths if p} + workdir_content = { + p: (workdir / p).read_bytes() for p in paths if p and (workdir / p).is_file() + } + plan = plan_putback(session.manifest, file_diffs, base_content, workdir_content) + + if plan.conflicts: + for conflict in plan.conflicts: + typer.echo(f"!! {conflict.kind}: {conflict.detail}", err=True) + typer.echo(f"error: {len(plan.conflicts)} conflict(s) — nothing was put back", err=True) + raise typer.Exit(1) + if not plan.replacements: + typer.echo("view is unedited; nothing to put back") + return + if dry_run: + _report(ctx, session, plan, dry_run=True) + return + + tree = rewrite_tree(repo, session.base_commit, plan.replacements) + text = message or f"apply edits from the {session.instance} view" + sha = create_commit(repo, tree, text, [session.base_commit]) + compare_and_swap_ref(repo, source_ref, sha, expected_old=session.base_commit) + ChangeIdMap(store).get_or_mint(sha, patch_id=patch_id(repo, sha)) + + refreshed = _refresh_view(repo, store, session, sha) + store.commit(f"put back edits from the {session.instance} view") + + if json_mode(ctx): + typer.echo( + json.dumps( + { + "instance": session.instance, + "source_branch": session.source_branch, + "commit": sha, + "files": sorted(plan.replacements), + "view_refreshed": refreshed, + } + ) + ) + return + typer.echo( + f"{session.source_branch} -> {sha[:12]} ({len(plan.replacements)} file(s) transplanted)" + ) + if refreshed: + typer.echo(f"view of {session.instance} refreshed onto the new source commit") + + +def _require_session(repo: pygit2.Repository, store: GitRefStore) -> ViewSession: + """The view session for the checked-out variant branch, or exit.""" + if repo.head_is_unborn or repo.head_is_detached: + typer.echo("error: not on a view branch (open one with 'git feature view')", err=True) + raise typer.Exit(1) + branch = repo.head.shorthand + if not branch.startswith("variant/"): + typer.echo("error: not on a view branch (open one with 'git feature view')", err=True) + raise typer.Exit(1) + instance = branch.removeprefix("variant/") + session = store.read_view(instance) + if session is None: + typer.echo( + f"error: no view session for {instance} — open one with 'git feature view {instance}'", + err=True, + ) + raise typer.Exit(1) + tip = read_ref(repo, variant_ref(instance)) + if tip != session.variant_commit and not _descends(repo, tip, session.variant_commit): + typer.echo( + f"error: variant/{instance} no longer matches the view session —" + f" re-open it with 'git feature view {instance}'", + err=True, + ) + raise typer.Exit(1) + return session + + +def _descends(repo: pygit2.Repository, tip: str | None, ancestor: str) -> bool: + if tip is None: + return False + try: + return repo.descendant_of(tip, ancestor) + except (KeyError, pygit2.GitError): + return False + + +def _refresh_view( + repo: pygit2.Repository, store: GitRefStore, session: ViewSession, new_base: str +) -> bool: + """Re-derive the view from the just-created source commit; never fails the putback.""" + instance = session.instance + try: + assignment, base_sha = configure(repo, instance, new_base) + projection = project(repo, store, instance, assignment, base_sha) + derivation = materialize_variant(repo, store, projection, refresh=True) + except (DerivationError, ProjectionConflicts, VerificationFailed) as exc: + typer.echo( + f"warning: view not refreshed ({exc}) — re-open it with 'git feature view {instance}'", + err=True, + ) + return False + store.write_view( + session.model_copy( + update={ + "base_commit": base_sha, + "variant_commit": derivation.sha, + "manifest": derivation.manifest, + "created_at": datetime.now(UTC), + } + ) + ) + try: + checkout_branch(repo, variant_ref(instance), force=True) + except pygit2.GitError as exc: + typer.echo(f"warning: could not sync the view worktree ({exc})", err=True) + return True + + +def _report(ctx: typer.Context, session: ViewSession, plan: PutbackPlan, *, dry_run: bool) -> None: + if json_mode(ctx): + typer.echo( + json.dumps( + { + "instance": session.instance, + "source_branch": session.source_branch, + "dry_run": dry_run, + "files": sorted(plan.replacements), + "edits": [ + { + "path": e.path, + "view_span": list(e.view_span), + "source_span": list(e.source_span), + } + for e in plan.edits + ], + } + ) + ) + return + typer.echo(f"would apply to {session.source_branch} @ {session.base_commit[:12]}:") + for edit in plan.edits: + typer.echo( + f" {edit.path}: view lines {_span_text(edit.view_span)}" + f" -> source lines {_span_text(edit.source_span)}" + ) + for path in plan.additions: + typer.echo(f" {path}: new file") + + +def _span_text(span: tuple[int, int]) -> str: + start, count = span + if count == 0: + return f"{start} (insertion)" + return f"{start}-{start + count - 1}" if count > 1 else str(start) diff --git a/git_feature/commands/reconcile.py b/git_feature/commands/reconcile.py new file mode 100644 index 0000000..1ce5e3d --- /dev/null +++ b/git_feature/commands/reconcile.py @@ -0,0 +1,83 @@ +"""`git feature reconcile` — re-link commits that arrived outside the hooks.""" + +import json + +import typer + +from ..gitio import RepositoryNotFound, open_repository +from ..identity import ChangeIdMap +from ..identity.reconcile import Ambiguity, Link, apply_links, plan_reconcile +from ..store import GitRefStore + + +def run(ctx: typer.Context, *, dry_run: bool, interactive: bool) -> None: + try: + repo = open_repository() + except RepositoryNotFound as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + store = GitRefStore(repo) + if store.read_meta() is None: + typer.echo("error: not initialized (run 'git feature init')", err=True) + raise typer.Exit(1) + + cid_map = ChangeIdMap(store) + plan = plan_reconcile(repo, cid_map) + if interactive and plan.ambiguous: + plan.links.extend(_resolve_interactively(plan.ambiguous)) + plan.ambiguous = [] + + if ctx.obj.get("json"): + typer.echo(json.dumps(_as_json(plan.links, plan.ambiguous, plan.unmatched, dry_run))) + else: + _print_report(plan.links, plan.ambiguous, plan.unmatched, dry_run) + + if not dry_run and plan.links: + apply_links(cid_map, plan.links) + store.commit(f"reconcile {len(plan.links)} commits") + + +def _resolve_interactively(ambiguous: list[Ambiguity]) -> list[Link]: + links = [] + for item in ambiguous: + typer.echo(f"commit {item.sha[:12]} matches several changes:") + for i, (change_id, score) in enumerate(item.candidates, start=1): + typer.echo(f" [{i}] {change_id} (score {score:.2f})") + choice = typer.prompt("link to which change-id? (0 = skip)", type=int, default=0) + if 1 <= choice <= len(item.candidates): + change_id, score = item.candidates[choice - 1] + links.append( + Link(item.sha, change_id, via="interactive", score=score, patch_id=item.patch_id) + ) + return links + + +def _print_report( + links: list[Link], ambiguous: list[Ambiguity], unmatched: list[str], dry_run: bool +) -> None: + verb = "would link" if dry_run else "linked" + for link in links: + typer.echo(f"{verb} {link.sha[:12]} -> {link.change_id} ({link.via}, {link.score:.2f})") + for item in ambiguous: + names = ", ".join(cid for cid, _ in item.candidates) + typer.echo(f"ambiguous {item.sha[:12]}: candidates {names} (use --interactive)") + if unmatched: + typer.echo(f"{len(unmatched)} commits have no counterpart (left unmapped)") + if not links and not ambiguous: + typer.echo("nothing to reconcile") + + +def _as_json( + links: list[Link], ambiguous: list[Ambiguity], unmatched: list[str], dry_run: bool +) -> dict[str, object]: + return { + "dry_run": dry_run, + "links": [ + {"sha": x.sha, "change_id": x.change_id, "via": x.via, "score": x.score} for x in links + ], + "ambiguous": [ + {"sha": x.sha, "candidates": [{"change_id": c, "score": s} for c, s in x.candidates]} + for x in ambiguous + ], + "unmatched": unmatched, + } diff --git a/git_feature/commands/status.py b/git_feature/commands/status.py new file mode 100644 index 0000000..b0d5078 --- /dev/null +++ b/git_feature/commands/status.py @@ -0,0 +1,80 @@ +"""`git feature status` — working-directory changes grouped by feature.""" + +import json + +import pygit2 +import typer + +from ..domain.query.catalog import iter_annotations +from ..gitio import worktree_diff +from ..identity import ChangeIdMap, RegionResolver +from ..store import GitRefStore +from .common import json_mode, require_repo, require_store + + +def run(ctx: typer.Context) -> None: + repo = require_repo() + store = require_store(repo) + + dirty = worktree_diff(repo) + if not any(file_diff.hunks for file_diff in dirty): + typer.echo("working tree clean") + return + + dirty_paths = {f.old_path for f in dirty if f.old_path} | { + f.new_path for f in dirty if f.new_path + } + regions = _resolved_regions(repo, store, dirty_paths) + + by_feature: dict[str, list[str]] = {} + unassigned: list[str] = [] + for file_diff in dirty: + path = file_diff.old_path or file_diff.new_path or "" + for hunk in file_diff.hunks: + location = f"{file_diff.new_path or path} @ {hunk.new_span[0]}" + touched = sorted( + { + presence + for region_path, span, presence in regions + if region_path == path and _overlaps(hunk.old_span, span) + } + ) + if touched: + for presence in touched: + by_feature.setdefault(presence, []).append(location) + else: + unassigned.append(location) + + if json_mode(ctx): + typer.echo(json.dumps({"features": by_feature, "unassigned": unassigned})) + return + for presence in sorted(by_feature): + typer.echo(f"{presence}:") + for location in by_feature[presence]: + typer.echo(f" {location}") + if unassigned: + typer.echo("unassigned:") + for location in unassigned: + typer.echo(f" {location}") + + +def _resolved_regions( + repo: pygit2.Repository, store: GitRefStore, paths: set[str] +) -> list[tuple[str, tuple[int, int], str]]: + """(path, span at HEAD, presence) for annotations on the given paths.""" + cid_map = ChangeIdMap(store) + resolver = RegionResolver(repo, cid_map) + regions = [] + for _, annotation in iter_annotations(store): + if annotation.anchor.path not in paths: + continue + resolution = resolver.resolve(annotation.anchor, "HEAD") + for run in resolution.line_runs(): + regions.append((annotation.anchor.path, run, annotation.presence)) + return regions + + +def _overlaps(a: tuple[int, int], b: tuple[int, int]) -> bool: + a_end = a[0] + max(a[1], 1) - 1 + b_end = b[0] + max(b[1], 1) - 1 + return a[0] <= b_end and b[0] <= a_end diff --git a/git_feature/commands/store.py b/git_feature/commands/store.py new file mode 100644 index 0000000..d7cffe6 --- /dev/null +++ b/git_feature/commands/store.py @@ -0,0 +1,134 @@ +"""`git feature store` — push, fetch, and merge the feature store with a remote.""" + +import json + +import pygit2 +import typer + +from ..gitio import compare_and_swap_ref, read_ref, write_ref +from ..store import STORE_REF, GitRefStore, StoreMergeError, merge_stores, store_files_at +from .common import json_mode, require_repo, require_store + + +def incoming_ref(remote: str) -> str: + """Where a remote's store commit is fetched to before merging.""" + return f"refs/feature/incoming/{remote}" + + +class _PushStatus(pygit2.RemoteCallbacks): + """Collects per-ref rejection messages from a push.""" + + def __init__(self) -> None: + super().__init__() + self.rejections: list[str] = [] + + def push_update_reference(self, refname: str, message: str | None) -> None: + if message is not None: + self.rejections.append(f"{refname}: {message}") + + +def run_push(ctx: typer.Context, *, remote_name: str) -> None: + repo = require_repo() + require_store(repo) + remote = _require_remote(repo, remote_name) + + status = _PushStatus() + try: + remote.push([f"{STORE_REF}:{STORE_REF}"], callbacks=status) + except pygit2.GitError as exc: + _push_rejected(remote_name, str(exc)) + if status.rejections: + _push_rejected(remote_name, "; ".join(status.rejections)) + + if json_mode(ctx): + typer.echo(json.dumps({"remote": remote_name, "pushed": read_ref(repo, STORE_REF)})) + else: + typer.echo(f"pushed feature store to {remote_name}") + + +def run_fetch(ctx: typer.Context, *, remote_name: str) -> None: + repo = require_repo() + remote = _require_remote(repo, remote_name) + + try: + remote.fetch([f"+{STORE_REF}:{incoming_ref(remote_name)}"]) + except pygit2.GitError as exc: + typer.echo(f"error: fetch from {remote_name} failed ({exc})", err=True) + raise typer.Exit(1) from None + + incoming = read_ref(repo, incoming_ref(remote_name)) + if incoming is None: + typer.echo(f"error: {remote_name} has no feature store", err=True) + raise typer.Exit(1) + local = read_ref(repo, STORE_REF) + + if local is None: + write_ref(repo, STORE_REF, incoming) + _done(ctx, remote_name, incoming, "fetched", []) + return + if incoming == local: + _done(ctx, remote_name, local, "up-to-date", []) + return + if _descends(repo, incoming, local): + compare_and_swap_ref(repo, STORE_REF, incoming, expected_old=local) + _done(ctx, remote_name, incoming, "fast-forwarded", []) + return + if _descends(repo, local, incoming): + _done(ctx, remote_name, local, "ahead", []) + return + + base_sha = repo.merge_base(local, incoming) + base = store_files_at(repo, str(base_sha)) if base_sha else {} + try: + merged, notes = merge_stores( + base, store_files_at(repo, local), store_files_at(repo, incoming) + ) + except StoreMergeError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + store = GitRefStore(repo) + store.commit_merge(merged, f"merge feature store from {remote_name}", other_parent=incoming) + _done(ctx, remote_name, store.tip or "", "merged", notes) + + +def _require_remote(repo: pygit2.Repository, name: str) -> pygit2.Remote: + try: + return repo.remotes[name] + except (KeyError, ValueError): + typer.echo(f"error: no remote named {name!r}", err=True) + raise typer.Exit(1) from None + + +def _push_rejected(remote_name: str, detail: str) -> None: + typer.echo(f"error: push to {remote_name} rejected ({detail})", err=True) + typer.echo( + f"the remote store has new commits — run 'git feature store fetch {remote_name}'" + " (it merges automatically), then push again", + err=True, + ) + raise typer.Exit(1) + + +def _descends(repo: pygit2.Repository, descendant: str, ancestor: str) -> bool: + try: + return repo.descendant_of(descendant, ancestor) + except (KeyError, pygit2.GitError): + return False + + +def _done(ctx: typer.Context, remote_name: str, tip: str, outcome: str, notes: list[str]) -> None: + if json_mode(ctx): + typer.echo( + json.dumps({"remote": remote_name, "outcome": outcome, "tip": tip, "notes": notes}) + ) + return + messages = { + "fetched": f"fetched feature store from {remote_name}", + "up-to-date": "feature store already up to date", + "fast-forwarded": f"fast-forwarded feature store to {remote_name}", + "ahead": f"local feature store is ahead of {remote_name} — push when ready", + "merged": f"merged feature store from {remote_name} — push to share the result", + } + typer.echo(messages[outcome]) + for note in notes: + typer.echo(f" ({note})") diff --git a/git_feature/commands/sync.py b/git_feature/commands/sync.py new file mode 100644 index 0000000..a3f6d66 --- /dev/null +++ b/git_feature/commands/sync.py @@ -0,0 +1,188 @@ +"""`git feature sync` — propagate a feature's commits between branches.""" + +import json +from dataclasses import dataclass + +import pygit2 +import typer + +from ..domain.query.catalog import presence_features +from ..gitio import ( + checkout_branch, + cherrypick_tree, + compare_and_swap_ref, + create_commit, + iter_commits, + patch_id, + resolve_commit, + worktree_diff, +) +from ..identity import ChangeIdMap +from ..store import GitRefStore +from .common import json_mode, require_repo, require_store + + +@dataclass +class Candidate: + """One commit missing from the target branch and eligible for propagation.""" + + commit: pygit2.Commit + change_id: str | None + features: list[str] + + @property + def sha(self) -> str: + return str(self.commit.id) + + @property + def subject(self) -> str: + return self.commit.message.splitlines()[0] if self.commit.message else "" + + +def run(ctx: typer.Context, *, source: str, feature: str | None, dry_run: bool) -> None: + repo = require_repo() + store = require_store(repo) + + if repo.head_is_unborn or repo.head_is_detached: + typer.echo("error: check out the branch to sync onto first", err=True) + raise typer.Exit(1) + target_branch = repo.head.shorthand + if target_branch.startswith("variant/"): + typer.echo("error: variants are derived, not synced — switch to a source branch", err=True) + raise typer.Exit(1) + try: + resolve_commit(repo, source) + except (KeyError, pygit2.GitError): + typer.echo(f"error: unknown revision {source!r}", err=True) + raise typer.Exit(1) from None + + cid_map = ChangeIdMap(store) + sha_to_cid = {record.sha: record.change_id for record in cid_map.iter_records()} + candidates, skipped = _plan(repo, store, source, feature, sha_to_cid) + + if not candidates: + typer.echo( + f"nothing to sync from {source}" + (f" for feature {feature}" if feature else "") + ) + for note in skipped: + typer.echo(f" ({note})") + return + + if dry_run: + _report(ctx, candidates, skipped, source, target_branch, applied=None) + return + + if worktree_diff(repo): + typer.echo("error: uncommitted changes — commit or stash them before syncing", err=True) + raise typer.Exit(1) + + old_tip = str(repo.head.target) + tip = old_tip + applied: list[tuple[Candidate, str]] = [] + for candidate in candidates: + tree = cherrypick_tree(repo, candidate.commit, tip) + if tree is None: + typer.echo( + f"error: cherry-pick of {candidate.sha[:12]} ({candidate.subject})" + f" conflicts — nothing was applied", + err=True, + ) + raise typer.Exit(1) + if tree == resolve_commit(repo, tip).tree.id: + skipped.append(f"{candidate.sha[:12]} adds no changes on {target_branch}, skipped") + continue + new_sha = create_commit( + repo, tree, candidate.commit.message, [tip], author=candidate.commit.author + ) + origin, _ = cid_map.get_or_mint(candidate.sha, patch_id=patch_id(repo, candidate.commit)) + cid_map.insert(new_sha, change_id=origin.change_id, patch_id=patch_id(repo, new_sha)) + applied.append((candidate, new_sha)) + tip = new_sha + + if not applied: + typer.echo(f"nothing to sync from {source}") + for note in skipped: + typer.echo(f" ({note})") + return + + refname = f"refs/heads/{target_branch}" + compare_and_swap_ref(repo, refname, tip, expected_old=old_tip) + checkout_branch(repo, refname, force=True) + store.commit(f"sync {len(applied)} commit(s) from {source}") + _report(ctx, candidates, skipped, source, target_branch, applied=applied) + + +def _plan( + repo: pygit2.Repository, + store: GitRefStore, + source: str, + feature: str | None, + sha_to_cid: dict[str, str], +) -> tuple[list[Candidate], list[str]]: + """Commits reachable from source but absent (by change-id) from the target, oldest first.""" + target_cids = { + cid for commit in iter_commits(repo) if (cid := sha_to_cid.get(str(commit.id))) is not None + } + candidates = [] + skipped = [] + for commit in reversed(list(iter_commits(repo, source, exclude=["HEAD"]))): + if len(commit.parents) > 1: + skipped.append(f"{str(commit.id)[:12]} is a merge commit, skipped") + continue + change_id = sha_to_cid.get(str(commit.id)) + if change_id is not None and change_id in target_cids: + continue # another incarnation of this change is already on the target + annotations = store.read_annotations(change_id) if change_id else [] + features = sorted({f for a in annotations for f in presence_features(a.presence)}) + if feature is not None and feature not in features: + continue + candidates.append(Candidate(commit=commit, change_id=change_id, features=features)) + return candidates, skipped + + +def _report( + ctx: typer.Context, + candidates: list[Candidate], + skipped: list[str], + source: str, + target_branch: str, + applied: list[tuple[Candidate, str]] | None, +) -> None: + if json_mode(ctx): + new_shas = {candidate.sha: new_sha for candidate, new_sha in applied or []} + typer.echo( + json.dumps( + { + "source": source, + "target": target_branch, + "dry_run": applied is None, + "commits": [ + { + "sha": c.sha, + "new_sha": new_shas.get(c.sha), + "change_id": c.change_id, + "features": c.features, + "subject": c.subject, + } + for c in candidates + if applied is None or c.sha in new_shas + ], + "notes": skipped, + } + ) + ) + return + if applied is None: + typer.echo(f"would sync from {source} onto {target_branch}:") + for candidate in candidates: + features = f" [{', '.join(candidate.features)}]" if candidate.features else "" + typer.echo(f" {candidate.sha[:12]} {candidate.subject}{features}") + else: + for candidate, new_sha in applied: + features = f" [{', '.join(candidate.features)}]" if candidate.features else "" + typer.echo( + f"synced {candidate.sha[:12]} -> {new_sha[:12]} {candidate.subject}{features}" + ) + typer.echo(f"{target_branch}: {len(applied)} commit(s) from {source}") + for note in skipped: + typer.echo(f" ({note})") diff --git a/git_feature/commands/variant.py b/git_feature/commands/variant.py new file mode 100644 index 0000000..f48609e --- /dev/null +++ b/git_feature/commands/variant.py @@ -0,0 +1,60 @@ +"""`git feature variant list` — list derived variants and their staleness.""" + +import json + +import pygit2 +import typer + +from ..gitio import read_ref +from .common import json_mode, require_repo, require_store + + +def run(ctx: typer.Context) -> None: + repo = require_repo() + store = require_store(repo) + + rows = [] + lines = [] + for name in store.list_variants(): + manifest = store.read_manifest(name) + if manifest is None: + continue + tip = read_ref(repo, f"refs/heads/variant/{name}") + disabled = sorted(f for f, on in manifest.assignment.items() if not on) + branch = f"variant/{name}" if tip else None + stale = _is_stale(repo, name, manifest.base_commit) + rows.append( + { + "name": name, + "base_commit": manifest.base_commit, + "disabled": disabled, + "branch": branch, + "stale": stale, + } + ) + lines.append( + f"{name}: {branch or 'branch missing!'} from {manifest.base_commit[:12]}" + f" (disabled: {', '.join(disabled) or '(none)'}) [{'stale' if stale else 'current'}]" + ) + + if json_mode(ctx): + typer.echo(json.dumps(rows)) + return + if not lines: + typer.echo("no variants derived yet (see 'git feature checkout')") + return + for line in lines: + typer.echo(line) + + +def _is_stale(repo: pygit2.Repository, name: str, base_sha: str) -> bool: + """Stale = the source moved past the base (HEAD on the variant branch doesn't count).""" + if repo.head_is_unborn or repo.head.shorthand == f"variant/{name}": + return False + head = str(repo.head.target) + if head == base_sha: + return False + try: + return repo.descendant_of(head, base_sha) + except (KeyError, pygit2.GitError): + return False diff --git a/git_feature/commands/view.py b/git_feature/commands/view.py new file mode 100644 index 0000000..8bf0cc2 --- /dev/null +++ b/git_feature/commands/view.py @@ -0,0 +1,97 @@ +"""`git feature view` — check out a variant as an editable view.""" + +import json +from datetime import UTC, datetime + +import pygit2 +import typer + +from ..domain.derivation.pipeline import DerivationError, configure, project +from ..domain.derivation.service import ( + ProjectionConflicts, + VerificationFailed, + materialize_variant, + variant_ref, +) +from ..gitio import read_ref, worktree_diff +from ..store import GitRefStore +from ..store.types import ViewSession +from .common import json_mode, require_repo, require_store, switch_branch + + +def run(ctx: typer.Context, *, instance: str) -> None: + repo = require_repo() + store = require_store(repo) + source_branch, base_rev, refreshing = _source(repo, store, instance) + + try: + assignment, base_sha = configure(repo, instance, base_rev) + except DerivationError as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + projection = project(repo, store, instance, assignment, base_sha) + + try: + derivation = materialize_variant(repo, store, projection, refresh=True) + except ProjectionConflicts as exc: + for conflict in exc.projection.conflicts: + typer.echo(f"!! {conflict.kind}: {conflict.detail}", err=True) + typer.echo(f"error: view of {instance} cannot be opened — resolve the conflicts", err=True) + raise typer.Exit(1) from None + except VerificationFailed as exc: + for conflict in exc.conflicts: + typer.echo(f"!! {conflict.kind}: {conflict.detail}", err=True) + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(1) from None + + session = ViewSession( + instance=instance, + source_branch=source_branch, + base_commit=base_sha, + variant_commit=derivation.sha, + manifest=derivation.manifest, + created_at=datetime.now(UTC), + ) + store.write_view(session) + store.commit(f"open view of variant {instance}") + + if json_mode(ctx): + typer.echo(json.dumps(session.model_dump(mode="json", exclude={"manifest"}))) + else: + typer.echo( + f"view of {instance}: editing {derivation.sha[:12]}" + f" (from {source_branch} @ {base_sha[:12]}) — put edits back with" + " 'git feature putback'" + ) + switch_branch(repo, variant_ref(instance), force=refreshing) + + +def _source(repo: pygit2.Repository, store: GitRefStore, instance: str) -> tuple[str, str, bool]: + """The branch putback will target, the revision to derive from, and whether refreshing.""" + if repo.head_is_unborn: + typer.echo("error: no commits yet", err=True) + raise typer.Exit(1) + if repo.head_is_detached: + typer.echo("error: detached HEAD — check out the source branch first", err=True) + raise typer.Exit(1) + branch = repo.head.shorthand + if not branch.startswith("variant/"): + return branch, "HEAD", False + + # re-opening a view from its own branch refreshes it from the source's tip + session = store.read_view(branch.removeprefix("variant/")) + if branch != f"variant/{instance}" or session is None: + typer.echo(f"error: on {branch} — switch to the source branch to open a view", err=True) + raise typer.Exit(1) + if worktree_diff(repo): + typer.echo( + "error: the view has uncommitted edits — put them back or discard them" + " before refreshing", + err=True, + ) + raise typer.Exit(1) + source_tip = read_ref(repo, f"refs/heads/{session.source_branch}") + if source_tip is None: + typer.echo(f"error: source branch {session.source_branch} no longer exists", err=True) + raise typer.Exit(1) + return session.source_branch, source_tip, True diff --git a/git_feature/commands/whatfeature.py b/git_feature/commands/whatfeature.py new file mode 100644 index 0000000..b6aced3 --- /dev/null +++ b/git_feature/commands/whatfeature.py @@ -0,0 +1,46 @@ +"""`git feature whatfeature` — which features a commit touches.""" + +import json + +import typer + +from ..gitio import resolve_commit +from ..identity import ChangeIdMap +from .common import json_mode, require_repo, require_store + + +def run(ctx: typer.Context, *, rev: str) -> None: + repo = require_repo() + store = require_store(repo) + + commit = resolve_commit(repo, rev) + sha = str(commit.id) + record = ChangeIdMap(store).get(sha) + annotations = store.read_annotations(record.change_id) if record else [] + + if json_mode(ctx): + typer.echo( + json.dumps( + { + "sha": sha, + "change_id": record.change_id if record else None, + "annotations": [ + { + "presence": a.presence, + "path": a.anchor.path, + "span": list(a.anchor.new_span), + } + for a in annotations + ], + } + ) + ) + return + + if not annotations: + typer.echo(f"{sha[:12]}: no features recorded") + return + typer.echo(f"{sha[:12]}:") + for annotation in annotations: + anchor = annotation.anchor + typer.echo(f" {annotation.presence} {anchor.path} @ {anchor.new_span[0]}") diff --git a/git_tool/__init__.py b/git_feature/domain/__init__.py similarity index 100% rename from git_tool/__init__.py rename to git_feature/domain/__init__.py diff --git a/git_tool/ci/__init__.py b/git_feature/domain/annotate/__init__.py similarity index 100% rename from git_tool/ci/__init__.py rename to git_feature/domain/annotate/__init__.py diff --git a/git_feature/domain/annotate/capture.py b/git_feature/domain/annotate/capture.py new file mode 100644 index 0000000..b2cab85 --- /dev/null +++ b/git_feature/domain/annotate/capture.py @@ -0,0 +1,97 @@ +"""Hunk capture: anchor a commit's hunks and write feature annotations.""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from fnmatch import fnmatch + +import pygit2 + +from ...gitio import Hunk, commit_diff, patch_id, resolve_commit +from ...identity import ChangeIdMap, anchor_from_hunk +from ...store.base import Store +from ...store.types import Annotation, FeatureDef + + +@dataclass +class CaptureResult: + """What a capture run did (aggregated over commits).""" + + written: int = 0 + duplicates: int = 0 + minted_change_ids: int = 0 + created_features: list[str] = field(default_factory=list) + annotated_commits: int = 0 + + +def matching_hunks( + repo: pygit2.Repository, rev: str | pygit2.Commit, paths: tuple[str, ...] +) -> list[tuple[str, Hunk]]: + """(path, hunk) pairs of a commit's diff whose path matches any glob (all if none).""" + pairs = [] + for file_diff in commit_diff(repo, rev): + if file_diff.new_path is None: # pure deletion: nothing to attach a feature to + continue + if paths and not any(fnmatch(file_diff.new_path, glob) for glob in paths): + continue + for hunk in file_diff.hunks: + pairs.append((file_diff.new_path, hunk)) + return pairs + + +def ensure_features(store: Store, names: list[str], result: CaptureResult) -> None: + """Auto-create a minimal definition for any unknown feature name.""" + for name in names: + if store.read_feature(name) is None: + store.write_feature(FeatureDef(name=name, auto_created=True)) + result.created_features.append(name) + + +def annotate_commit( + repo: pygit2.Repository, + store: Store, + rev: str | pygit2.Commit, + presences: list[str], + paths: tuple[str, ...], + author: str, + result: CaptureResult, +) -> None: + """Write one annotation per (matching hunk × presence condition) for a commit.""" + commit = resolve_commit(repo, rev) + pairs = matching_hunks(repo, commit, paths) + if not pairs: + return + cid_map = ChangeIdMap(store) + sha = str(commit.id) + record, minted = cid_map.get_or_mint(sha, patch_id=patch_id(repo, commit)) + if minted: + result.minted_change_ids += 1 + + annotations = store.read_annotations(record.change_id) + existing = { + (a.anchor.path, a.anchor.old_span, a.anchor.new_span, a.presence) for a in annotations + } + added = False + for path, hunk in pairs: + anchor = anchor_from_hunk(record.change_id, path, hunk) + for presence in presences: + key = (anchor.path, anchor.old_span, anchor.new_span, presence) + if key in existing: + result.duplicates += 1 + continue + annotations.append( + Annotation(anchor=anchor, presence=presence, author=author, ts=datetime.now(UTC)) + ) + existing.add(key) + result.written += 1 + added = True + if added: + store.write_annotations(record.change_id, annotations) + result.annotated_commits += 1 + + +def signature_name(repo: pygit2.Repository) -> str: + """The configured git author name, or a placeholder.""" + try: + return repo.default_signature.name or "unknown" + except (KeyError, pygit2.GitError): + return "unknown" diff --git a/git_tool/feature_data/__init__.py b/git_feature/domain/derivation/__init__.py similarity index 100% rename from git_tool/feature_data/__init__.py rename to git_feature/domain/derivation/__init__.py diff --git a/git_feature/domain/derivation/materialize.py b/git_feature/domain/derivation/materialize.py new file mode 100644 index 0000000..283bebd --- /dev/null +++ b/git_feature/domain/derivation/materialize.py @@ -0,0 +1,114 @@ +"""Variant derivation stages 3–4: materialize the reduced tree and verify it.""" + +import pygit2 + +from ...gitio import blob_data, rewrite_tree +from ...identity import ChangeIdMap, original_added_lines +from ...store.base import Store +from ...store.types import RegionDecision +from .pipeline import Conflict, Projection + + +def removal_plan(projection: Projection) -> dict[str, list[tuple[int, int]]]: + """Line runs to delete per file, from the projection's removed decisions.""" + plan: dict[str, list[tuple[int, int]]] = {} + for decision in projection.removed: + plan.setdefault(decision.location(), []).extend(decision.runs()) + return plan + + +def materialize_tree(repo: pygit2.Repository, projection: Projection) -> pygit2.Oid: + """Build the variant tree: base tree with disabled spans spliced out of each file.""" + replacements = {} + for path, spans in removal_plan(projection).items(): + raw = blob_data(repo, projection.base_sha, path) + if raw is None: + raise KeyError(f"'{path}' not found in base commit {projection.base_sha[:12]}") + replacements[path] = splice_out(raw, spans) + return rewrite_tree(repo, projection.base_sha, replacements) + + +def splice_out(raw: bytes, spans: list[tuple[int, int]]) -> bytes: + """Remove 1-based line spans from file content, byte-exact for the kept lines.""" + drop: set[int] = set() + for start, count in spans: + drop.update(range(start, start + count)) + lines = raw.decode("utf-8").splitlines(keepends=True) + return "".join(line for number, line in enumerate(lines, start=1) if number not in drop).encode( + "utf-8" + ) + + +def verify_tree( + repo: pygit2.Repository, + store: Store, + tree_rev: str | pygit2.Commit, + base_sha: str, + decisions: list[RegionDecision], +) -> list[Conflict]: + """Check a derived tree against what each region *originally said*, not against + the resolver's answer: a removed region's content must be gone, a kept region's + content must survive. This catches mis-resolved spans that spliced wrong lines. + """ + cid_map = ChangeIdMap(store) + conflicts = [] + for decision in decisions: + content = _region_content(repo, cid_map, decision, base_sha) + if not content: + continue + # Regions evolve: later commits may have rewritten these lines. Only what + # was literally present at the base can be demanded from (or denied to) + # the derived tree. + if not _content_present(repo, base_sha, decision.location(), content): + continue + present = _content_present(repo, tree_rev, decision.location(), content) + if decision.included and not present: + conflicts.append( + Conflict( + "verify", + f"kept region {decision.presence!r} is missing from the derived" + f" {decision.location()}", + decision.location(), + ) + ) + if not decision.included and present: + conflicts.append( + Conflict( + "verify", + f"removed region {decision.presence!r} still present in the derived" + f" {decision.location()}", + decision.location(), + ) + ) + return conflicts + + +def _region_content( + repo: pygit2.Repository, cid_map: ChangeIdMap, decision: RegionDecision, base_sha: str +) -> list[str]: + """The region's salient lines: whitespace-normalized, blanks dropped.""" + lines = original_added_lines(repo, cid_map, decision.anchor) + if lines is None and decision.resolved_span is not None: + raw = blob_data(repo, base_sha, decision.location()) + if raw is not None: + start, count = decision.resolved_span + lines = tuple(raw.decode("utf-8").splitlines()[start - 1 : start - 1 + count]) + return _salient(lines or ()) + + +def _content_present( + repo: pygit2.Repository, rev: str | pygit2.Commit, path: str, content: list[str] +) -> bool: + raw = blob_data(repo, rev, path) + if raw is None: + return False + haystack = _salient(raw.decode("utf-8").splitlines()) + length = len(content) + return any( + haystack[i : i + length] == content for i in range(0, max(len(haystack) - length + 1, 1)) + ) + + +def _salient(lines: tuple[str, ...] | list[str]) -> list[str]: + normalized = ("".join(line.split()) for line in lines) + return [line for line in normalized if line] diff --git a/git_feature/domain/derivation/pipeline.py b/git_feature/domain/derivation/pipeline.py new file mode 100644 index 0000000..ce33fb8 --- /dev/null +++ b/git_feature/domain/derivation/pipeline.py @@ -0,0 +1,196 @@ +"""Variant derivation pipeline, stages 1–2: configure and project.""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from itertools import combinations + +import pygit2 + +from ...domain.query.catalog import iter_annotations +from ...domain.variability import ( + ConfigurationError, + Evaluator, + ModelError, + ModelParseError, + model_text, + parse_model, +) +from ...domain.variability.presence import ( + PresenceError, + UnknownFeatureError, + eval_presence, + parse_presence, +) +from ...gitio import resolve_commit +from ...identity import ChangeIdMap, RegionResolver +from ...store.base import Store +from ...store.types import DerivationManifest, RegionDecision + + +class DerivationError(Exception): + """Configuration cannot even be attempted (missing model, illegal instance...).""" + + +@dataclass(frozen=True) +class Conflict: + """One reported problem from the projection consistency checks.""" + + kind: str # "overlap" | "dangling-dependency" | "lost-region" | "bad-presence" + detail: str + path: str | None = None + + +@dataclass +class Projection: + """Everything stages 1–2 produce: the plan for a variant, before any write.""" + + instance: str + base_sha: str + assignment: dict[str, bool] + decisions: list[RegionDecision] = field(default_factory=list) + conflicts: list[Conflict] = field(default_factory=list) + + @property + def removed(self) -> list[RegionDecision]: + return [d for d in self.decisions if not d.included] + + def manifest(self) -> DerivationManifest: + return DerivationManifest( + instance=self.instance, + base_commit=self.base_sha, + assignment=self.assignment, + decisions=self.decisions, + created_at=datetime.now(UTC), + ) + + +def configure(repo: pygit2.Repository, instance: str, base_rev: str) -> tuple[dict[str, bool], str]: + """Stage 1: resolve the instance against model.cfr as of the base commit.""" + base_sha = str(resolve_commit(repo, base_rev).id) + text = model_text(repo, base_sha) + if text is None: + raise DerivationError(f"no model.cfr in the tree of base commit {base_sha[:12]}") + try: + module = parse_model(text) + assignment = Evaluator(module).resolve_assignment(instance) + except (ModelParseError, ModelError, ConfigurationError) as exc: + raise DerivationError(str(exc)) from None + return assignment, base_sha + + +def project( + repo: pygit2.Repository, + store: Store, + instance: str, + assignment: dict[str, bool], + base_sha: str, +) -> Projection: + """Stage 2: decide every region's fate at the base commit and check consistency.""" + projection = Projection(instance=instance, base_sha=base_sha, assignment=assignment) + cid_map = ChangeIdMap(store) + resolver = RegionResolver(repo, cid_map) + for change_id, annotation in iter_annotations(store): + if not _change_in_history(repo, cid_map, change_id, base_sha): + continue # the change is not part of the base commit; nothing to include or remove + try: + included = eval_presence(parse_presence(annotation.presence), assignment) + except (PresenceError, UnknownFeatureError) as exc: + projection.conflicts.append( + Conflict("bad-presence", f"{annotation.presence!r}: {exc}", annotation.anchor.path) + ) + continue + resolution = resolver.resolve(annotation.anchor, base_sha) + projection.decisions.append( + RegionDecision( + anchor=annotation.anchor, + presence=annotation.presence, + included=included, + resolved_span=resolution.span, + resolved_runs=list(resolution.runs) if resolution.runs else None, + resolved_path=resolution.path, + confidence=resolution.confidence, + ) + ) + if not included and resolution.confidence == "lost": + projection.conflicts.append( + Conflict( + "lost-region", + f"region of {annotation.presence!r} in {annotation.anchor.path} must be" + " removed but cannot be located at the base commit", + annotation.anchor.path, + ) + ) + projection.conflicts.extend(_consistency_conflicts(projection.decisions)) + return projection + + +def _change_in_history( + repo: pygit2.Repository, cid_map: ChangeIdMap, change_id: str, base_sha: str +) -> bool: + """Whether any incarnation of the change is the base commit or one of its ancestors.""" + base = repo[base_sha].peel(pygit2.Commit).id + for sha in cid_map.shas_for(change_id): + if sha == base_sha: + return True + try: + if repo.descendant_of(base, sha): + return True + except (KeyError, pygit2.GitError): + continue + return False + + +def _consistency_conflicts(decisions: list[RegionDecision]) -> list[Conflict]: + conflicts = [] + located = [d for d in decisions if d.resolved_span is not None] + for a, b in combinations(located, 2): + if a.location() != b.location() or a.included == b.included: + continue + enabled, disabled = (a, b) if a.included else (b, a) + clash = [ + (kept, removed) + for kept in enabled.runs() + for removed in disabled.runs() + if _overlaps(kept, removed) + ] + if not clash: + continue + kept_run, removed_run = clash[0] + if _contained(kept_run, removed_run): + conflicts.append( + Conflict( + "dangling-dependency", + f"enabled region {enabled.presence!r} sits inside removed region" + f" {disabled.presence!r} in {enabled.location()}" + f" (lines {_span_text(kept_run)})", + enabled.location(), + ) + ) + else: + conflicts.append( + Conflict( + "overlap", + f"regions {enabled.presence!r} (kept) and {disabled.presence!r} (removed)" + f" overlap in {enabled.location()}" + f" (lines {_span_text(removed_run)})", + enabled.location(), + ) + ) + return conflicts + + +def _overlaps(a: tuple[int, int], b: tuple[int, int]) -> bool: + a_end = a[0] + max(a[1], 1) - 1 + b_end = b[0] + max(b[1], 1) - 1 + return a[0] <= b_end and b[0] <= a_end + + +def _contained(inner: tuple[int, int], outer: tuple[int, int]) -> bool: + inner_end = inner[0] + max(inner[1], 1) - 1 + outer_end = outer[0] + max(outer[1], 1) - 1 + return outer[0] <= inner[0] and inner_end <= outer_end and inner != outer + + +def _span_text(span: tuple[int, int]) -> str: + start, count = span + return f"{start}-{start + max(count, 1) - 1}" diff --git a/git_feature/domain/derivation/putback.py b/git_feature/domain/derivation/putback.py new file mode 100644 index 0000000..adc053f --- /dev/null +++ b/git_feature/domain/derivation/putback.py @@ -0,0 +1,152 @@ +"""Putback: remap view-space edits through a derivation manifest onto the source tree.""" + +from dataclasses import dataclass, field + +from ...gitio import FileDiff, Hunk +from ...store.types import DerivationManifest +from .pipeline import Conflict + + +@dataclass(frozen=True) +class MappedEdit: + """One hunk translated from view coordinates to source coordinates.""" + + path: str + view_span: tuple[int, int] + source_span: tuple[int, int] + + +@dataclass +class PutbackPlan: + """Everything needed to transplant view edits onto the source commit.""" + + replacements: dict[str, bytes] = field(default_factory=dict) + additions: list[str] = field(default_factory=list) + edits: list[MappedEdit] = field(default_factory=list) + conflicts: list[Conflict] = field(default_factory=list) + + +def plan_putback( + manifest: DerivationManifest, + file_diffs: list[FileDiff], + base_content: dict[str, bytes | None], + workdir_content: dict[str, bytes], +) -> PutbackPlan: + """Map every edited file's hunks from view coordinates to source coordinates. + + `base_content` holds each changed path's bytes at the manifest's base commit + (None if absent there); `workdir_content` holds the edited view's bytes. + """ + plan = PutbackPlan() + for file_diff in file_diffs: + path = file_diff.new_path or file_diff.old_path + if path is None: + continue + if file_diff.status in ("A", "?"): # added in the view, or untracked-new + plan.replacements[path] = workdir_content[path] + plan.additions.append(path) + continue + if file_diff.status != "M": + plan.conflicts.append( + Conflict( + "unsupported-change", + f"{path}: only edits and new files can be put back" + f" (change status {file_diff.status!r})", + path, + ) + ) + continue + if not file_diff.hunks: + plan.conflicts.append( + Conflict("unsupported-change", f"{path}: binary or metadata change", path) + ) + continue + base_raw = base_content.get(path) + if base_raw is None: + plan.conflicts.append( + Conflict("putback", f"{path}: not found at the view's base commit", path) + ) + continue + _map_file( + plan, + path, + base_raw, + workdir_content[path], + manifest.removed_runs(path), + file_diff.hunks, + ) + return plan + + +def _map_file( + plan: PutbackPlan, + path: str, + base_raw: bytes, + edited_raw: bytes, + runs: list[tuple[int, int]], + hunks: tuple[Hunk, ...], +) -> None: + """Splice one file's edited view lines into its base content at mapped positions.""" + base_lines = base_raw.decode("utf-8").splitlines(keepends=True) + edited_lines = edited_raw.decode("utf-8").splitlines(keepends=True) + removed: set[int] = set() + for start, count in runs: + removed.update(range(start, start + count)) + # kept[k-1] = the base line number that appears as view line k + kept = [number for number in range(1, len(base_lines) + 1) if number not in removed] + + # (source_start, delete_count, replacement_lines), collected then applied bottom-up + operations: list[tuple[int, int, list[str]]] = [] + for hunk in hunks: + old_start, old_count = hunk.old_span + new_start, new_count = hunk.new_span + replacement = edited_lines[new_start - 1 : new_start - 1 + new_count] + if old_count == 0: + # pure insertion after view line `old_start` (0 = top of file) + if old_start > len(kept): + plan.conflicts.append( + Conflict( + "putback", + f"{path}: view line {old_start} does not map onto the view's" + " derivation — re-open the view", + path, + ) + ) + return + anchor = kept[old_start - 1] if old_start >= 1 else 0 + operations.append((anchor + 1, 0, replacement)) + source_span = (anchor + 1, 0) + else: + if old_start < 1 or old_start + old_count - 1 > len(kept): + plan.conflicts.append( + Conflict( + "putback", + f"{path}: view lines {_span_text(hunk.old_span)} do not map onto" + " the view's derivation — re-open the view", + path, + ) + ) + return + positions = kept[old_start - 1 : old_start - 1 + old_count] + if positions[-1] - positions[0] + 1 != old_count: + plan.conflicts.append( + Conflict( + "removed-region", + f"{path}: edit at view lines {_span_text(hunk.old_span)} crosses" + " a region removed from this variant", + path, + ) + ) + return + operations.append((positions[0], old_count, replacement)) + source_span = (positions[0], old_count) + plan.edits.append(MappedEdit(path, hunk.old_span, source_span)) + + for start, delete_count, replacement in sorted(operations, reverse=True): + base_lines[start - 1 : start - 1 + delete_count] = replacement + plan.replacements[path] = "".join(base_lines).encode("utf-8") + + +def _span_text(span: tuple[int, int]) -> str: + start, count = span + return f"{start}-{start + max(count, 1) - 1}" if count > 1 else str(start) diff --git a/git_feature/domain/derivation/service.py b/git_feature/domain/derivation/service.py new file mode 100644 index 0000000..6783f41 --- /dev/null +++ b/git_feature/domain/derivation/service.py @@ -0,0 +1,95 @@ +"""Variant materialization driver shared by checkout, view, and putback.""" + +from dataclasses import dataclass + +import pygit2 + +from ...gitio import compare_and_swap_ref, create_commit, read_ref +from ...store.base import Store +from ...store.types import DerivationManifest +from .materialize import materialize_tree, verify_tree +from .pipeline import Conflict, Projection + + +def variant_ref(instance: str) -> str: + """The branch ref a variant is materialized on.""" + return f"refs/heads/variant/{instance}" + + +class ProjectionConflicts(Exception): + """The projection reported conflicts; nothing was materialized.""" + + def __init__(self, projection: Projection) -> None: + super().__init__(f"{len(projection.conflicts)} conflict(s)") + self.projection = projection + + +class VariantExists(Exception): + """The variant branch already exists and rebuilding was not requested.""" + + def __init__(self, instance: str) -> None: + super().__init__(f"variant {instance} already exists") + self.instance = instance + + +class VerificationFailed(Exception): + """The derived tree failed verification; the variant ref was not moved.""" + + def __init__(self, conflicts: list[Conflict], refname: str) -> None: + super().__init__(f"verification failed — {refname} was not updated") + self.conflicts = conflicts + self.refname = refname + + +@dataclass +class Derivation: + """Outcome of materializing a projection onto the variant branch.""" + + sha: str + manifest: DerivationManifest + up_to_date: bool + + +def materialize_variant( + repo: pygit2.Repository, store: Store, projection: Projection, *, refresh: bool +) -> Derivation: + """Stages 3–4: build, commit, verify, and persist the variant for a clean projection.""" + if projection.conflicts: + raise ProjectionConflicts(projection) + + instance = projection.instance + refname = variant_ref(instance) + current_tip = read_ref(repo, refname) + manifest = projection.manifest() + + if current_tip is not None and _up_to_date(store, instance, manifest): + return Derivation(sha=current_tip, manifest=manifest, up_to_date=True) + if current_tip is not None and not refresh: + raise VariantExists(instance) + + tree = materialize_tree(repo, projection) + parents = [current_tip] if current_tip else [] + sha = create_commit( + repo, tree, f"derive variant {instance} from {projection.base_sha[:12]}", parents + ) + + conflicts = verify_tree(repo, store, sha, projection.base_sha, projection.decisions) + if conflicts: + raise VerificationFailed(conflicts, refname) + + compare_and_swap_ref(repo, refname, sha, expected_old=current_tip) + store.write_manifest(instance, manifest) + store.commit(f"derive variant {instance}") + return Derivation(sha=sha, manifest=manifest, up_to_date=False) + + +def _up_to_date(store: Store, instance: str, manifest: DerivationManifest) -> bool: + existing = store.read_manifest(instance) + if existing is None: + return False + return ( + existing.base_commit == manifest.base_commit + and existing.assignment == manifest.assignment + and [d.model_dump(exclude={"confidence"}) for d in existing.decisions] + == [d.model_dump(exclude={"confidence"}) for d in manifest.decisions] + ) diff --git a/test/fixtures/__init__.py b/git_feature/domain/query/__init__.py similarity index 100% rename from test/fixtures/__init__.py rename to git_feature/domain/query/__init__.py diff --git a/git_feature/domain/query/catalog.py b/git_feature/domain/query/catalog.py new file mode 100644 index 0000000..9c69d81 --- /dev/null +++ b/git_feature/domain/query/catalog.py @@ -0,0 +1,30 @@ +"""Catalog and annotation lookups shared by the query commands.""" + +import re +from collections.abc import Iterator + +from ...store.base import Store +from ...store.types import Annotation + +_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*") + + +def presence_features(presence: str) -> set[str]: + """Feature names referenced by a presence condition (token scan until the algebra lands).""" + return set(_IDENT.findall(presence)) + + +def iter_annotations(store: Store) -> Iterator[tuple[str, Annotation]]: + """Yield (change_id, annotation) for every stored annotation.""" + for change_id in store.list_annotated_changes(): + for annotation in store.read_annotations(change_id): + yield change_id, annotation + + +def feature_annotations(store: Store, name: str) -> list[tuple[str, Annotation]]: + """All (change_id, annotation) pairs whose presence condition references a feature.""" + return [ + (change_id, annotation) + for change_id, annotation in iter_annotations(store) + if name in presence_features(annotation.presence) + ] diff --git a/git_feature/domain/variability/__init__.py b/git_feature/domain/variability/__init__.py new file mode 100644 index 0000000..1610097 --- /dev/null +++ b/git_feature/domain/variability/__init__.py @@ -0,0 +1,22 @@ +"""Feature model (Clafer subset) parsing and instance resolution.""" + +from .ast import Clafer, Expr, Module, referenced_features, render +from .evaluator import Configuration, ConfigurationError, Evaluator, ModelError +from .parser import ModelParseError, parse_model +from .source import MODEL_FILE, model_text + +__all__ = [ + "MODEL_FILE", + "Clafer", + "Configuration", + "ConfigurationError", + "Evaluator", + "Expr", + "ModelError", + "ModelParseError", + "Module", + "model_text", + "parse_model", + "referenced_features", + "render", +] diff --git a/git_feature/domain/variability/ast.py b/git_feature/domain/variability/ast.py new file mode 100644 index 0000000..24ae136 --- /dev/null +++ b/git_feature/domain/variability/ast.py @@ -0,0 +1,99 @@ +"""AST for the Clafer feature-modeling subset.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Ref: + name: str + + +@dataclass(frozen=True) +class Not: + operand: Expr + + +@dataclass(frozen=True) +class And: + left: Expr + right: Expr + + +@dataclass(frozen=True) +class Or: + left: Expr + right: Expr + + +@dataclass(frozen=True) +class Xor: + left: Expr + right: Expr + + +@dataclass(frozen=True) +class Implies: + left: Expr + right: Expr + + +@dataclass(frozen=True) +class Iff: + left: Expr + right: Expr + + +Expr = Ref | Not | And | Or | Xor | Implies | Iff + + +@dataclass +class Clafer: + """One node of the feature tree (or an instance declaration).""" + + name: str + is_abstract: bool = False + gcard: str | None = None + super_type: str | None = None + card: str | None = None + constraints: list[Expr] = field(default_factory=list) + children: list[Clafer] = field(default_factory=list) + + +@dataclass +class Module: + """A parsed model.cfr: top-level clafers and free-standing constraints.""" + + clafers: list[Clafer] = field(default_factory=list) + constraints: list[Expr] = field(default_factory=list) + + +def render(expr: Expr) -> str: + """Human-readable form of an expression, for error messages.""" + match expr: + case Ref(name): + return name + case Not(operand): + return f"!{render(operand)}" + case And(left, right): + return f"({render(left)} && {render(right)})" + case Or(left, right): + return f"({render(left)} || {render(right)})" + case Xor(left, right): + return f"({render(left)} xor {render(right)})" + case Implies(left, right): + return f"({render(left)} => {render(right)})" + case Iff(left, right): + return f"({render(left)} <=> {render(right)})" + + +def referenced_features(expr: Expr) -> set[str]: + """Every feature name mentioned in an expression.""" + match expr: + case Ref(name): + return {name} + case Not(operand): + return referenced_features(operand) + case And(a, b) | Or(a, b) | Xor(a, b) | Implies(a, b) | Iff(a, b): + return referenced_features(a) | referenced_features(b) diff --git a/git_feature/domain/variability/clafer.lark b/git_feature/domain/variability/clafer.lark new file mode 100644 index 0000000..b747ed6 --- /dev/null +++ b/git_feature/domain/variability/clafer.lark @@ -0,0 +1,56 @@ +// Clafer feature-modeling subset: feature tree, cardinalities, group +// cardinalities, abstract models + instances, boolean constraints. +// Anything beyond this (enums, references, arithmetic, quantifiers, joins) +// is deliberately not in the grammar and fails to parse. + +module: declaration* + +?declaration: clafer | constraint + +clafer: ABSTRACT? gcard? NAME super_ref? card? elements? +super_ref: ":" NAME +elements: "{" declaration* "}" + +gcard: XOR | OR_KW | MUX | OPT_KW | ncard +// A range between two clafer names ("a 1..3 b") is ambiguous: card of the +// previous clafer vs gcard of the next. The pest original resolved it greedily +// as card; the rule priority reproduces that choice. +card.2: QMARK | PLUS | STAR | ncard | INT +ncard: INT ".." (INT | STAR) + +constraint: "[" expr "]" + +?expr: iff_expr +?iff_expr: implies_expr + | iff_expr "<=>" implies_expr -> iff +?implies_expr: or_expr + | implies_expr "=>" or_expr -> implies +?or_expr: xor_expr + | or_expr "||" xor_expr -> or_ +?xor_expr: and_expr + | xor_expr XOR and_expr -> xor +?and_expr: unary_expr + | and_expr "&&" unary_expr -> and_ +?unary_expr: atom + | "!" unary_expr -> not_ + | NOT_KW unary_expr -> not_ +?atom: NAME -> ref + | "(" expr ")" + +// Keywords outrank NAME; \b keeps them from matching inside identifiers +// (a feature may be called "optimize" but not literally "opt"). +ABSTRACT.2: /abstract\b/ +XOR.2: /xor\b/ +OR_KW.2: /or\b/ +MUX.2: /mux\b/ +OPT_KW.2: /opt\b/ +NOT_KW.2: /not\b|no\b/ +QMARK: "?" +PLUS: "+" +STAR: "*" +NAME: /[A-Za-z][A-Za-z0-9_']*/ +INT: /[0-9]+/ + +%ignore /[ \t\r\n]+/ +%ignore /\/\/[^\n]*/ +%ignore /\/\*([^*]|\*(?!\/))*\*\// diff --git a/git_feature/domain/variability/evaluator.py b/git_feature/domain/variability/evaluator.py new file mode 100644 index 0000000..8b74b0d --- /dev/null +++ b/git_feature/domain/variability/evaluator.py @@ -0,0 +1,323 @@ +"""Three-valued instance resolution over the feature tree (port of the Rust evaluator).""" + +from dataclasses import dataclass, field + +from .ast import And, Clafer, Expr, Iff, Implies, Module, Not, Or, Ref, Xor, render + +Assignment = dict[str, bool | None] + + +class ModelError(Exception): + """The model itself is unusable for the request (unknown instance, missing super).""" + + +class ConfigurationError(Exception): + """The requested configuration violates the model's constraints.""" + + +@dataclass +class Configuration: + """A resolved instance: concrete leaf features classified as included or excluded.""" + + included: set[str] = field(default_factory=set) + excluded: set[str] = field(default_factory=set) + + +class Evaluator: + """Resolves named instances of an abstract feature model into configurations.""" + + def __init__(self, module: Module) -> None: + self.module = module + self.registry: dict[str, Clafer] = {} + self.duplicates: list[str] = [] + for clafer in module.clafers: + self._index(clafer) + + def _index(self, clafer: Clafer) -> None: + if clafer.name in self.registry: + self.duplicates.append(clafer.name) + self.registry[clafer.name] = clafer + for child in clafer.children: + self._index(child) + + def resolve_instance(self, instance_name: str) -> Configuration: + """Classify the concrete leaf features of an instance as included/excluded.""" + assignment, supertype = self._resolve(instance_name) + config = Configuration() + for name in assignment: + if name == supertype.name: + continue + clafer = self.registry.get(name) + if clafer is None or clafer.is_abstract or clafer.children: + continue + if assignment.get(name) is True: + config.included.add(name) + else: + config.excluded.add(name) + return config + + def resolve_assignment(self, instance_name: str) -> dict[str, bool]: + """Total feature → bool map over the model subtree (undetermined = excluded).""" + assignment, supertype = self._resolve(instance_name) + return { + name: value is True for name, value in assignment.items() if name != supertype.name + } | {supertype.name: True} + + def _resolve(self, instance_name: str) -> tuple[Assignment, Clafer]: + instance = self.registry.get(instance_name) + if instance is None: + raise ModelError(f"instance '{instance_name}' not found in model") + if instance.super_type is None: + raise ModelError(f"instance '{instance_name}' has no super type") + supertype = self.registry.get(instance.super_type) + if supertype is None: + raise ModelError( + f"super type '{instance.super_type}' of instance '{instance_name}' not found" + ) + + subtree_names: set[str] = set() + self._collect_subtree_names(supertype, subtree_names) + assignment: Assignment = dict.fromkeys(subtree_names) + conflicts: list[str] = [] + _try_assign(assignment, supertype.name, True, conflicts) + + # Explicit instance-body selections are seeded first so they win over inference. + instance_constraints: list[Expr] = [] + _collect_constraints(instance, instance_constraints) + for expr in instance_constraints: + _propagate_expr(expr, True, assignment, conflicts) + + constraints = list(instance_constraints) + _collect_constraints(supertype, constraints) + + while True: + changed = self._propagate_mandatory_children(subtree_names, assignment, conflicts) + changed |= self._propagate_group_cardinality(subtree_names, assignment, conflicts) + for expr in constraints: + changed |= _propagate_expr(expr, True, assignment, conflicts) + if not changed: + break + + self._check_legality(instance_name, constraints, assignment, conflicts, subtree_names) + return assignment, supertype + + def _check_legality( + self, + instance_name: str, + constraints: list[Expr], + assignment: Assignment, + conflicts: list[str], + subtree_names: set[str], + ) -> None: + """Hard-fail on any violated constraint, conflicting inference, or broken group.""" + problems = list(conflicts) + for expr in constraints: + if _eval_expr(expr, assignment) is False: + problems.append(f"constraint {render(expr)} is violated") + total: Assignment = {name: value is True for name, value in assignment.items()} + for name in subtree_names: + clafer = self.registry.get(name) + if clafer is None or clafer.gcard is None or total.get(name) is not True: + continue + active = sum(1 for child in clafer.children if total.get(child.name) is True) + minimum, maximum = _gcard_bounds(clafer.gcard) + if active < minimum or (maximum is not None and active > maximum): + problems.append( + f"group '{name}' ({clafer.gcard}) has {active} active children," + f" allowed {minimum}..{maximum if maximum is not None else '*'}" + ) + if problems: + details = "; ".join(problems) + raise ConfigurationError(f"configuration '{instance_name}' is illegal: {details}") + + def _collect_subtree_names(self, root: Clafer, into: set[str]) -> None: + into.add(root.name) + for child in root.children: + self._collect_subtree_names(child, into) + + def _propagate_mandatory_children( + self, subtree_names: set[str], assignment: Assignment, conflicts: list[str] + ) -> bool: + changed = False + for name in subtree_names: + if assignment.get(name) is not True: + continue + clafer = self.registry.get(name) + if clafer is None or clafer.gcard is not None: + continue + for child in clafer.children: + if _is_mandatory_card(child.card): + changed |= _try_assign(assignment, child.name, True, conflicts) + return changed + + def _propagate_group_cardinality( + self, subtree_names: set[str], assignment: Assignment, conflicts: list[str] + ) -> bool: + changed = False + for name in subtree_names: + if assignment.get(name) is not True: + continue + clafer = self.registry.get(name) + if clafer is None or clafer.gcard is None: + continue + minimum, maximum = _gcard_bounds(clafer.gcard) + children = [child.name for child in clafer.children] + true_count = sum(1 for child in children if assignment.get(child) is True) + unknown = [child for child in children if assignment.get(child) is None] + if maximum is not None and true_count >= maximum: + for child in unknown: + changed |= _try_assign(assignment, child, False, conflicts) + continue + if minimum > 0 and true_count + len(unknown) == minimum: + for child in unknown: + changed |= _try_assign(assignment, child, True, conflicts) + return changed + + +def _collect_constraints(clafer: Clafer, out: list[Expr]) -> None: + out.extend(clafer.constraints) + for child in clafer.children: + _collect_constraints(child, out) + + +def _try_assign(assignment: Assignment, name: str, value: bool, conflicts: list[str]) -> bool: + """Assign only if currently unknown (monotonic); record conflicts and unknown names.""" + if name not in assignment: + conflicts.append(f"reference to unknown feature '{name}'") + return False + current = assignment[name] + if current is None: + assignment[name] = value + return True + if current != value: + conflicts.append(f"conflicting requirements for '{name}' (both on and off)") + return False + + +def _eval_expr(expr: Expr, assignment: Assignment) -> bool | None: + """Three-valued evaluation over a partial assignment.""" + match expr: + case Ref(name): + return assignment.get(name) + case Not(operand): + value = _eval_expr(operand, assignment) + return None if value is None else not value + case And(a, b): + left, right = _eval_expr(a, assignment), _eval_expr(b, assignment) + if left is False or right is False: + return False + if left is True and right is True: + return True + return None + case Or(a, b): + left, right = _eval_expr(a, assignment), _eval_expr(b, assignment) + if left is True or right is True: + return True + if left is False and right is False: + return False + return None + case Implies(a, b): + left, right = _eval_expr(a, assignment), _eval_expr(b, assignment) + if left is False or right is True: + return True + if left is True and right is False: + return False + return None + case Xor(a, b): + left, right = _eval_expr(a, assignment), _eval_expr(b, assignment) + if left is None or right is None: + return None + return left != right + case Iff(a, b): + left, right = _eval_expr(a, assignment), _eval_expr(b, assignment) + if left is None or right is None: + return None + return left == right + + +def _propagate_expr( + expr: Expr, required: bool, assignment: Assignment, conflicts: list[str] +) -> bool: + """Push a required truth value down the expression, asserting what follows.""" + match expr: + case Ref(name): + return _try_assign(assignment, name, required, conflicts) + case Not(operand): + return _propagate_expr(operand, not required, assignment, conflicts) + case And(a, b): + if required: + changed = _propagate_expr(a, True, assignment, conflicts) + return _propagate_expr(b, True, assignment, conflicts) or changed + changed = False + if _eval_expr(a, assignment) is True: + changed |= _propagate_expr(b, False, assignment, conflicts) + if _eval_expr(b, assignment) is True: + changed |= _propagate_expr(a, False, assignment, conflicts) + return changed + case Or(a, b): + if required: + changed = False + if _eval_expr(a, assignment) is False: + changed |= _propagate_expr(b, True, assignment, conflicts) + if _eval_expr(b, assignment) is False: + changed |= _propagate_expr(a, True, assignment, conflicts) + return changed + changed = _propagate_expr(a, False, assignment, conflicts) + return _propagate_expr(b, False, assignment, conflicts) or changed + case Implies(a, b): + if required: + changed = False + if _eval_expr(a, assignment) is True: + changed |= _propagate_expr(b, True, assignment, conflicts) + if _eval_expr(b, assignment) is False: + changed |= _propagate_expr(a, False, assignment, conflicts) + return changed + changed = _propagate_expr(a, True, assignment, conflicts) + return _propagate_expr(b, False, assignment, conflicts) or changed + case Xor(a, b): + changed = False + left, right = _eval_expr(a, assignment), _eval_expr(b, assignment) + if left is not None: + changed |= _propagate_expr(b, left != required, assignment, conflicts) + if right is not None: + changed |= _propagate_expr(a, right != required, assignment, conflicts) + return changed + case Iff(a, b): + changed = False + left, right = _eval_expr(a, assignment), _eval_expr(b, assignment) + if left is not None: + changed |= _propagate_expr(b, left == required, assignment, conflicts) + if right is not None: + changed |= _propagate_expr(a, right == required, assignment, conflicts) + return changed + + +def _is_mandatory_card(card: str | None) -> bool: + """Mandatory means min >= 1: absent, '+', or a range/number with min >= 1.""" + if card is None or card == "+": + return True + if card in ("?", "*"): + return False + minimum = _ncard_min(card) + return True if minimum is None else minimum >= 1 + + +def _gcard_bounds(gcard: str) -> tuple[int, int | None]: + """(min, max) for a group cardinality; max None means unbounded.""" + named = {"xor": (1, 1), "mux": (0, 1), "or": (1, None), "opt": (0, None)} + if gcard in named: + return named[gcard] + return (_ncard_min(gcard) or 0, _ncard_max(gcard)) + + +def _ncard_min(card: str) -> int | None: + head = card.split("..")[0].strip() + return int(head) if head.isdigit() else None + + +def _ncard_max(card: str) -> int | None: + head, sep, tail = card.partition("..") + part = tail.strip() if sep else head.strip() + if part == "*": + return None + return int(part) if part.isdigit() else None diff --git a/git_feature/domain/variability/lint.py b/git_feature/domain/variability/lint.py new file mode 100644 index 0000000..867b2ac --- /dev/null +++ b/git_feature/domain/variability/lint.py @@ -0,0 +1,36 @@ +"""Well-formedness checks for a parsed feature model.""" + +from .ast import Module, referenced_features, render +from .evaluator import Evaluator + + +def model_feature_names(module: Module) -> set[str]: + """Names annotations may reference: every clafer that is not an instance.""" + evaluator = Evaluator(module) + return {name for name, clafer in evaluator.registry.items() if clafer.super_type is None} + + +def instance_names(module: Module) -> list[str]: + """Declared instances (clafers with a super type), in file order.""" + return [clafer.name for clafer in module.clafers if clafer.super_type is not None] + + +def lint_module(module: Module) -> list[str]: + """Structural problems: duplicate names, missing supers, unknown constraint refs.""" + evaluator = Evaluator(module) + problems = [f"duplicate feature name '{name}'" for name in evaluator.duplicates] + for clafer in module.clafers: + if clafer.super_type is not None and clafer.super_type not in evaluator.registry: + problems.append(f"instance '{clafer.name}' extends unknown model '{clafer.super_type}'") + known = set(evaluator.registry) + all_constraints = list(module.constraints) + for clafer in evaluator.registry.values(): + all_constraints.extend(clafer.constraints) + for expr in all_constraints: + unknown = referenced_features(expr) - known + if unknown: + problems.append( + f"constraint {render(expr)} references unknown feature(s):" + f" {', '.join(sorted(unknown))}" + ) + return problems diff --git a/git_feature/domain/variability/parser.py b/git_feature/domain/variability/parser.py new file mode 100644 index 0000000..64196f5 --- /dev/null +++ b/git_feature/domain/variability/parser.py @@ -0,0 +1,135 @@ +"""Clafer subset parsing: Lark grammar → AST.""" + +from dataclasses import dataclass +from importlib import resources + +from lark import Lark, Token, Transformer +from lark.exceptions import UnexpectedInput + +from .ast import And, Clafer, Expr, Iff, Implies, Module, Not, Or, Ref, Xor + + +class ModelParseError(Exception): + """The model text is not valid feature-modeling Clafer.""" + + +@dataclass +class _Constraint: + expr: Expr + + +@dataclass +class _Super: + name: str + + +@dataclass +class _GCard: + value: str + + +@dataclass +class _Card: + value: str + + +@dataclass +class _Elements: + items: list[Clafer | _Constraint] + + +class _ToAst(Transformer): + def ref(self, items: list[Token]) -> Expr: + return Ref(str(items[0])) + + def not_(self, items: list) -> Expr: + return Not(items[-1]) + + def and_(self, items: list) -> Expr: + return And(items[0], items[-1]) + + def or_(self, items: list) -> Expr: + return Or(items[0], items[-1]) + + def xor(self, items: list) -> Expr: + return Xor(items[0], items[-1]) + + def implies(self, items: list) -> Expr: + return Implies(items[0], items[-1]) + + def iff(self, items: list) -> Expr: + return Iff(items[0], items[-1]) + + def constraint(self, items: list) -> _Constraint: + return _Constraint(items[0]) + + def super_ref(self, items: list[Token]) -> _Super: + return _Super(str(items[0])) + + def ncard(self, items: list[Token]) -> str: + return f"{items[0]}..{items[1]}" + + def gcard(self, items: list) -> _GCard: + return _GCard(str(items[0])) + + def card(self, items: list) -> _Card: + return _Card(str(items[0])) + + def elements(self, items: list) -> _Elements: + return _Elements(list(items)) + + def clafer(self, items: list) -> Clafer: + result = Clafer(name="") + for item in items: + match item: + case Token(type="ABSTRACT"): + result.is_abstract = True + case Token(type="NAME"): + result.name = str(item) + case _GCard(value): + result.gcard = value + case _Card(value): + result.card = value + case _Super(name): + result.super_type = name + case _Elements(children): + for child in children: + if isinstance(child, _Constraint): + result.constraints.append(child.expr) + else: + result.children.append(child) + return result + + def module(self, items: list) -> Module: + module = Module() + for item in items: + if isinstance(item, _Constraint): + module.constraints.append(item.expr) + else: + module.clafers.append(item) + return module + + +def _build_parser() -> Lark: + grammar = resources.files(__package__).joinpath("clafer.lark").read_text("utf-8") + return Lark(grammar, start="module", parser="earley") + + +_PARSER: Lark | None = None + + +def parse_model(text: str) -> Module: + """Parse model.cfr text into a Module, or raise ModelParseError.""" + global _PARSER + if _PARSER is None: + _PARSER = _build_parser() + try: + tree = _PARSER.parse(text) + except UnexpectedInput as exc: + context = exc.get_context(text).strip() + raise ModelParseError( + f"not valid feature-model syntax at line {exc.line}, column {exc.column}: {context}" + ) from None + module = _ToAst().transform(tree) + assert isinstance(module, Module) + return module diff --git a/git_feature/domain/variability/presence.py b/git_feature/domain/variability/presence.py new file mode 100644 index 0000000..6d603e1 --- /dev/null +++ b/git_feature/domain/variability/presence.py @@ -0,0 +1,129 @@ +"""Presence-condition algebra: `name`, `!`, `&`, `|`, parentheses.""" + +import re +from collections.abc import Iterable, Mapping +from itertools import product + +from .ast import And, Expr, Not, Or, Ref, referenced_features + +_TOKEN = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_-]*|&&|\|\||[!&|()])") + +_MAX_SAT_FEATURES = 16 + + +class PresenceError(Exception): + """A presence condition is syntactically invalid.""" + + +class UnknownFeatureError(Exception): + """A presence condition references features the model doesn't know.""" + + def __init__(self, names: set[str]) -> None: + self.names = names + super().__init__(f"unknown feature(s): {', '.join(sorted(names))}") + + +def parse_presence(text: str) -> Expr: + """Parse a presence condition into an expression, or raise PresenceError.""" + tokens = _tokenize(text) + expr, position = _parse_or(tokens, 0) + if position != len(tokens): + raise PresenceError(f"unexpected {tokens[position]!r} in presence condition {text!r}") + return expr + + +def eval_presence(expr: Expr, assignment: Mapping[str, bool]) -> bool: + """Evaluate under a total assignment; unknown features are an error, never False.""" + unknown = referenced_features(expr) - set(assignment) + if unknown: + raise UnknownFeatureError(unknown) + return _eval(expr, assignment) + + +def satisfiable(expr: Expr) -> bool: + """Brute-force satisfiability over the referenced features (small conditions only).""" + names = sorted(referenced_features(expr)) + if len(names) > _MAX_SAT_FEATURES: + return True # too large to decide; do not flag + return any( + _eval(expr, dict(zip(names, values, strict=True))) + for values in product((False, True), repeat=len(names)) + ) + + +def lint_presence(text: str, known_features: Iterable[str]) -> list[str]: + """Problems with one stored presence condition: syntax, unknown names, unsatisfiable.""" + try: + expr = parse_presence(text) + except PresenceError as exc: + return [str(exc)] + problems = [] + unknown = referenced_features(expr) - set(known_features) + if unknown: + problems.append(f"{text!r} references unknown feature(s): {', '.join(sorted(unknown))}") + if not satisfiable(expr): + problems.append(f"{text!r} is unsatisfiable (no configuration can enable this region)") + return problems + + +def _eval(expr: Expr, assignment: Mapping[str, bool]) -> bool: + match expr: + case Ref(name): + return assignment.get(name, False) + case Not(operand): + return not _eval(operand, assignment) + case And(a, b): + return _eval(a, assignment) and _eval(b, assignment) + case Or(a, b): + return _eval(a, assignment) or _eval(b, assignment) + case _: + raise PresenceError(f"operator not allowed in a presence condition: {expr}") + + +def _tokenize(text: str) -> list[str]: + tokens = [] + position = 0 + while position < len(text): + match = _TOKEN.match(text, position) + if match is None: + if text[position:].strip(): + raise PresenceError(f"invalid character in presence condition: {text!r}") + break + tokens.append(match.group(1)) + position = match.end() + if not tokens: + raise PresenceError("empty presence condition") + return tokens + + +def _parse_or(tokens: list[str], position: int) -> tuple[Expr, int]: + left, position = _parse_and(tokens, position) + while position < len(tokens) and tokens[position] in ("|", "||"): + right, position = _parse_and(tokens, position + 1) + left = Or(left, right) + return left, position + + +def _parse_and(tokens: list[str], position: int) -> tuple[Expr, int]: + left, position = _parse_unary(tokens, position) + while position < len(tokens) and tokens[position] in ("&", "&&"): + right, position = _parse_unary(tokens, position + 1) + left = And(left, right) + return left, position + + +def _parse_unary(tokens: list[str], position: int) -> tuple[Expr, int]: + if position >= len(tokens): + raise PresenceError("presence condition ends unexpectedly") + token = tokens[position] + if token == "!": + operand, position = _parse_unary(tokens, position + 1) + return Not(operand), position + if token == "(": + expr, position = _parse_or(tokens, position + 1) + if position >= len(tokens) or tokens[position] != ")": + raise PresenceError("missing closing parenthesis in presence condition") + return expr, position + 1 + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", token): + return Ref(token), position + 1 + raise PresenceError(f"unexpected {token!r} in presence condition") diff --git a/git_feature/domain/variability/source.py b/git_feature/domain/variability/source.py new file mode 100644 index 0000000..7fe6272 --- /dev/null +++ b/git_feature/domain/variability/source.py @@ -0,0 +1,20 @@ +"""Reading model.cfr from the working tree or from a commit's tree.""" + +from pathlib import Path + +import pygit2 + +from ...gitio import blob_lines + +MODEL_FILE = "model.cfr" + + +def model_text(repo: pygit2.Repository, rev: str | None = None) -> str | None: + """model.cfr content at a revision (None = working tree); None if absent.""" + if rev is None: + if repo.workdir is None: + return None + path = Path(repo.workdir) / MODEL_FILE + return path.read_text("utf-8") if path.exists() else None + lines = blob_lines(repo, rev, MODEL_FILE) + return None if lines is None else "\n".join(lines) + "\n" diff --git a/git_feature/gitio/__init__.py b/git_feature/gitio/__init__.py new file mode 100644 index 0000000..4b0c4b8 --- /dev/null +++ b/git_feature/gitio/__init__.py @@ -0,0 +1,36 @@ +"""Thin pygit2 wrappers: repository access, diffs, history walks, ref I/O.""" + +from .blame import blob_lines, line_origins, tree_paths +from .diff import FileDiff, Hunk, commit_diff, diff_to_workdir, resolve_commit, worktree_diff +from .history import iter_all_commits, iter_commits, iter_range +from .patchid import patch_id +from .refio import RefUpdateConflict, compare_and_swap_ref, read_ref, write_ref +from .repo import RepositoryNotFound, open_repository +from .tree import blob_data, checkout_branch, cherrypick_tree, create_commit, rewrite_tree + +__all__ = [ + "FileDiff", + "Hunk", + "RefUpdateConflict", + "RepositoryNotFound", + "blob_lines", + "blob_data", + "checkout_branch", + "cherrypick_tree", + "commit_diff", + "compare_and_swap_ref", + "create_commit", + "diff_to_workdir", + "iter_all_commits", + "iter_commits", + "iter_range", + "line_origins", + "open_repository", + "patch_id", + "read_ref", + "resolve_commit", + "rewrite_tree", + "tree_paths", + "worktree_diff", + "write_ref", +] diff --git a/git_feature/gitio/blame.py b/git_feature/gitio/blame.py new file mode 100644 index 0000000..6eff618 --- /dev/null +++ b/git_feature/gitio/blame.py @@ -0,0 +1,56 @@ +"""Blame and tree-content access used by the region resolver.""" + +from collections.abc import Iterator + +import pygit2 + +from .diff import resolve_commit + + +def line_origins(repo: pygit2.Repository, rev: str | pygit2.Commit, path: str) -> list[str] | None: + """Per-line origin commit sha of a file at a revision, or None if the file is absent.""" + commit = resolve_commit(repo, rev) + lines = blob_lines(repo, commit, path) + if lines is None: + return None + blame = repo.blame(path, newest_commit=commit.id) + origins = [""] * len(lines) + for hunk in blame: + for i in range(hunk.lines_in_hunk): + index = hunk.final_start_line_number - 1 + i + if 0 <= index < len(origins): + origins[index] = str(hunk.orig_commit_id) + return origins + + +def blob_lines(repo: pygit2.Repository, rev: str | pygit2.Commit, path: str) -> list[str] | None: + """Text lines of a file at a revision, or None if absent or binary.""" + commit = resolve_commit(repo, rev) + try: + entry = commit.tree[path] + except KeyError: + return None + obj = repo[entry.id] + if not isinstance(obj, pygit2.Blob) or obj.is_binary: + return None + try: + text = obj.data.decode("utf-8") + except UnicodeDecodeError: + return None + return text.splitlines() + + +def tree_paths(repo: pygit2.Repository, rev: str | pygit2.Commit) -> Iterator[str]: + """Yield every file path in a revision's tree.""" + commit = resolve_commit(repo, rev) + yield from _walk_tree(repo, commit.tree, "") + + +def _walk_tree(repo: pygit2.Repository, tree: pygit2.Tree, prefix: str) -> Iterator[str]: + for entry in tree: + obj = repo[entry.id] + name = f"{prefix}{entry.name}" + if isinstance(obj, pygit2.Tree): + yield from _walk_tree(repo, obj, f"{name}/") + elif isinstance(obj, pygit2.Blob): + yield name diff --git a/git_feature/gitio/diff.py b/git_feature/gitio/diff.py new file mode 100644 index 0000000..4be6365 --- /dev/null +++ b/git_feature/gitio/diff.py @@ -0,0 +1,101 @@ +"""Commit diffs exposed as plain data (no pygit2 types leak upward).""" + +from dataclasses import dataclass + +import pygit2 + + +@dataclass(frozen=True) +class Hunk: + """One contiguous change: spans are (start_line, line_count), 1-based.""" + + old_span: tuple[int, int] + new_span: tuple[int, int] + header: str + added_lines: tuple[str, ...] + removed_lines: tuple[str, ...] + + +@dataclass(frozen=True) +class FileDiff: + """All hunks of one file in a commit's diff against its first parent.""" + + old_path: str | None + new_path: str | None + status: str + hunks: tuple[Hunk, ...] + + +def resolve_commit(repo: pygit2.Repository, rev: str | pygit2.Commit) -> pygit2.Commit: + """Resolve a revision string (or pass through a Commit) to a Commit object.""" + if isinstance(rev, pygit2.Commit): + return rev + return repo.revparse_single(str(rev)).peel(pygit2.Commit) + + +def commit_diff( + repo: pygit2.Repository, rev: str | pygit2.Commit, context_lines: int = 0 +) -> list[FileDiff]: + """Diff a commit against its first parent (or the empty tree for a root commit).""" + commit = resolve_commit(repo, rev) + if commit.parents: + diff = repo.diff(commit.parents[0], commit, context_lines=context_lines) + else: + diff = commit.tree.diff_to_tree(swap=True, context_lines=context_lines) + diff.find_similar() + return _convert_diff(diff) + + +def worktree_diff(repo: pygit2.Repository, context_lines: int = 0) -> list[FileDiff]: + """Diff HEAD against the working directory, staged and unstaged changes included.""" + diff = repo.diff("HEAD", context_lines=context_lines) + return _convert_diff(diff) + + +def diff_to_workdir( + repo: pygit2.Repository, rev: str | pygit2.Commit, context_lines: int = 0 +) -> list[FileDiff]: + """Diff a commit's tree against the working directory, untracked files included.""" + commit = resolve_commit(repo, rev) + flags = ( + pygit2.enums.DiffOption.INCLUDE_UNTRACKED + | pygit2.enums.DiffOption.SHOW_UNTRACKED_CONTENT + | pygit2.enums.DiffOption.RECURSE_UNTRACKED_DIRS + ) + diff = repo.diff(str(commit.id), flags=flags, context_lines=context_lines) + return _convert_diff(diff) + + +def _convert_diff(diff: pygit2.Diff) -> list[FileDiff]: + files = [] + for patch in diff: + if patch is None: + continue + delta = patch.delta + hunks = tuple(_convert_hunk(h) for h in patch.hunks) if not delta.is_binary else () + files.append( + FileDiff( + old_path=delta.old_file.path if delta.status_char() != "A" else None, + new_path=delta.new_file.path if delta.status_char() != "D" else None, + status=delta.status_char(), + hunks=hunks, + ) + ) + return files + + +def _convert_hunk(hunk: pygit2.DiffHunk) -> Hunk: + added = [] + removed = [] + for line in hunk.lines: + if line.origin == "+": + added.append(line.content.removesuffix("\n")) + elif line.origin == "-": + removed.append(line.content.removesuffix("\n")) + return Hunk( + old_span=(hunk.old_start, hunk.old_lines), + new_span=(hunk.new_start, hunk.new_lines), + header=hunk.header.removesuffix("\n"), + added_lines=tuple(added), + removed_lines=tuple(removed), + ) diff --git a/git_feature/gitio/history.py b/git_feature/gitio/history.py new file mode 100644 index 0000000..69d3584 --- /dev/null +++ b/git_feature/gitio/history.py @@ -0,0 +1,53 @@ +"""Revision-walk helpers.""" + +from collections.abc import Iterator + +import pygit2 + +from .diff import resolve_commit + + +def iter_commits( + repo: pygit2.Repository, + rev: str | None = None, + exclude: list[str] | None = None, +) -> Iterator[pygit2.Commit]: + """Yield commits reachable from `rev` (default HEAD), newest first.""" + tip = resolve_commit(repo, rev or "HEAD") + walker = repo.walk(tip.id, pygit2.enums.SortMode.TOPOLOGICAL) + for ex in exclude or []: + walker.hide(resolve_commit(repo, ex).id) + yield from walker + + +def iter_range(repo: pygit2.Repository, range_spec: str) -> Iterator[pygit2.Commit]: + """Yield commits in a `base..tip` range, newest first.""" + base, sep, tip = range_spec.partition("..") + if not sep: + raise ValueError(f"not a range: {range_spec!r} (expected 'base..tip')") + return iter_commits(repo, tip or "HEAD", exclude=[base]) + + +def iter_all_commits( + repo: pygit2.Repository, + ignore_prefixes: tuple[str, ...] = ("refs/feature/",), +) -> Iterator[pygit2.Commit]: + """Yield every commit reachable from any reference, deduplicated. + + Refs under `ignore_prefixes` (default: the tool's own store ref) are skipped. + """ + walker = None + for name in repo.references: + if name.startswith(ignore_prefixes): + continue + ref = repo.references[name] + try: + target = ref.peel(pygit2.Commit) + except (pygit2.GitError, pygit2.InvalidSpecError, KeyError): + continue + if walker is None: + walker = repo.walk(target.id, pygit2.enums.SortMode.TOPOLOGICAL) + else: + walker.push(target.id) + if walker is not None: + yield from walker diff --git a/git_feature/gitio/hooks.py b/git_feature/gitio/hooks.py new file mode 100644 index 0000000..19563a7 --- /dev/null +++ b/git_feature/gitio/hooks.py @@ -0,0 +1,90 @@ +"""Chained git hook installation: a dispatcher per hook plus a hook.d directory.""" + +import shlex +import shutil +from pathlib import Path + +import pygit2 + +HOOK_NAMES = ("post-commit", "post-rewrite") +_DISPATCHER_MARKER = "# git-feature chained hook dispatcher" +_HOOK_SCRIPT_MARKER = "# installed by git-feature" + +_DISPATCHER = f"""#!/bin/sh +{_DISPATCHER_MARKER} +# Runs every executable in .d/, feeding each the hook's stdin. +hook_name=$(basename "$0") +hook_dir="$(dirname "$0")/$hook_name.d" +[ -d "$hook_dir" ] || exit 0 +stdin_file=$(mktemp) +trap 'rm -f "$stdin_file"' EXIT +if [ ! -t 0 ]; then cat >"$stdin_file"; fi +status=0 +for hook in "$hook_dir"/*; do + [ -x "$hook" ] || continue + "$hook" "$@" <"$stdin_file" || status=$? +done +exit $status +""" + +_HOOK_SCRIPT = f"""#!/bin/sh +{_HOOK_SCRIPT_MARKER}; safe to delete, reinstalled by 'git feature init'. +# The absolute path is baked in so an unrelated 'git-feature' on PATH is never run. +GIT_FEATURE_BIN={{binary}} +[ -x "$GIT_FEATURE_BIN" ] || GIT_FEATURE_BIN=git-feature +command -v "$GIT_FEATURE_BIN" >/dev/null 2>&1 || exit 0 +"$GIT_FEATURE_BIN" hook-run {{name}} || echo "git-feature: {{name}} hook failed (ignored)" >&2 +exit 0 +""" + + +def hooks_dir(repo: pygit2.Repository) -> Path: + """Return the repository's hooks directory.""" + return Path(repo.path) / "hooks" + + +def install_hooks(repo: pygit2.Repository) -> list[str]: + """Install chained dispatchers + our hook scripts; returns the actions taken.""" + actions = [] + directory = hooks_dir(repo) + directory.mkdir(parents=True, exist_ok=True) + for name in HOOK_NAMES: + hook_path = directory / name + chain_dir = directory / f"{name}.d" + chain_dir.mkdir(exist_ok=True) + if hook_path.exists() and _DISPATCHER_MARKER not in hook_path.read_text(): + preserved = chain_dir / "00-preexisting" + hook_path.rename(preserved) + preserved.chmod(0o755) + actions.append(f"preserved existing {name} hook as {name}.d/00-preexisting") + if not hook_path.exists(): + hook_path.write_text(_DISPATCHER) + hook_path.chmod(0o755) + actions.append(f"installed {name} dispatcher") + our_hook = chain_dir / "50-git-feature" + script = _HOOK_SCRIPT.format(name=name, binary=shlex.quote(_own_binary())) + if not our_hook.exists() or our_hook.read_text() != script: + our_hook.write_text(script) + our_hook.chmod(0o755) + actions.append(f"installed {name}.d/50-git-feature") + return actions + + +def _own_binary() -> str: + """The git-feature executable installing the hooks (PATH lookup as fallback).""" + return shutil.which("git-feature") or "git-feature" + + +def hooks_installed(repo: pygit2.Repository) -> dict[str, bool]: + """Report per hook name whether the dispatcher and our chained script are in place.""" + directory = hooks_dir(repo) + status = {} + for name in HOOK_NAMES: + hook_path = directory / name + our_hook = directory / f"{name}.d" / "50-git-feature" + status[name] = ( + hook_path.is_file() + and _DISPATCHER_MARKER in hook_path.read_text() + and our_hook.is_file() + ) + return status diff --git a/git_feature/gitio/patchid.py b/git_feature/gitio/patchid.py new file mode 100644 index 0000000..0649769 --- /dev/null +++ b/git_feature/gitio/patchid.py @@ -0,0 +1,25 @@ +"""Stable patch-id: hash of a commit's diff with offsets and whitespace normalized.""" + +import hashlib + +import pygit2 + +from .diff import commit_diff + + +def patch_id(repo: pygit2.Repository, rev: str | pygit2.Commit) -> str: + """Return a hex id identifying a commit's diff regardless of position or whitespace.""" + digest = hashlib.sha1(usedforsecurity=False) + for file_diff in commit_diff(repo, rev): + header = f"{file_diff.old_path}\0{file_diff.new_path}\0{file_diff.status}\0" + digest.update(header.encode("utf-8")) + for hunk in file_diff.hunks: + for line in hunk.removed_lines: + digest.update(b"-" + _normalize(line) + b"\0") + for line in hunk.added_lines: + digest.update(b"+" + _normalize(line) + b"\0") + return digest.hexdigest() + + +def _normalize(line: str) -> bytes: + return "".join(line.split()).encode("utf-8") diff --git a/git_feature/gitio/refio.py b/git_feature/gitio/refio.py new file mode 100644 index 0000000..8967c9a --- /dev/null +++ b/git_feature/gitio/refio.py @@ -0,0 +1,33 @@ +"""Reference reading and guarded writing.""" + +import pygit2 + + +class RefUpdateConflict(Exception): + """Raised when a guarded ref update finds an unexpected current target.""" + + +def read_ref(repo: pygit2.Repository, name: str) -> str | None: + """Return the commit sha a ref points to, or None if the ref doesn't exist.""" + ref = repo.references.get(name) + if ref is None: + return None + return str(ref.peel(pygit2.Commit).id) + + +def write_ref(repo: pygit2.Repository, name: str, target: str) -> None: + """Point a ref at a commit, creating or force-updating it.""" + repo.references.create(name, target, force=True) + + +def compare_and_swap_ref( + repo: pygit2.Repository, name: str, target: str, expected_old: str | None +) -> None: + """Update a ref only if it currently points at `expected_old` (None = must not exist).""" + current = read_ref(repo, name) + if current != expected_old: + raise RefUpdateConflict(f"{name}: expected {expected_old}, found {current}") + if current is None: + repo.references.create(name, target) + else: + repo.references[name].set_target(target) diff --git a/git_feature/gitio/repo.py b/git_feature/gitio/repo.py new file mode 100644 index 0000000..9829824 --- /dev/null +++ b/git_feature/gitio/repo.py @@ -0,0 +1,22 @@ +"""Repository discovery and access.""" + +import os +from pathlib import Path + +import pygit2 + + +class RepositoryNotFound(Exception): + """Raised when no git repository can be discovered from the given path.""" + + +def open_repository(path: str | Path | None = None) -> pygit2.Repository: + """Open the repository containing `path` (default: cwd), worktree-safe.""" + start = str(path) if path is not None else os.getcwd() + try: + found = pygit2.discover_repository(start) + except Exception as exc: + raise RepositoryNotFound(f"no git repository found from {start!r}") from exc + if found is None: + raise RepositoryNotFound(f"no git repository found from {start!r}") + return pygit2.Repository(found) diff --git a/git_feature/gitio/tree.py b/git_feature/gitio/tree.py new file mode 100644 index 0000000..3132b6f --- /dev/null +++ b/git_feature/gitio/tree.py @@ -0,0 +1,94 @@ +"""Tree rewriting and commit creation for derived variants.""" + +import pygit2 + +from .diff import resolve_commit + + +def blob_data(repo: pygit2.Repository, rev: str | pygit2.Commit, path: str) -> bytes | None: + """Raw bytes of a file at a revision, or None if absent or not a blob.""" + commit = resolve_commit(repo, rev) + try: + entry = commit.tree[path] + except KeyError: + return None + obj = repo[entry.id] + return bytes(obj.data) if isinstance(obj, pygit2.Blob) else None + + +def rewrite_tree( + repo: pygit2.Repository, rev: str | pygit2.Commit, replacements: dict[str, bytes] +) -> pygit2.Oid: + """A copy of a commit's tree with the given files replaced; all else shared by OID.""" + commit = resolve_commit(repo, rev) + return _rewrite(repo, commit.tree, replacements) + + +def _rewrite( + repo: pygit2.Repository, tree: pygit2.Tree | None, replacements: dict[str, bytes] +) -> pygit2.Oid: + nested: dict[str, dict[str, bytes]] = {} + direct: dict[str, bytes] = {} + for path, data in replacements.items(): + head, sep, rest = path.partition("/") + if sep: + nested.setdefault(head, {})[rest] = data + else: + direct[head] = data + + builder = repo.TreeBuilder(tree) if tree is not None else repo.TreeBuilder() + for name, data in direct.items(): + builder.insert(name, repo.create_blob(data), pygit2.enums.FileMode.BLOB) + for name, sub in nested.items(): + subtree = None + if tree is not None and name in tree: + obj = repo[tree[name].id] + if not isinstance(obj, pygit2.Tree): + raise KeyError(f"'{name}' is not a directory") + subtree = obj + builder.insert(name, _rewrite(repo, subtree, sub), pygit2.enums.FileMode.TREE) + return builder.write() + + +def create_commit( + repo: pygit2.Repository, + tree: pygit2.Oid, + message: str, + parents: list[str], + author: pygit2.Signature | None = None, +) -> str: + """Create a commit object (no ref updated); returns its sha.""" + try: + signature = repo.default_signature + except (KeyError, pygit2.GitError): + signature = pygit2.Signature("git-feature", "git-feature@localhost") + oid = repo.create_commit(None, author or signature, signature, message, tree, parents) + return str(oid) + + +def cherrypick_tree( + repo: pygit2.Repository, rev: str | pygit2.Commit, onto: str | pygit2.Commit +) -> pygit2.Oid | None: + """Tree of replaying one commit's change onto another commit, or None on conflict.""" + commit = resolve_commit(repo, rev) + onto_commit = resolve_commit(repo, onto) + ancestor: pygit2.Tree | pygit2.Oid = ( + commit.parents[0].tree if commit.parents else repo.TreeBuilder().write() + ) + index = repo.merge_trees(ancestor, onto_commit.tree, commit.tree) + if index.conflicts is not None: + return None + return index.write_tree(repo) + + +def checkout_branch(repo: pygit2.Repository, refname: str, force: bool = False) -> None: + """Switch the working tree and HEAD to a branch (safe strategy unless forced).""" + if force: + # A forced checkout diffs against HEAD, so it no-ops (leaving the index + # and workdir stale) when the ref was already moved under HEAD; a hard + # reset rewrites both unconditionally. + target = repo.references[refname].target + repo.set_head(refname) + repo.reset(target, pygit2.enums.ResetMode.HARD) + else: + repo.checkout(refname) diff --git a/git_feature/identity/__init__.py b/git_feature/identity/__init__.py new file mode 100644 index 0000000..25881bb --- /dev/null +++ b/git_feature/identity/__init__.py @@ -0,0 +1,21 @@ +"""Identity layer: change-id map, patch-id reconcile, region anchors + resolver.""" + +from .change_id import ChangeIdMap, backfill, mint_change_id +from .region import ( + RegionResolver, + Resolution, + anchor_from_hunk, + normalized_fingerprint, + original_added_lines, +) + +__all__ = [ + "ChangeIdMap", + "RegionResolver", + "Resolution", + "anchor_from_hunk", + "backfill", + "mint_change_id", + "normalized_fingerprint", + "original_added_lines", +] diff --git a/git_feature/identity/change_id.py b/git_feature/identity/change_id.py new file mode 100644 index 0000000..41bfac8 --- /dev/null +++ b/git_feature/identity/change_id.py @@ -0,0 +1,75 @@ +"""ChangeId minting and the SHA ↔ change-id map over the store.""" + +from collections.abc import Iterator + +import pygit2 +from ulid import ULID + +from ..gitio import iter_all_commits, patch_id +from ..store.base import Store +from ..store.types import ChangeIdRecord + + +def mint_change_id() -> str: + """Return a fresh ULID string.""" + return str(ULID()) + + +class ChangeIdMap: + """Typed access to the SHA ↔ change-id map persisted in a feature store.""" + + def __init__(self, store: Store) -> None: + self._store = store + + def get(self, sha: str) -> ChangeIdRecord | None: + """Return the record for a commit sha, or None if unmapped.""" + return self._store.get_changeid(sha) + + def iter_records(self) -> Iterator[ChangeIdRecord]: + """Yield every record in the map.""" + return self._store.iter_changeids() + + def shas_for(self, change_id: str) -> list[str]: + """Return every sha mapped to a change-id (rewrites and cherry-picks share one).""" + return [r.sha for r in self._store.iter_changeids() if r.change_id == change_id] + + def insert( + self, sha: str, change_id: str | None = None, patch_id: str | None = None + ) -> ChangeIdRecord: + """Map a sha to a change-id (freshly minted unless given).""" + record = ChangeIdRecord(sha=sha, change_id=change_id or mint_change_id(), patch_id=patch_id) + self._store.put_changeid(record) + return record + + def get_or_mint(self, sha: str, patch_id: str | None = None) -> tuple[ChangeIdRecord, bool]: + """Return the record for a sha, minting one lazily; True if newly minted.""" + existing = self.get(sha) + if existing is not None: + return existing, False + return self.insert(sha, patch_id=patch_id), True + + def migrate(self, old_sha: str, new_sha: str, patch_id: str | None = None) -> bool: + """Carry an old sha's change-id over to its rewritten sha; False if old is unmapped. + + Overwrites any existing mapping of the new sha: on amend, git fires + post-commit (minting a stray id for the new sha) before post-rewrite, + and git's explicit old→new notification is the authoritative identity. + """ + old = self.get(old_sha) + if old is None: + return False + existing = self.get(new_sha) + if existing is None or existing.change_id != old.change_id: + self.insert(new_sha, change_id=old.change_id, patch_id=patch_id) + return True + + +def backfill(repo: pygit2.Repository, cid_map: ChangeIdMap) -> int: + """Mint change-ids + patch-ids for every unmapped commit reachable from any ref.""" + minted = 0 + for commit in iter_all_commits(repo): + sha = str(commit.id) + if cid_map.get(sha) is None: + cid_map.insert(sha, patch_id=patch_id(repo, commit)) + minted += 1 + return minted diff --git a/git_feature/identity/reconcile.py b/git_feature/identity/reconcile.py new file mode 100644 index 0000000..576d0c9 --- /dev/null +++ b/git_feature/identity/reconcile.py @@ -0,0 +1,120 @@ +"""Match unmapped commits to known change-ids by patch-id, then fuzzy similarity.""" + +from dataclasses import dataclass, field +from difflib import SequenceMatcher + +import pygit2 + +from ..gitio import commit_diff, iter_all_commits, patch_id +from .change_id import ChangeIdMap + +AUTO_LINK_THRESHOLD = 0.85 +CANDIDATE_THRESHOLD = 0.6 + + +@dataclass(frozen=True) +class Link: + """A planned sha → existing change-id association.""" + + sha: str + change_id: str + via: str # "patch-id" or "fuzzy" + score: float + patch_id: str + + +@dataclass(frozen=True) +class Ambiguity: + """An unmapped commit with several plausible change-id candidates.""" + + sha: str + candidates: tuple[tuple[str, float], ...] + patch_id: str + + +@dataclass +class ReconcilePlan: + """Outcome of the matching pass, before anything is written.""" + + links: list[Link] = field(default_factory=list) + ambiguous: list[Ambiguity] = field(default_factory=list) + unmatched: list[str] = field(default_factory=list) + + +def plan_reconcile(repo: pygit2.Repository, cid_map: ChangeIdMap) -> ReconcilePlan: + """Compute links for unmapped reachable commits without writing anything.""" + known = _known_patch_ids(repo, cid_map) + plan = ReconcilePlan() + for commit in iter_all_commits(repo): + sha = str(commit.id) + if cid_map.get(sha) is not None: + continue + pid = patch_id(repo, commit) + exact = sorted(known.get(pid, ())) + if len(exact) == 1: + plan.links.append(Link(sha, exact[0], via="patch-id", score=1.0, patch_id=pid)) + continue + if len(exact) > 1: + candidates = tuple((cid, 1.0) for cid in exact) + plan.ambiguous.append(Ambiguity(sha, candidates, patch_id=pid)) + continue + _fuzzy_match(repo, cid_map, sha, pid, plan) + return plan + + +def apply_links(cid_map: ChangeIdMap, links: list[Link]) -> None: + """Write the planned links into the change-id map.""" + for link in links: + cid_map.insert(link.sha, change_id=link.change_id, patch_id=link.patch_id) + + +def _fuzzy_match( + repo: pygit2.Repository, cid_map: ChangeIdMap, sha: str, pid: str, plan: ReconcilePlan +) -> None: + text = _added_text(repo, sha) + if not text: + plan.unmatched.append(sha) + return + scores: dict[str, float] = {} + for record in cid_map.iter_records(): + if record.sha not in repo: + continue + other = _added_text(repo, record.sha) + if not other: + continue + score = SequenceMatcher(None, text, other).ratio() + if score > scores.get(record.change_id, 0.0): + scores[record.change_id] = score + ranked = sorted( + ((cid, s) for cid, s in scores.items() if s >= CANDIDATE_THRESHOLD), + key=lambda pair: -pair[1], + ) + if ranked and ranked[0][1] >= AUTO_LINK_THRESHOLD: + best_cid, best_score = ranked[0] + runner_up = ranked[1][1] if len(ranked) > 1 else 0.0 + if runner_up < AUTO_LINK_THRESHOLD: + plan.links.append(Link(sha, best_cid, via="fuzzy", score=best_score, patch_id=pid)) + return + if ranked: + plan.ambiguous.append(Ambiguity(sha, tuple(ranked), patch_id=pid)) + else: + plan.unmatched.append(sha) + + +def _known_patch_ids(repo: pygit2.Repository, cid_map: ChangeIdMap) -> dict[str, set[str]]: + index: dict[str, set[str]] = {} + for record in cid_map.iter_records(): + pid = record.patch_id + if pid is None and record.sha in repo: + pid = patch_id(repo, record.sha) + if pid is not None: + index.setdefault(pid, set()).add(record.change_id) + return index + + +def _added_text(repo: pygit2.Repository, sha: str) -> str: + lines: list[str] = [] + for file_diff in commit_diff(repo, sha): + for hunk in file_diff.hunks: + lines.extend(line.strip() for line in hunk.added_lines) + return "\n".join(lines) diff --git a/git_feature/identity/region.py b/git_feature/identity/region.py new file mode 100644 index 0000000..71c433f --- /dev/null +++ b/git_feature/identity/region.py @@ -0,0 +1,209 @@ +"""Region anchors and the blame-forward resolver (tiers 1–2).""" + +from collections.abc import Sequence +from dataclasses import dataclass +from difflib import SequenceMatcher + +import pygit2 +import xxhash + +from ..gitio import ( + Hunk, + blob_lines, + commit_diff, + line_origins, + resolve_commit, + tree_paths, +) +from ..store.types import Confidence, RegionAnchor +from .change_id import ChangeIdMap + +FUZZY_THRESHOLD = 0.75 + + +def normalized_fingerprint(lines: Sequence[str]) -> str: + """Whitespace-insensitive content hash of a sequence of lines.""" + joined = "\n".join("".join(line.split()) for line in lines) + return xxhash.xxh3_64_hexdigest(joined) + + +def anchor_from_hunk(change_id: str, path: str, hunk: Hunk) -> RegionAnchor: + """Anchor a hunk to the change that introduced it.""" + return RegionAnchor( + change_id=change_id, + path=path, + old_span=hunk.old_span, + new_span=hunk.new_span, + fingerprint=normalized_fingerprint(hunk.added_lines), + ) + + +@dataclass(frozen=True) +class Resolution: + """Where a region lives at a target commit, and how sure we are. + + `span` is the overall extent; `runs` are the exact surviving line runs + (a region scattered by interleaved edits has several). `path` is set only + when the region resolved in a different file than it was anchored to + (the anchored file was renamed or the content moved). + """ + + span: tuple[int, int] | None + confidence: Confidence + runs: tuple[tuple[int, int], ...] | None = None + path: str | None = None + + def line_runs(self) -> tuple[tuple[int, int], ...]: + if self.runs: + return self.runs + return (self.span,) if self.span else () + + +class RegionResolver: + """Answers: which lines does an anchored region occupy at a target commit?""" + + def __init__(self, repo: pygit2.Repository, cid_map: ChangeIdMap) -> None: + self._repo = repo + self._map = cid_map + self._cache: dict[tuple[str, str, tuple[int, int], str, str], Resolution] = {} + self._origins_cache: dict[tuple[str, str], list[str] | None] = {} + self._rename_cache: dict[tuple[str, str], dict[str, str]] = {} + + def resolve(self, anchor: RegionAnchor, target: str | pygit2.Commit) -> Resolution: + commit = resolve_commit(self._repo, target) + key = (anchor.change_id, anchor.path, anchor.new_span, anchor.fingerprint, str(commit.id)) + cached = self._cache.get(key) + if cached is not None: + return cached + resolution = ( + self._blame_forward(anchor, commit) + or self._fingerprint_search(anchor, commit) + or Resolution(None, "lost") + ) + self._cache[key] = resolution + return resolution + + def _blame_forward(self, anchor: RegionAnchor, commit: pygit2.Commit) -> Resolution | None: + origins = self._line_origins(anchor.path, commit) + if origins is None: + return None + shas = set(self._map.shas_for(anchor.change_id)) + mine = [i + 1 for i, sha in enumerate(origins) if sha in shas] + if not mine: + return None + lines = blob_lines(self._repo, commit, anchor.path) or [] + length = anchor.new_span[1] + runs = _contiguous_runs(mine) + for run in runs: + for start in range(run[0], run[0] + len(run) - length + 1): + window = lines[start - 1 : start - 1 + length] + if normalized_fingerprint(window) == anchor.fingerprint: + return Resolution((start, length), "exact") + return Resolution( + (mine[0], mine[-1] - mine[0] + 1), + "moved", + runs=tuple((run[0], len(run)) for run in runs), + ) + + def _line_origins(self, path: str, commit: pygit2.Commit) -> list[str] | None: + """One blame pass per (path, target commit), shared by every anchor.""" + key = (path, str(commit.id)) + if key not in self._origins_cache: + self._origins_cache[key] = line_origins(self._repo, commit, path) + return self._origins_cache[key] + + def _renamed_target(self, anchor: RegionAnchor, commit: pygit2.Commit) -> str | None: + """Where the anchored file went if it was renamed before the target commit.""" + if blob_lines(self._repo, commit, anchor.path) is not None: + return None # still there; nothing to follow + for sha in self._map.shas_for(anchor.change_id): + if sha not in self._repo: + continue + key = (sha, str(commit.id)) + if key not in self._rename_cache: + diff = self._repo.diff(sha, str(commit.id)) + diff.find_similar() + self._rename_cache[key] = { + delta.old_file.path: delta.new_file.path + for delta in diff.deltas + if delta.status == pygit2.enums.DeltaStatus.RENAMED + } + target = self._rename_cache[key].get(anchor.path) + if target is not None: + return target + return None + + def _fingerprint_search(self, anchor: RegionAnchor, commit: pygit2.Commit) -> Resolution | None: + length = anchor.new_span[1] + if length == 0: + return None + preferred = [anchor.path] + renamed = self._renamed_target(anchor, commit) + if renamed is not None: + preferred.append(renamed) + paths = preferred + sorted(p for p in tree_paths(self._repo, commit) if p not in preferred) + for path in paths: + lines = blob_lines(self._repo, commit, path) + if lines is None or len(lines) < length: + continue + for start in range(1, len(lines) - length + 2): + window = lines[start - 1 : start - 1 + length] + if normalized_fingerprint(window) == anchor.fingerprint: + return Resolution( + (start, length), + "fuzzy", + path=path if path != anchor.path else None, + ) + return self._similarity_search(anchor, commit, paths) + + def _similarity_search( + self, anchor: RegionAnchor, commit: pygit2.Commit, paths: list[str] + ) -> Resolution | None: + original = self._original_lines(anchor) + if not original: + return None + target_text = "\n".join("".join(line.split()) for line in original) + length = anchor.new_span[1] + best: tuple[float, tuple[int, int], str] | None = None + for path in paths: + lines = blob_lines(self._repo, commit, path) + if lines is None or len(lines) < length: + continue + for start in range(1, len(lines) - length + 2): + window = lines[start - 1 : start - 1 + length] + window_text = "\n".join("".join(line.split()) for line in window) + score = SequenceMatcher(None, target_text, window_text).ratio() + if best is None or score > best[0]: + best = (score, (start, length), path) + if best is not None and best[0] >= FUZZY_THRESHOLD: + return Resolution(best[1], "fuzzy", path=best[2] if best[2] != anchor.path else None) + return None + + def _original_lines(self, anchor: RegionAnchor) -> tuple[str, ...] | None: + return original_added_lines(self._repo, self._map, anchor) + + +def original_added_lines( + repo: pygit2.Repository, cid_map: ChangeIdMap, anchor: RegionAnchor +) -> tuple[str, ...] | None: + """Recover an anchor's added lines from the frozen diff of its change.""" + for sha in cid_map.shas_for(anchor.change_id): + if sha not in repo: + continue + for file_diff in commit_diff(repo, sha): + if file_diff.new_path != anchor.path: + continue + for hunk in file_diff.hunks: + if hunk.new_span == anchor.new_span and hunk.old_span == anchor.old_span: + return hunk.added_lines + return None + + +def _contiguous_runs(line_numbers: list[int]) -> list[list[int]]: + runs: list[list[int]] = [] + for number in line_numbers: + if runs and number == runs[-1][-1] + 1: + runs[-1].append(number) + else: + runs.append([number]) + return runs diff --git a/git_feature/store/__init__.py b/git_feature/store/__init__.py new file mode 100644 index 0000000..9745d29 --- /dev/null +++ b/git_feature/store/__init__.py @@ -0,0 +1,38 @@ +"""Feature store: typed persistence for annotations, features, ids, and manifests.""" + +from .base import Store +from .gitref import STORE_REF, GitRefStore, store_files_at +from .memory import MemoryStore +from .merge import StoreMergeError, merge_stores +from .types import ( + SCHEMA_VERSION, + Annotation, + ChangeIdRecord, + Confidence, + DerivationManifest, + FeatureDef, + RegionAnchor, + RegionDecision, + StoreMeta, + ViewSession, +) + +__all__ = [ + "SCHEMA_VERSION", + "STORE_REF", + "Annotation", + "ChangeIdRecord", + "Confidence", + "DerivationManifest", + "FeatureDef", + "GitRefStore", + "MemoryStore", + "RegionAnchor", + "RegionDecision", + "Store", + "StoreMergeError", + "StoreMeta", + "ViewSession", + "merge_stores", + "store_files_at", +] diff --git a/git_feature/store/base.py b/git_feature/store/base.py new file mode 100644 index 0000000..eea1c2f --- /dev/null +++ b/git_feature/store/base.py @@ -0,0 +1,118 @@ +"""Typed store interface over an abstract path→bytes backend.""" + +from abc import ABC, abstractmethod +from collections.abc import Iterator + +from pydantic import TypeAdapter + +from .types import ( + Annotation, + ChangeIdRecord, + DerivationManifest, + FeatureDef, + StoreMeta, + ViewSession, +) + +_META_PATH = "meta.json" +_ANNOTATIONS = TypeAdapter(list[Annotation]) +_CHANGEID_SHARD = TypeAdapter(dict[str, ChangeIdRecord]) + + +class Store(ABC): + """Feature store: typed reads/writes staged in memory until `commit`.""" + + @abstractmethod + def _read(self, path: str) -> bytes | None: + """Return the content stored at a path, or None.""" + + @abstractmethod + def _write(self, path: str, data: bytes) -> None: + """Stage content for a path.""" + + @abstractmethod + def _list(self, prefix: str) -> list[str]: + """Return all paths under a prefix, sorted.""" + + @abstractmethod + def commit(self, message: str) -> None: + """Persist all staged writes atomically.""" + + def read_meta(self) -> StoreMeta | None: + data = self._read(_META_PATH) + return None if data is None else StoreMeta.model_validate_json(data) + + def write_meta(self, meta: StoreMeta) -> None: + self._write(_META_PATH, _encode(meta.model_dump_json(indent=2))) + + def read_feature(self, name: str) -> FeatureDef | None: + data = self._read(f"features/{name}.json") + return None if data is None else FeatureDef.model_validate_json(data) + + def write_feature(self, feature: FeatureDef) -> None: + self._write(f"features/{feature.name}.json", _encode(feature.model_dump_json(indent=2))) + + def list_features(self) -> list[str]: + return [_basename(p, "features/") for p in self._list("features/")] + + def read_annotations(self, change_id: str) -> list[Annotation]: + data = self._read(f"annotations/{change_id}.json") + return [] if data is None else _ANNOTATIONS.validate_json(data) + + def write_annotations(self, change_id: str, annotations: list[Annotation]) -> None: + data = _ANNOTATIONS.dump_json(annotations, indent=2) + self._write(f"annotations/{change_id}.json", data) + + def list_annotated_changes(self) -> list[str]: + return [_basename(p, "annotations/") for p in self._list("annotations/")] + + def get_changeid(self, sha: str) -> ChangeIdRecord | None: + return self._read_shard(sha).get(sha) + + def put_changeid(self, record: ChangeIdRecord) -> None: + shard = self._read_shard(record.sha) + shard[record.sha] = record + data = _CHANGEID_SHARD.dump_json(dict(sorted(shard.items())), indent=2) + self._write(_shard_path(record.sha), data) + + def iter_changeids(self) -> Iterator[ChangeIdRecord]: + for path in self._list("changeids/"): + data = self._read(path) + if data is not None: + yield from _CHANGEID_SHARD.validate_json(data).values() + + def read_manifest(self, name: str) -> DerivationManifest | None: + data = self._read(f"variants/{name}.json") + return None if data is None else DerivationManifest.model_validate_json(data) + + def write_manifest(self, name: str, manifest: DerivationManifest) -> None: + self._write(f"variants/{name}.json", _encode(manifest.model_dump_json(indent=2))) + + def list_variants(self) -> list[str]: + return [_basename(p, "variants/") for p in self._list("variants/")] + + def read_view(self, name: str) -> ViewSession | None: + data = self._read(f"views/{name}.json") + return None if data is None else ViewSession.model_validate_json(data) + + def write_view(self, session: ViewSession) -> None: + self._write(f"views/{session.instance}.json", _encode(session.model_dump_json(indent=2))) + + def list_views(self) -> list[str]: + return [_basename(p, "views/") for p in self._list("views/")] + + def _read_shard(self, sha: str) -> dict[str, ChangeIdRecord]: + data = self._read(_shard_path(sha)) + return {} if data is None else _CHANGEID_SHARD.validate_json(data) + + +def _shard_path(sha: str) -> str: + return f"changeids/map-{sha[:2]}.json" + + +def _basename(path: str, prefix: str) -> str: + return path.removeprefix(prefix).removesuffix(".json") + + +def _encode(text: str) -> bytes: + return text.encode("utf-8") diff --git a/git_feature/store/gitref.py b/git_feature/store/gitref.py new file mode 100644 index 0000000..78d0b4f --- /dev/null +++ b/git_feature/store/gitref.py @@ -0,0 +1,102 @@ +"""Store backend persisting to a dedicated git ref.""" + +import pygit2 + +from ..gitio import compare_and_swap_ref, read_ref +from .base import Store + +STORE_REF = "refs/feature/store" + + +class GitRefStore(Store): + """Store backend on an orphan commit history under `refs/feature/store`.""" + + def __init__(self, repo: pygit2.Repository, ref_name: str = STORE_REF) -> None: + self._repo = repo + self._ref_name = ref_name + self._base_sha = read_ref(repo, ref_name) + self._files = self._load_files() + self._pending: dict[str, bytes] = {} + + def _read(self, path: str) -> bytes | None: + if path in self._pending: + return self._pending[path] + return self._files.get(path) + + def _write(self, path: str, data: bytes) -> None: + self._pending[path] = data + + def _list(self, prefix: str) -> list[str]: + paths = set(self._files) | set(self._pending) + return sorted(p for p in paths if p.startswith(prefix)) + + def commit(self, message: str) -> None: + merged = {**self._files, **self._pending} + self._commit_files(merged, message, [self._base_sha] if self._base_sha else []) + + @property + def tip(self) -> str | None: + """The store commit this instance was loaded from (None if uninitialized).""" + return self._base_sha + + def committed_files(self) -> dict[str, bytes]: + """Snapshot of the store's committed content (pending writes excluded).""" + return dict(self._files) + + def commit_merge(self, files: dict[str, bytes], message: str, other_parent: str) -> None: + """Replace the store content with `files` as a merge with another store commit.""" + parents = ([self._base_sha] if self._base_sha else []) + [other_parent] + self._commit_files(files, message, parents) + + def _commit_files(self, files: dict[str, bytes], message: str, parents: list[str]) -> None: + tree_oid = _build_tree(self._repo, files) + sig = _signature(self._repo) + new_oid = self._repo.create_commit(None, sig, sig, message, tree_oid, parents) + compare_and_swap_ref(self._repo, self._ref_name, str(new_oid), expected_old=self._base_sha) + self._base_sha = str(new_oid) + self._files = files + self._pending = {} + + def _load_files(self) -> dict[str, bytes]: + if self._base_sha is None: + return {} + return store_files_at(self._repo, self._base_sha) + + +def store_files_at(repo: pygit2.Repository, sha: str) -> dict[str, bytes]: + """Flatten a store commit's tree into path → bytes.""" + commit = repo[sha].peel(pygit2.Commit) + return _flatten_tree(repo, commit.tree) + + +def _flatten_tree(repo: pygit2.Repository, tree: pygit2.Tree, prefix: str = "") -> dict[str, bytes]: + files: dict[str, bytes] = {} + for entry in tree: + obj = repo[entry.id] + name = f"{prefix}{entry.name}" + if isinstance(obj, pygit2.Tree): + files.update(_flatten_tree(repo, obj, f"{name}/")) + elif isinstance(obj, pygit2.Blob): + files[name] = obj.data + return files + + +def _build_tree(repo: pygit2.Repository, files: dict[str, bytes]) -> pygit2.Oid: + builder = repo.TreeBuilder() + subdirs: dict[str, dict[str, bytes]] = {} + for path, data in files.items(): + if "/" in path: + head, rest = path.split("/", 1) + subdirs.setdefault(head, {})[rest] = data + else: + builder.insert(path, repo.create_blob(data), pygit2.enums.FileMode.BLOB) + for name, sub in sorted(subdirs.items()): + builder.insert(name, _build_tree(repo, sub), pygit2.enums.FileMode.TREE) + return builder.write() + + +def _signature(repo: pygit2.Repository) -> pygit2.Signature: + try: + return repo.default_signature + except (KeyError, pygit2.GitError): + return pygit2.Signature("git-feature", "git-feature@localhost") diff --git a/git_feature/store/memory.py b/git_feature/store/memory.py new file mode 100644 index 0000000..dad2a6b --- /dev/null +++ b/git_feature/store/memory.py @@ -0,0 +1,23 @@ +"""In-memory store backend for tests.""" + +from .base import Store + + +class MemoryStore(Store): + """Store backend holding everything in a dict; `commits` records messages.""" + + def __init__(self) -> None: + self._files: dict[str, bytes] = {} + self.commits: list[str] = [] + + def _read(self, path: str) -> bytes | None: + return self._files.get(path) + + def _write(self, path: str, data: bytes) -> None: + self._files[path] = data + + def _list(self, prefix: str) -> list[str]: + return sorted(p for p in self._files if p.startswith(prefix)) + + def commit(self, message: str) -> None: + self.commits.append(message) diff --git a/git_feature/store/merge.py b/git_feature/store/merge.py new file mode 100644 index 0000000..6b3714b --- /dev/null +++ b/git_feature/store/merge.py @@ -0,0 +1,191 @@ +"""Three-way merge of diverged feature stores (union annotations, structured map merge).""" + +from pydantic import TypeAdapter + +from .types import ( + Annotation, + ChangeIdRecord, + DerivationManifest, + FeatureDef, + StoreMeta, + ViewSession, +) + +_ANNOTATIONS = TypeAdapter(list[Annotation]) +_SHARD = TypeAdapter(dict[str, ChangeIdRecord]) + + +class StoreMergeError(Exception): + """The two stores cannot be merged automatically.""" + + +def merge_stores( + base: dict[str, bytes], ours: dict[str, bytes], theirs: dict[str, bytes] +) -> tuple[dict[str, bytes], list[str]]: + """Merge two diverged store trees; returns the merged files and human-readable notes. + + Every rule is deterministic and symmetric, so both machines converge on the + same content: change-ids minted twice keep the older ULID (annotations of the + newer one are re-keyed), annotation lists are unioned, feature definitions are + merged field-wise, and manifests/views keep the newest record. + """ + notes: list[str] = [] + merged: dict[str, bytes] = {} + + merged["meta.json"] = _merge_meta(ours, theirs) + records, remap = _merge_changeids(ours, theirs, notes) + for shard, content in _shards(records).items(): + merged[shard] = content + for path, content in _merge_annotations(ours, theirs, remap).items(): + merged[path] = content + + for path in sorted(set(ours) | set(theirs)): + prefix = path.partition("/")[0] + "/" + if path == "meta.json" or prefix in ("changeids/", "annotations/"): + continue + our_raw, their_raw = ours.get(path), theirs.get(path) + if our_raw == their_raw or their_raw is None: + merged[path] = our_raw # type: ignore[assignment] + elif our_raw is None: + merged[path] = their_raw + elif prefix == "features/": + merged[path] = _merge_feature(base.get(path), our_raw, their_raw, path, notes) + elif prefix == "variants/": + merged[path] = _newest(our_raw, their_raw, DerivationManifest, path, notes) + elif prefix == "views/": + merged[path] = _newest(our_raw, their_raw, ViewSession, path, notes) + else: + merged[path] = our_raw + notes.append(f"{path}: changed on both sides; kept the local version") + return merged, notes + + +def _merge_meta(ours: dict[str, bytes], theirs: dict[str, bytes]) -> bytes: + our_raw, their_raw = ours.get("meta.json"), theirs.get("meta.json") + if our_raw is None or their_raw is None: + raw = our_raw or their_raw + if raw is None: + raise StoreMergeError("neither store has meta.json") + return raw + our_meta = StoreMeta.model_validate_json(our_raw) + their_meta = StoreMeta.model_validate_json(their_raw) + if our_meta.schema_version != their_meta.schema_version: + raise StoreMergeError( + f"schema versions differ ({our_meta.schema_version} vs" + f" {their_meta.schema_version}) — upgrade the tool on both sides first" + ) + return our_raw + + +def _merge_changeids( + ours: dict[str, bytes], theirs: dict[str, bytes], notes: list[str] +) -> tuple[dict[str, ChangeIdRecord], dict[str, str]]: + """Union of both maps; a sha minted twice keeps the older (smaller) ULID.""" + records = _parse_records(ours) + remap: dict[str, str] = {} + for sha, their_record in _parse_records(theirs).items(): + mine = records.get(sha) + if mine is None: + records[sha] = their_record + continue + if mine.change_id == their_record.change_id: + if mine.patch_id is None and their_record.patch_id is not None: + records[sha] = their_record + continue + winner, loser = sorted((mine.change_id, their_record.change_id)) + remap[loser] = winner + kept = mine if mine.change_id == winner else their_record + other = their_record if kept is mine else mine + if kept.patch_id is None and other.patch_id is not None: + kept = kept.model_copy(update={"patch_id": other.patch_id}) + records[sha] = kept + notes.append(f"{sha[:12]}: change-id minted on both sides; kept {winner}") + resolved = { + sha: record.model_copy(update={"change_id": _canonical(record.change_id, remap)}) + for sha, record in records.items() + } + return resolved, remap + + +def _canonical(change_id: str, remap: dict[str, str]) -> str: + while change_id in remap: + change_id = remap[change_id] + return change_id + + +def _parse_records(files: dict[str, bytes]) -> dict[str, ChangeIdRecord]: + records: dict[str, ChangeIdRecord] = {} + for path, raw in files.items(): + if path.startswith("changeids/"): + records.update(_SHARD.validate_json(raw)) + return records + + +def _shards(records: dict[str, ChangeIdRecord]) -> dict[str, bytes]: + shards: dict[str, dict[str, ChangeIdRecord]] = {} + for sha, record in records.items(): + shards.setdefault(f"changeids/map-{sha[:2]}.json", {})[sha] = record + return { + path: _SHARD.dump_json(dict(sorted(shard.items())), indent=2) + for path, shard in shards.items() + } + + +def _merge_annotations( + ours: dict[str, bytes], theirs: dict[str, bytes], remap: dict[str, str] +) -> dict[str, bytes]: + """Union both sides' annotation lists, re-keyed through the change-id remap.""" + combined: dict[str, dict[str, Annotation]] = {} + for side in (ours, theirs): + for path, raw in side.items(): + if not path.startswith("annotations/"): + continue + change_id = _canonical(path.removeprefix("annotations/").removesuffix(".json"), remap) + bucket = combined.setdefault(change_id, {}) + for annotation in _ANNOTATIONS.validate_json(raw): + bucket[annotation.model_dump_json()] = annotation + return { + f"annotations/{change_id}.json": _ANNOTATIONS.dump_json( + sorted(bucket.values(), key=lambda a: (a.ts.isoformat(), a.presence)), indent=2 + ) + for change_id, bucket in combined.items() + } + + +def _merge_feature( + base_raw: bytes | None, our_raw: bytes, their_raw: bytes, path: str, notes: list[str] +) -> bytes: + base = FeatureDef.model_validate_json(base_raw) if base_raw else None + our = FeatureDef.model_validate_json(our_raw) + their = FeatureDef.model_validate_json(their_raw) + description = our.description + if our.description != their.description: + if base is not None and our.description == base.description: + description = their.description + elif base is None or their.description != base.description: + description = min(our.description, their.description, key=lambda d: (len(d), d)) + notes.append(f"{path}: descriptions differ; kept {description!r}") + merged = FeatureDef( + name=our.name, + description=description, + owners=sorted({*our.owners, *their.owners}), + tags=sorted({*our.tags, *their.tags}), + links=sorted({*our.links, *their.links}), + auto_created=our.auto_created and their.auto_created, + ) + return merged.model_dump_json(indent=2).encode("utf-8") + + +def _newest( + our_raw: bytes, + their_raw: bytes, + model: type[DerivationManifest] | type[ViewSession], + path: str, + notes: list[str], +) -> bytes: + our = model.model_validate_json(our_raw) + their = model.model_validate_json(their_raw) + if their.created_at > our.created_at: + notes.append(f"{path}: kept the newer remote record") + return their_raw + return our_raw diff --git a/git_feature/store/types.py b/git_feature/store/types.py new file mode 100644 index 0000000..4ade03a --- /dev/null +++ b/git_feature/store/types.py @@ -0,0 +1,107 @@ +"""Persisted schema types for the feature store.""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + +SCHEMA_VERSION = 1 + +Confidence = Literal["exact", "moved", "fuzzy", "lost"] + + +class StoreMeta(BaseModel): + """Store-wide metadata written at initialization.""" + + schema_version: int = SCHEMA_VERSION + tool_version: str = "0" + + +class FeatureDef(BaseModel): + """Definition record of a feature.""" + + name: str + description: str = "" + owners: list[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + links: list[str] = Field(default_factory=list) + auto_created: bool = False + + +class RegionAnchor(BaseModel): + """A hunk pinned to the immutable diff of the change that introduced it.""" + + change_id: str + path: str + old_span: tuple[int, int] + new_span: tuple[int, int] + fingerprint: str + + +class Annotation(BaseModel): + """Association of an anchored region with a feature presence condition.""" + + anchor: RegionAnchor + presence: str + author: str + ts: datetime + note: str | None = None + + +class ChangeIdRecord(BaseModel): + """One entry of the SHA ↔ change-id map.""" + + sha: str + change_id: str + patch_id: str | None = None + + +class RegionDecision(BaseModel): + """Per-region outcome of a variant projection.""" + + anchor: RegionAnchor + presence: str + included: bool + resolved_span: tuple[int, int] | None = None # overall extent (first..last line) + resolved_runs: list[tuple[int, int]] | None = None # exact surviving line runs + resolved_path: str | None = None # set when the region resolved in a renamed/moved file + confidence: Confidence | None = None + + def runs(self) -> list[tuple[int, int]]: + """The region's line runs; falls back to the overall span.""" + if self.resolved_runs: + return self.resolved_runs + return [self.resolved_span] if self.resolved_span else [] + + def location(self) -> str: + """The file the region occupies at the base commit.""" + return self.resolved_path or self.anchor.path + + +class DerivationManifest(BaseModel): + """Record of how a variant was produced from a base commit.""" + + instance: str + base_commit: str + assignment: dict[str, bool] + decisions: list[RegionDecision] = Field(default_factory=list) + created_at: datetime + + def removed_runs(self, path: str) -> list[tuple[int, int]]: + """Line runs removed from a file, sorted by position (source coordinates).""" + runs: list[tuple[int, int]] = [] + for decision in self.decisions: + if not decision.included and decision.anchor.path == path: + runs.extend(decision.runs()) + return sorted(runs) + + +class ViewSession(BaseModel): + """An editable variant checkout and the derivation it was built from.""" + + instance: str + source_branch: str + base_commit: str + variant_commit: str + manifest: DerivationManifest + created_at: datetime diff --git a/git_tool/__main__.py b/git_tool/__main__.py deleted file mode 100644 index 69b1c5e..0000000 --- a/git_tool/__main__.py +++ /dev/null @@ -1,28 +0,0 @@ -import typer - -from git_tool.ci.subcommands.feature_add import feature_add_by_add -from git_tool.ci.subcommands.feature_add_from_staged import features_from_staging_area -from git_tool.ci.subcommands.feature_blame import feature_blame -from git_tool.ci.subcommands.feature_commit import feature_commit -from git_tool.ci.subcommands.feature_commit_msg import feature_commit_msg -from git_tool.ci.subcommands.feature_commits import app as feature_commits -from git_tool.ci.subcommands.feature_info import inspect_feature -from git_tool.ci.subcommands.feature_info_all import all_feature_info -from git_tool.ci.subcommands.feature_pre_commit import feature_pre_commit -from git_tool.ci.subcommands.feature_status import feature_status - - -app = typer.Typer(name="feature", no_args_is_help=True) # "git feature --help" does not work, but "git-feature --help" does -app.command(name="add", help="Stage files and associate them with the provided features.")(feature_add_by_add) -app.command(name="add-from-staged", help="Associate staged files with features.")(features_from_staging_area) -app.command(name="blame", help="Display features associated with file lines.")(feature_blame) -app.command(name="commit", help="Associate an existing commit with one or more features.")(feature_commit) -app.command(name="commit-msg", help="Generate feature information for the commit message.")(feature_commit_msg) -app.add_typer(feature_commits, name="commits", help="Use with the subcommand 'list' or 'missing' to show commits with or without associated features.") -app.command(name="info", help="Show information of a specific feature.")(inspect_feature) -app.command(name="info-all", help="List all available features in the project.")(all_feature_info) -app.command(name="pre-commit", help="Check if all staged changes are properly associated with features.")(feature_pre_commit) -app.command(name="status", help="Display unstaged and staged changes with associated features.")(feature_status) - - -app() diff --git a/git_tool/ci/compare_branches.py b/git_tool/ci/compare_branches.py deleted file mode 100644 index 187a419..0000000 --- a/git_tool/ci/compare_branches.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -import os -import sys -from typing import List, Set - -if __name__ == "__main__": - sys.path.append( - os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) - ) - -from git import Commit, Tuple - -from git_tool.feature_data.models_and_context.repo_context import repo_context -from git_tool.feature_data.read_feature_data.parse_data import ( - get_features_touched_by_commit, -) - - -def get_feature_sets_for_branch(branch_name: str) -> List[Set[str]]: - """ - Retrieves the sets of features touched together in a branch. - - Args: - branch_name (str): The branch to analyze. - - Returns: - List[Set[str]]: List of sets of features touched together in the branch. - """ - with repo_context() as repo: - commits = list(repo.iter_commits(branch_name)) - feature_sets = [ - get_features_touched_by_commit(commit) for commit in commits - ] - return feature_sets - - -def is_commit_compatible_with_branch( - commit: Commit, branch_name: str -) -> Tuple[bool, Set[str]]: - """ - Checks if a commit is compatible with a branch. - Compatible means that the features touched by a commit - are a subset of the features touched by the branch - - Args: - commit (Commit): The commit to check. - branch_name (str): The branch to check against. - - Returns: - Tuple[bool, Set[str]]: Whether the commit is compatible and the set of features it touches. - """ - commit_features = get_features_touched_by_commit(commit) - branch_feature_sets = get_feature_sets_for_branch(branch_name) - - for feature_set in branch_feature_sets: - if commit_features.issubset(feature_set): - return (True, commit_features) - return (False, commit_features) - - -if __name__ == "__main__": - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - handlers=[ - logging.FileHandler("debug.log"), - logging.StreamHandler(), - ], - ) - - branch_name = "inital-experiments" # Beispielbranchname - other_commit = input("Get commit-like format") - with repo_context() as repo: - is_compatible, commit_features = is_commit_compatible_with_branch( - other_commit, branch_name - ) - if is_compatible: - logging.info( - f"Commit {other_commit} is compatible with branch {branch_name}." - ) - else: - logging.warning( - f"Commit {other_commit} is NOT compatible with branch {branch_name}." - ) - logging.warning(f"Touched features: {commit_features}") - logging.warning( - f"Commit {other_commit} touches features not fully compatible with branch {branch_name}." - ) diff --git a/git_tool/ci/fact_for_commit.py b/git_tool/ci/fact_for_commit.py deleted file mode 100644 index 0313ccb..0000000 --- a/git_tool/ci/fact_for_commit.py +++ /dev/null @@ -1,73 +0,0 @@ -if __name__ == "__main__": - import os - import sys - - sys.path.append( - os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) - ) -from datetime import datetime - -from prompt_toolkit import prompt - -from git_tool.feature_data.add_feature_data.add_data import ( - add_fact_to_metadata_branch, -) -from git_tool.feature_data.models_and_context.fact_model import ( - ChangeHolder, - FeatureFactModel, -) -from git_tool.feature_data.models_and_context.repo_context import repo_context - - -# TODO currently unused code -def select_features(feature_options: dict[str, str]) -> list: - """ - Let users decide which features to use by selection. - """ - choices = [(uuid, name) for name, uuid in feature_options.items()] - selected = [] - - print("Select features (use space to toggle, enter to confirm):") - while True: - print(selected) - for i, (uuid, name) in enumerate(choices): - selected_marker = "[x]" if uuid in selected else "[ ]" - print(f"{i + 1}. {selected_marker} {name}") - - selection = prompt("> ") - - if selection.isdigit(): - index = int(selection) - 1 - if 0 <= index < len(choices): - uuid, name = choices[index] - if uuid in selected: - selected.remove(uuid) - else: - selected.append(uuid) - elif selection.lower() in ("done", "d"): - break - else: - print( - "Invalid selection. Enter the number of the feature to toggle selection, or 'done' to finish." - ) - - return selected - - -if __name__ == "__main__": - commit_id = input("Enter Commit Id:") - with repo_context() as repo: - commit = repo.commit(commit_id) - print(commit, commit.message) - # selected_uuid = select_features(features) - features = ["test"] - fact = FeatureFactModel( - commit=str(commit), - authors=[commit.author.name], - date=datetime.now(), - features=features, - changes=ChangeHolder( - code_changes=[], constraint_changes=[], name_change=None - ), - ) - add_fact_to_metadata_branch(fact=fact, commit_ref=commit) diff --git a/git_tool/ci/subcommands/feature_add.py b/git_tool/ci/subcommands/feature_add.py deleted file mode 100644 index 26e65f4..0000000 --- a/git_tool/ci/subcommands/feature_add.py +++ /dev/null @@ -1,110 +0,0 @@ -import typer - -from git_tool.feature_data.git_status_per_feature import ( - get_features_for_file, - get_files_by_git_change, -) -from git_tool.feature_data.models_and_context.feature_state import ( - reset_staged_featureset, - write_staged_featureset, -) -from git_tool.feature_data.models_and_context.repo_context import repo_context -from git_tool.finding_features import features_for_file_by_annotation - -app = typer.Typer(no_args_is_help=True) - - -@app.command("add", help="Stage files and associate them with the provided features.", no_args_is_help=True) -def feature_add_by_add( - feature_names: list[str] = typer.Argument( - None, help="List of feature names to associate with the staged files" - ), - all_files: bool = typer.Option( - False, - "--all", - "-a", - help="Stage all tracked changes and associate with features", - ), - selected_files: list[str] = typer.Option( - None, - "--files", - "-f", - help="Specify a list of files to stage and associate with features", - ), -): - """ - Stage specific files or all files and associate them with the provided features. - """ - if all_files: - typer.echo("Staging all files and associating with features") - stage_files(get_files_by_git_change().get("tracked_files", [])) - elif selected_files: - typer.echo(f"Staging selected files: {selected_files}") - stage_files(selected_files) - else: - typer.echo("No files selected. Use --all or --files.", err=True) - return - - if feature_names: - typer.echo(f"Associating features: {feature_names}") - write_staged_featureset(feature_names) - else: - typer.echo("No features provided.", err=True) - - - -def stage_files(selected_files: list[str]) -> bool: - """ - Stage all selected files and return whether the staging area has changed. - - Arguments: - selected_files -- List of file identifiers that git understands for git add - - Returns: - Boolean whether there were actually new staged changes. - """ - typer.echo("Staging files") - with repo_context() as repo: - for file in selected_files: - try: - initial_diff = repo.git.diff("--cached") - repo.git.add(file) - typer.echo(f"Staged file: {file}") - final_diff = repo.git.diff("--cached") - if initial_diff == final_diff: - typer.echo( - "No changes were made to the staging area. Aborting.", - err=True, - ) - return False - except Exception as e: - typer.echo(f"Error staging file {file}: {e}", err=True) - return False - return True - - -def is_staging_area_empty() -> bool: - typer.echo("Checking if there are staged files") - changes = get_files_by_git_change() - - if len(changes["staged_files"]) == 0: - typer.echo( - "No staged changes, so no feature information to add", err=True - ) - reset_staged_featureset() - return True - else: - return False - - -def create_feature_meta_info( - features: list[str], insert_annotations: bool = False -): - typer.echo(f"Adding feature information {features} to staged files") - write_staged_featureset(features) - if insert_annotations: - typer.echo("Not adding annotations, not implemented yet", err=True) - - -if __name__ == "__main__": - app() diff --git a/git_tool/ci/subcommands/feature_add_from_staged.py b/git_tool/ci/subcommands/feature_add_from_staged.py deleted file mode 100644 index 66f528b..0000000 --- a/git_tool/ci/subcommands/feature_add_from_staged.py +++ /dev/null @@ -1,56 +0,0 @@ -import typer - -from git_tool.feature_data.git_status_per_feature import ( - get_features_for_file, - get_files_by_git_change, -) -from git_tool.feature_data.models_and_context.feature_state import ( - reset_staged_featureset, - write_staged_featureset, -) - - -app = typer.Typer() - - -@app.command(name="add-from-staged", help="Associate staged files with features.") -def features_from_staging_area(): - """ - Use the staged files to add feature information. - """ - feature_names = read_features_from_staged() - if feature_names: - typer.echo(f"Features in staging area: {feature_names}") - write_staged_featureset(feature_names) - else: - typer.echo("No features found in staging area.", err=True) - - -def read_features_from_staged(type: str = "Union") -> list[str]: - """ - Retrieve the features from the current staging area. - - Keyword Arguments: - type -- Can be Union or Intersection. Describes how multiple features will be combined (default: {"Union"}) - - Returns: - List of features. - """ - typer.echo("Using staged files") - - staged_files = get_files_by_git_change().get("staged_files", []) - feature_sets = [set(get_features_for_file(f)) for f in staged_files] - - if not feature_sets: - return [] - - if type == "Union": - combined_features = set.union(*feature_sets) - elif type == "Intersection": - combined_features = set.intersection(*feature_sets) - else: - raise ValueError( - "Invalid type specified. Use 'Union' or 'Intersection'." - ) - - return list(combined_features) \ No newline at end of file diff --git a/git_tool/ci/subcommands/feature_blame.py b/git_tool/ci/subcommands/feature_blame.py deleted file mode 100644 index 900ef31..0000000 --- a/git_tool/ci/subcommands/feature_blame.py +++ /dev/null @@ -1,163 +0,0 @@ -from pathlib import Path -from typing import Any - -import typer -from git import Repo -from git_tool.feature_data.read_feature_data.parse_data import get_features_touched_by_commit -from git_tool.feature_data.models_and_context.repo_context import ( - repo_context, -) - -app = typer.Typer(no_args_is_help=True) - - -def read_file_lines(file_path: Path) -> list[str]: - """ - Reads the lines of a file and returns them as a list. - """ - with file_path.open("r", encoding="utf-8") as f: - return f.readlines() - - -def get_line_to_blame_mapping( - repo: Repo, file_path: Path, start_line: int, end_line: int -) -> dict[int, tuple[str, str]]: - """ - Returns a mapping of line numbers to (commit hash, blame line). - """ - blame_output = repo.git.blame( - "-L", f"{start_line},{end_line}", "--date=short", str(file_path) - ) - - line_to_blame = {} - line_number = start_line - - for line in blame_output.splitlines(): - if line.startswith(":"): - continue - blame_part = line.split(" ", 1) - short_hash = blame_part[0] - blame_text = blame_part[1] if len(blame_part) > 1 else "" - full_hash = repo.git.rev_parse(short_hash) - line_to_blame[line_number] = (short_hash, blame_text) - line_number += 1 - - return line_to_blame - - -def get_commit_to_features_mapping(line_to_commit: dict[int, tuple[str, str]]) -> dict[str, str]: - """ - Returns a mapping of commit hashes to features. - """ - unique_commits = {commit for commit, _ in line_to_commit.values()} - - commit_to_features = { - commit_id: ", ".join(get_features_touched_by_commit(commit_id)) - for commit_id in unique_commits - } - - return commit_to_features - - -def get_line_to_features_mapping( - repo: Repo, file_path: Path, start_line: int, end_line: int -) -> tuple[dict[int, Any], dict[int, tuple[str, str]]]: - """ - Returns a mapping of line numbers to features. - """ - # Get the commit for each line using 'git blame' - line_to_blame = get_line_to_blame_mapping(repo, file_path, start_line, end_line) - # for debugging: print("Step 1: ", line_to_blame) - - # Get the features for each commit - commit_to_features = get_commit_to_features_mapping(line_to_blame) - # for debugging: print("Step 2: ", commit_to_features) - - # Map each line to its corresponding feature - line_to_features = { - line: commit_to_features.get(commit_hash, "UNKNOWN") - for line, (commit_hash, _) in line_to_blame.items() - } - # for debugging: print("Step 3: ", line_to_features) - - return line_to_features, line_to_blame - - -def print_feature_blame_output( - lines: list[str], - mappings: tuple[dict[int, Any], dict[int, tuple[str, str]]], - start_line: int, - end_line: int, -): - """ - Prints the feature blame output similar to git blame. - """ - line_to_features, line_to_blame = mappings - # Get the max width of feature strings for alignment - max_feature_width = max( - (len(line_to_features.get(commit, "UNKNOWN")) for commit in line_to_features.values()), - default=15, - ) - - for i in range(start_line, end_line + 1): - line = lines[i - 1] # Adjust because list is 0-indexed, but line numbers start from 1 - commit_hash, blame_text = line_to_blame.get(i) - blame_text = blame_text.replace("(", "", 1) - feature = line_to_features.get(i, "UNKNOWN") - typer.echo(f"{feature:<15} ({commit_hash} {blame_text}") - - -@app.command(help="Display features associated with file lines.", no_args_is_help=True, name=None) -def feature_blame( - filename: str = typer.Argument( - ..., help="The file to display feature blame for." - ), - line: str = typer.Option( - None, help="Specify a line or range of lines (e.g., '5' or '5-10')." - ), -): - """ - Displays features associated with file lines. You can optionally specify a line or a range of lines. - """ - file_path = Path(filename) - - if not file_path.exists(): - typer.echo(f"Error: file '{filename}' not found.") - raise typer.Exit(code=1) - - # Read the file contents - lines = read_file_lines(file_path) - - # Default to the entire file if no line argument is provided - start_line = 1 - end_line = len(lines) - - if line: - if "-" in line: - # Handle a range of lines - start_line, end_line = map(int, line.split("-")) - else: - # Handle a single line - start_line = end_line = int(line) - - # Ensure the line range is valid - if start_line < 1 or end_line > len(lines): - typer.echo("Error: Line number out of range.") - raise typer.Exit(code=1) - - if start_line > end_line: - typer.echo("Error: Start line must be less than end line.") - raise typer.Exit(code=1) - - with repo_context() as repo: # Use repo_context for the git operations - feature_to_line_mapping = get_line_to_features_mapping( - repo, file_path, start_line, end_line - ) - - print_feature_blame_output( - lines, feature_to_line_mapping, start_line, end_line - ) - - -if __name__ == "__main__": - app() diff --git a/git_tool/ci/subcommands/feature_commit.py b/git_tool/ci/subcommands/feature_commit.py deleted file mode 100644 index 2e142eb..0000000 --- a/git_tool/ci/subcommands/feature_commit.py +++ /dev/null @@ -1,90 +0,0 @@ -from datetime import datetime -import typer - -from git_tool.feature_data.add_feature_data.add_data import ( - add_fact_to_metadata_branch, -) -from git_tool.feature_data.models_and_context.fact_model import ( - ChangeHolder, - FeatureFactModel, -) -from git_tool.feature_data.models_and_context.feature_state import ( - read_staged_featureset, - reset_staged_featureset, -) -from git_tool.feature_data.models_and_context.repo_context import repo_context, sync_feature_branch - - -app = typer.Typer( - help="Associate an existing commit with one or more features", - no_args_is_help=True, -) - - -@app.command( - name="commit", - help="Associate an existing commit with one or more features.", - no_args_is_help=True, -) -def feature_commit( - commit_id: str = typer.Argument( - ..., - help="Provide the commit-id that the feature information should belong to.", - ), - features: list[str] = typer.Option( - None, - help="Manually specify feature names. Each feature-name needs to be prefixed with --features \ - If this option is provided, staged feature information will be ignored.", - ), - upload: bool = typer.Option(True, help="Set whether feature information are also directly uploaded to remote.") -): - """ - Associate feature information with a regular git commit. - This command is a plumbing command, usually this would be happening automatically when - setting up git hooks. - """ - # typer.echo("Step 1: select commit to assign information to") - # typer.echo(f"\t Commit is {commit}") - # typer.echo("Step 2: Select feature information") - # test commit - with repo_context() as repo: - try: - commit_obj = repo.commit(commit_id) - except: - typer.echo("Invalid commit.", err=True) - return - if not features: - staged_features = read_staged_featureset() - typer.echo(f"Selecting features {staged_features}") - if not staged_features: - typer.echo("No feature information available.", err=True) - return - else: - staged_features = features - typer.echo(f"Selecting features from cli parameters {staged_features}") - # typer.echo("Step 3: Add a feature meta commit on meta data branch") - with repo_context() as repo: - feature_fact = FeatureFactModel( - commit=commit_id, - authors=[commit_obj.author.name], - date=datetime.now(), - features=staged_features, - changes=ChangeHolder( - code_changes=[], constraint_changes=[], name_change=None - ), - ) - # Add the fact to the metadata branch - add_fact_to_metadata_branch(fact=feature_fact, commit_ref=commit_obj) - typer.echo(f"Features {features} assigned to {commit_id}") - - if upload: - sync_feature_branch() - # typer.echo("Step 4: Cleanup all information/ internal state stuff") - reset_staged_featureset() - - - # typer.echo("Feature commit process completed successfully.") - - -if __name__ == "__main__": - app() diff --git a/git_tool/ci/subcommands/feature_commit_msg.py b/git_tool/ci/subcommands/feature_commit_msg.py deleted file mode 100644 index e1be33a..0000000 --- a/git_tool/ci/subcommands/feature_commit_msg.py +++ /dev/null @@ -1,24 +0,0 @@ -import typer - -from git_tool.feature_data.models_and_context.feature_state import ( - read_staged_featureset, -) - - -app = typer.Typer() - - -@app.command() -def feature_commit_msg(): - """ - Generates feature information for the commit message. - """ - staged_features = read_staged_featureset() - - if not staged_features: - typer.echo("No features associated with the staged changes.") - raise typer.Exit(code=1) - - feature_msg = f"Associated Features: {', '.join(staged_features)}" - - typer.echo(feature_msg) diff --git a/git_tool/ci/subcommands/feature_commits.py b/git_tool/ci/subcommands/feature_commits.py deleted file mode 100644 index 79a1fe5..0000000 --- a/git_tool/ci/subcommands/feature_commits.py +++ /dev/null @@ -1,88 +0,0 @@ -import typer -from git_tool.ci.subcommands.feature_status import is_commit_in_list -from git_tool.feature_data.analyze_feature_data.feature_utils import ( - get_commits_with_feature, -) -from git_tool.feature_data.models_and_context.repo_context import ( - get_all_commits, - get_commit_title, - repo_context, -) - -app = typer.Typer( - no_args_is_help=True, help="Manage commits with feature associations." -) - - -@app.command(name="list") -def find_commits_with_feature( - message: bool = typer.Option( - False, - help="Display the commit message/title along with the commit hash.", - ), -): - """ - Find all commits that have feature information associated. - """ - feature_commits = get_commits_with_feature() - - if not feature_commits: - typer.echo("No commits with feature associations found.") - return - - typer.echo("Commits with feature association:") - with repo_context() as repo: - for commit in feature_commits: - try: - commit_obj = repo.commit(commit) - except: - typer.echo(f"Could not work with {commit}", err=True) - continue - if message: - typer.echo(f"{commit_obj.hexsha}: {get_commit_title(commit)}") - else: - typer.echo(commit_obj.hexsha) # Output the full commit hash - - -@app.command(name="missing") -def find_commits_without_feature( - message: bool = typer.Option( - False, - help="Display the commit message/title along with the commit hash.", - ), -): - """ - Find all commits that don't have any associated feature information. - """ - all_commits = get_all_commits() - feature_commits = get_commits_with_feature() - - if not feature_commits: - typer.echo("No feature commits found.") - return - - commits_without_feature = [ - commit - for commit in all_commits - if not is_commit_in_list(commit, feature_commits) - ] - - if not commits_without_feature: - typer.echo("All commits have feature associations.") - return - - typer.echo("Commits without feature association:") - with repo_context() as repo: - for commit in commits_without_feature: - try: - commit_obj = repo.commit(commit) - except: - commit_obj = {"hexsha": "ERROR"} - if message: - typer.echo(f"{commit_obj.hexsha}: {get_commit_title(commit)}") - else: - typer.echo(commit_obj.hexsha) # Output the full commit hash - - -if __name__ == "__main__": - app() diff --git a/git_tool/ci/subcommands/feature_info.py b/git_tool/ci/subcommands/feature_info.py deleted file mode 100644 index 70f3a27..0000000 --- a/git_tool/ci/subcommands/feature_info.py +++ /dev/null @@ -1,139 +0,0 @@ -import typer - -from git_tool.feature_data.analyze_feature_data.feature_utils import ( - get_commits_for_feature_on_other_branches, - get_current_branchname, -) -from git_tool.feature_data.git_helper import ( - get_author_for_commit, - get_branches_for_commit, - get_files_for_commit, -) -from git_tool.feature_data.git_status_per_feature import get_commits_for_feature - - -def print_list_w_indent(stuff: list, indent: int = 1) -> None: - for item in stuff: - typer.echo("\t" * indent + item) - - -app = typer.Typer(help="Displaying feature information for the entire git repo", no_args_is_help=True) - - -# TODO sollte zu git feature-status -# @currently_staged_app.command(name=None) -# def get_stuff(): -# typer.echo("Currently staged") -# print_list_w_indent(read_staged_featureset()) -# return - - -@app.command(name="info") -def inspect_feature( - feature: str = typer.Argument(..., help="Inspect a particular feature"), - authors: bool = typer.Option( - False, help="Show all authors who contributed to this feature." - ), - files: bool = typer.Option( - False, help="List all files associated with the feature." - ), - branches: bool = typer.Option( - False, help="Show all branches where the feature is present." - ), - updatable: bool = typer.Option( - False, - help="Check if the feature has updates available on other branches and list the update options.", - ), - branch: str = typer.Option( - None, - help="Used with --updatable to specify a branch for checking updates. \ - Ignored if --updatable is not set.", - ), -): - typer.echo(f"Collecting information for feature {feature}") - try: - typer.echo("Commit IDs") - commit_ids = [x.hexsha for x in get_commits_for_feature(feature)] - print_list_w_indent(commit_ids) - except Exception: - commit_ids = [] - typer.echo(f"No commit-ids for feature {feature} found") - if branches: - typer.echo("Branches (* indicates current branch)") - branches = set( - [ - branch - for c in commit_ids - for branch in get_branches_for_commit(c) - ] - ) - print_list_w_indent(branches) - if authors: - typer.echo("Authors") - authors = set([get_author_for_commit(c) for c in commit_ids]) - print_list_w_indent(authors) - if files: - typer.echo("Files") - files = set( - file for c in commit_ids for file in get_files_for_commit(c) - ) - print_list_w_indent(files) - if updatable: - typer.echo( - f"Evaluating if feature {feature} can be updated on current branch {get_current_branchname()}" - ) - if branch: - typer.echo( - f"Comparing commits for the feature '{feature}' on the current branch '{get_current_branchname()}' with branch '{branch}'" - ) - try: - other_commits = get_commits_for_feature_on_other_branches( - feature_commits=commit_ids, other_branch=branch - ) - if other_commits: - typer.echo( - f"Found {len(other_commits)} commits for feature '{feature}' on branch '{branch}'." - ) - for commit in other_commits: - typer.echo( - f"{commit.hexsha} - {commit.message.splitlines()[0]}" - ) - - else: - typer.echo( - f"No commits found for feature '{feature}' on branch '{branch}'." - ) - except Exception as e: - typer.secho( - f"Error retrieving commits for branch '{branch}': {e}", - fg=typer.colors.RED, - ) - else: - typer.echo( - f"Comparing commits for the feature '{feature}' on all other branches with the current branch '{get_current_branchname()}'" - ) - try: - other_commits = get_commits_for_feature_on_other_branches( - feature_commits=commit_ids - ) - if other_commits: - typer.echo( - f"Found {len(other_commits)} commits for feature '{feature}' on other branches." - ) - for commit in other_commits: - typer.echo( - f"{commit.hexsha[:7]} - {commit.message.splitlines()[0]}" - ) - else: - typer.echo( - f"No commits found for feature '{feature}' on other branches." - ) - except Exception as e: - typer.secho( - f"Error retrieving commits for other branches: {e}", - fg=typer.colors.RED, - ) - - -if __name__ == "__main__": - app() diff --git a/git_tool/ci/subcommands/feature_info_all.py b/git_tool/ci/subcommands/feature_info_all.py deleted file mode 100644 index 4bd86c5..0000000 --- a/git_tool/ci/subcommands/feature_info_all.py +++ /dev/null @@ -1,22 +0,0 @@ -import typer - -from git_tool.feature_data.read_feature_data.parse_data import ( - _get_feature_uuids, -) - - -app = typer.Typer() - - -@app.command(name="info-all") -def all_feature_info(): - """ - Inspects feature information. - """ - typer.echo("All Features") - print_list_w_indent(_get_feature_uuids()) - - -def print_list_w_indent(stuff: list, indent: int = 1) -> None: - for item in stuff: - typer.echo("\t" * indent + item) \ No newline at end of file diff --git a/git_tool/ci/subcommands/feature_pre_commit.py b/git_tool/ci/subcommands/feature_pre_commit.py deleted file mode 100644 index 34847b0..0000000 --- a/git_tool/ci/subcommands/feature_pre_commit.py +++ /dev/null @@ -1,31 +0,0 @@ -import typer - -from git_tool.feature_data.git_status_per_feature import get_files_by_git_change -from git_tool.feature_data.models_and_context.feature_state import ( - read_staged_featureset, -) - - -app = typer.Typer() - - -@app.command() -def feature_pre_commit(): - """ - Checks if all staged changes are properly associated with features. - Returns an error if any issues are found. - """ - changes = get_files_by_git_change() - - if len(changes["staged_files"]) == 0: - typer.echo("Error: No staged changes found.") - raise typer.Exit(code=1) - - staged_features = read_staged_featureset() - - if not staged_features: - typer.echo("Warning: No features associated with the staged changes. Remember to use git feature-commit to add these information") - raise typer.Exit(code=0) - - typer.echo("Pre-commit checks passed.") - raise typer.Exit(code=0) diff --git a/git_tool/ci/subcommands/feature_status.py b/git_tool/ci/subcommands/feature_status.py deleted file mode 100644 index 7800182..0000000 --- a/git_tool/ci/subcommands/feature_status.py +++ /dev/null @@ -1,114 +0,0 @@ -from collections import defaultdict -import typer - -from git_tool.feature_data.git_status_per_feature import ( - get_features_for_file, - get_files_by_git_change, -) -from git_tool.feature_data.models_and_context.feature_state import ( - read_staged_featureset, -) - -WITHOUT_FEATURE = "Without Feature" - -app = typer.Typer( - no_args_is_help=False, # Allow running without arguments to show help - name=None, - help="Display the current status of staged, unstaged, and untracked files, \ - along with their associated features.", -) - - -@app.command(name="status") -def feature_status( - help: bool = typer.Option( - None, "--help", "-h", is_eager=True, help="Show this message and exit." - ) -): - """ - Displays the current status of files in the working directory, showing staged, unstaged, and untracked changes along with their associated features. - Files without associated features will be highlighted, and suggestions for adding features will be provided. - """ - if help: - typer.echo(app.rich_help_panel) - raise typer.Exit() - - changes = get_files_by_git_change() - - def group_files_by_feature(files: list[str]): - feature_dict = defaultdict(list) - for file in files: - features = get_features_for_file(file) - if not features: - features = [WITHOUT_FEATURE] - for feature in features: - feature_dict[feature].append(file) - return feature_dict - - def print_changes( - change_type: str, files: list[str], staged_features: list[str] = [] - ): - feature_dict = group_files_by_feature(files) - typer.echo(f"{change_type.replace('_', ' ').title()}:") - for feature, files in feature_dict.items(): - typer.echo(f"\t{feature}:") - if feature == WITHOUT_FEATURE: - typer.echo( - """\t (To assign a feature, run git feature-add )""" - ) - for file in files: - typer.echo(f"\t\t{file}") - - printed = False - - if len(changes.get("staged_files", [])) > 0: - printed = True - - staged_features = read_staged_featureset() - typer.echo("Staged Features: ") - typer.echo( - "\t(Features in parentheses are associated but not yet staged for the corresponding files)", - color="yellow", - ) - if staged_features: - typer.echo(", ".join(staged_features)) - else: - typer.echo( - "No feature information found for the staged files. Please run 'git feature-add from-staged' \ - to associate features.", - err=True, - ) - - typer.echo("Feature changes to be committed:") - for item in changes["staged_files"]: - additional_features = get_features_for_file(item) - unstaged_features = [ - f for f in additional_features if not f in staged_features - ] - if unstaged_features: - non_staged_str = f" ({', '.join(unstaged_features)})" - else: - non_staged_str = "" - typer.echo(f"\t{item} {non_staged_str}") - - if len(changes.get("unstaged_files", [])) > 0: - printed = True - print_changes( - "Feature changes not staged for commit", changes["unstaged_files"] - ) - - if len(changes.get("untracked_files", [])) > 0: - printed = True - print_changes("Untracked files", changes["untracked_files"]) - - if not printed: - typer.echo("No changes.") - - -def is_commit_in_list(commit_id: str, commit_id_list: list[str]) -> bool: - for commit in commit_id_list: - min_length = min(len(commit_id), len(commit)) - if commit_id[:min_length] == commit[:min_length]: - return True - - return False diff --git a/git_tool/feature_data/add_feature_data/add_data.py b/git_tool/feature_data/add_feature_data/add_data.py deleted file mode 100644 index 2268c8c..0000000 --- a/git_tool/feature_data/add_feature_data/add_data.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -This wraps the fast-import utils and writes the necessary code for determining all data artefacts -needed to add a FeatureFact to the respective metadata branch. -""" - -import hashlib -import os -import tempfile -from pathlib import Path - -from git import Commit - -from git_tool.feature_data.analyze_feature_data.feature_utils import ( - get_uuid_for_featurename, -) -from git_tool.feature_data.models_and_context.fact_model import FeatureFactModel -from git_tool.feature_data.models_and_context.repo_context import ( - FEATURE_BRANCH_NAME, - repo_context, -) -from git_tool.feature_data.utils.fast_import_utils import ( - AccumulatedCommitData, - FastImportCommitData, - to_fast_import_format, -) - - -def generate_fact_file_path(fact: FeatureFactModel) -> list[Path]: - """ - The fact file is stored in the folder // - Each of these information is computed in this function - - Args: - fact (FeatureFactModel): Information used to derive path - - Returns: - list[str]: All paths where the associated fact needs to be stored - """ - feature_uuids = [ - get_uuid_for_featurename(feature) for feature in fact.features - ] - fact_json = fact.model_dump_json() - sha1_hash = hashlib.sha1(fact_json.encode("utf-8")).hexdigest() - - paths = [ - Path(uuid) - .joinpath(Path(fact.commit)) - .joinpath( - f'{fact.date.isoformat(timespec="minutes").replace(":", "-")}-{sha1_hash[:7]}' - ) - .as_posix() - for uuid in feature_uuids - ] - return paths - - -def generate_fact_commit_data( - fact: FeatureFactModel, - branch_name: str = FEATURE_BRANCH_NAME, - commit_ref: Commit = None, -): - commit_data = AccumulatedCommitData( - branch_name=branch_name, - committer_name=commit_ref.author.name if commit_ref is not None else "", - committer_email=( - commit_ref.author.email if commit_ref is not None else "" - ), - message=f"Generate fact for {str(commit_ref)}\n\nTouching features {','.join(fact.features)}", - add_files=[ - FastImportCommitData( - file_path=file, - content=fact.model_dump_json(), - ) - for file in generate_fact_file_path(fact=fact) - ], - ) - return commit_data - - -def add_fact_to_metadata_branch( - fact: FeatureFactModel, - branch_name: str = FEATURE_BRANCH_NAME, - commit_ref: Commit = None, -): - """ - Create a new commit by creating a temporary file using the fast-import format. - The commit data is derived from the fact information. - :param fact Structured information used to generate the commit content - :param branch-name reference for git which is used to determine the branch that a commit is added to - :param commit_ref Optional parameter that can include more informatino for the commit content, specifying which commit the metadata describes - - """ - commit_data = generate_fact_commit_data(fact, branch_name, commit_ref) - fast_import_content = to_fast_import_format(commits=[commit_data]) - try: - with tempfile.NamedTemporaryFile( - delete=False, mode="w", encoding="utf-8", newline="\n" - ) as temp_file: - temp_file.write(fast_import_content) - temp_file_path = temp_file.name - - with repo_context() as repo: - with open(temp_file_path, "r") as file: - repo.git.fast_import(istream=file) - except Exception as e: - print("error\n", e) - finally: - if temp_file_path: - os.remove(temp_file_path) - return commit_data diff --git a/git_tool/feature_data/analyze_feature_data/feature_utils.py b/git_tool/feature_data/analyze_feature_data/feature_utils.py deleted file mode 100644 index 8a627dc..0000000 --- a/git_tool/feature_data/analyze_feature_data/feature_utils.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -All functions working on feature-uuids that help to get data in a structured format -based on a given feature -""" - -import uuid -from datetime import datetime -from typing import Generator, Optional - -from git import Commit - -from git_tool.feature_data.models_and_context.fact_model import ( - get_fact_from_featurefile, -) -from git_tool.feature_data.models_and_context.repo_context import ( - FEATURE_BRANCH_NAME, - repo_context, -) - - -class FeatureNameNotFoundException(Exception): ... - - -def get_associated_files(feature_uuid: str) -> Generator[str, None, None]: - """Search for all files belonging to a certain feature_uuid. Assume that all files are - stored in the folder with the name feature_uuid - - Args: - feature_uuid (str): Identifier for feature - - Returns: - Generator[str, None, None]: Iterator over all filenames associated with the feature - """ - with repo_context() as repo: - # repo.git always allows to execute git commands, see - # https://gitpython.readthedocs.io/en/stable/tutorial.html#using-git-directly - result: str = repo.git.ls_tree( - "-r", "--name_only", FEATURE_BRANCH_NAME, feature_uuid - ) - files: list[str] = ( - result.split() - ) # outputs one string. Filenames are separated by whitespace-like delimiters - return files - - -def get_featurename_from_uuid( - uuid: uuid.UUID, -) -> str | list[tuple[datetime, str]]: - """ - Look through all changes associated with the feature uuid and check which ones - changed the name - - Arguments: - uuid -- Feature UUID - - Raises: - FeatureNameNotFoundException: No Name Found for the feature - - Returns: - If there is just one name, return the name. If there are multiple names, return - tuples of name and timestamp so the context can decide which name to look for - """ - feature_files = get_associated_files(feature_uuid=uuid) - names: list[tuple[datetime, str]] = [] - for file in feature_files: - fact = get_fact_from_featurefile(file) - if fact.changes.name_change: - names.append((fact.date, fact.changes.name_change.feature_name)) - if len(names) == 0: - raise FeatureNameNotFoundException - elif len(names) == 1: - return names[0][1] - else: - return names - - -def get_uuid_for_featurename(name: str) -> uuid.UUID: - """ - git log feature_metadata -S --name-only --pretty=format: - lists the filenames of all files that contain the given string. - They are sorted by commit and separated by a newline for each commit - - Arguments: - name -- Featurename that is searched for - Returns: - UUID associated with the feautre - """ - with repo_context() as repo: - repo.git.log( - FEATURE_BRANCH_NAME, - f"-S {name}", - name_only=True, - oneline=True, - pretty="format:", - ) - # TODO this is not implemented correctly - return name - -# Usages: FEATURE INFO -def get_current_branchname() -> str: - with repo_context() as repo: - return repo.active_branch - -# Usages: FEATURE INFO -def get_commits_for_feature_on_other_branches( - feature_commits: set[str], - current_branch: str = get_current_branchname(), - other_branch: str = "", -) -> set[Commit]: - """ - Find commits on other branches that affect the same feature but are not present on the current branch. - - Args: - repo: The Git repository object. - feature_commits: A set of commit IDs associated with the feature. - current_branch: The name of the current branch. - other_branch: Optional limitatation of the branch that should be compared to - - Returns: - A set of commit IDs that are on other branches but not on the current branch. - """ - with repo_context() as repo: - # Normalize the feature commits to full hashes if they are not already - feature_commits = set( - repo.git.rev_parse(commit) for commit in feature_commits - ) - if other_branch: - other_branches = [other_branch] - else: - other_branches = [ - branch.name - for branch in repo.branches - if branch.name != current_branch - ] - updatable_commits = set() - - for branch in other_branches: - branch_commits = set( - repo.git.log( - f"{current_branch}..{branch}", "--pretty=%H" - ).split("\n") - ) - relevant_commits = branch_commits.intersection(feature_commits) - if relevant_commits: - updatable_commits.update(relevant_commits) - - updatable_commit_objects = { - repo.commit(commit_hash) for commit_hash in updatable_commits - } - - return updatable_commit_objects - - -def get_all_features() -> list[str]: - """ - Return list of all feature names. A feature name is equivalent to its folder name - on the top level of the feature-metadata branch - - Returns: - list[str]: List of folder names (features) at the top level. - """ - with repo_context() as repo: - folder_string = repo.git.ls_tree( - "-d", "--name-only", FEATURE_BRANCH_NAME - ) - folders = folder_string.splitlines() - return folders - -# Usages: FEATURE COMMITS -def get_commits_with_feature() -> list[str]: - """ - Return a list of short ids of commits that are assoicated - with at least one feature. - - Returns: - list[str] - """ - all_features = get_all_features() - commit_ids = set() - - with repo_context() as repo: - for feature in all_features: - feature_files = repo.git.ls_tree( - "-d", "--name-only", f"{FEATURE_BRANCH_NAME}:{feature}" - ).splitlines() - - for commit_folder in feature_files: - commit_id = commit_folder - commit_ids.add(commit_id) - return list(commit_ids) diff --git a/git_tool/feature_data/file_based_git_info.py b/git_tool/feature_data/file_based_git_info.py deleted file mode 100644 index 4fadb99..0000000 --- a/git_tool/feature_data/file_based_git_info.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Functions to retrieve git information for a specific file rather than starting with -a list of features or commits -""" - -from typing import Optional - -from git_tool.feature_data.models_and_context.repo_context import ( - get_current_branch, - repo_context, -) - - -def get_commits_for_file( - file_name: str, branch_name: Optional[str] = None -) -> list[str]: - """ - Search git history for commits targeting a file, then collecting the commit ids. - If no branchname is given, the current branch is used - """ - with repo_context() as repo: - if branch_name is None: - branch_name = get_current_branch() - commit_id_only = "--pretty=format:%H" - try: - commits = repo.git.log(branch_name, commit_id_only, "--", file_name) - return commits.split("\n") - except Exception: - # this happens for example if no commits exists - return [] diff --git a/git_tool/feature_data/git_helper.py b/git_tool/feature_data/git_helper.py deleted file mode 100644 index 4a43efa..0000000 --- a/git_tool/feature_data/git_helper.py +++ /dev/null @@ -1,27 +0,0 @@ -from git_tool.feature_data.models_and_context.repo_context import repo_context - - -def get_branches_for_commit(commit_id: str) -> set[str]: - """ - Get all branches that contain the given commit. - """ - - with repo_context() as repo: - branches = repo.git.branch("--contains", commit_id).split("\n") - return {branch.strip() for branch in branches} - - -def get_author_for_commit(commit_id: str) -> str: - """ - Get the author of the given commit. - """ - with repo_context() as repo: - return repo.git.show("-s", "--format=%an", commit_id).strip() - - -def get_files_for_commit(commit_id: str) -> list[str]: - """ - Get the list of files modified by the given commit. - """ - with repo_context() as repo: - return repo.git.show("--name-only", "--pretty=", commit_id).split("\n") diff --git a/git_tool/feature_data/git_status_per_feature.py b/git_tool/feature_data/git_status_per_feature.py deleted file mode 100644 index 4c195f8..0000000 --- a/git_tool/feature_data/git_status_per_feature.py +++ /dev/null @@ -1,165 +0,0 @@ -from collections import namedtuple -from typing import List, TypedDict - -from git import Commit, GitCommandError -from git_tool.feature_data.file_based_git_info import get_commits_for_file - -from git_tool.feature_data.models_and_context.repo_context import ( - FEATURE_BRANCH_NAME, - branch_folder_list, - repo_context, -) - - -class GitChanges(TypedDict): - """ - List files by git status - """ - - staged_files: List[str] - unstaged_files: List[str] - untracked_files: List[str] - - -GitStatusEntry = namedtuple("GitStatusEntry", ["status", "file_path"]) - -# Usages: FEATURE ADD, ADD-FROM-STAGED, PRE-COMMIT, STATUS -def get_files_by_git_change() -> GitChanges: - """ - Retrieves files sorted by the type of git change (staged, unstaged, untracked). - Can be used in combination with finding feature annotations for files to help - figure out which features are already staged. - - Returns: - Dict[str, List[str]]: A dictionary with keys 'staged_files', 'unstaged_files', - and 'untracked_files', each containing a list of file paths. - """ - - def convert_to_status_entry(short_status_line: str) -> GitStatusEntry: - return GitStatusEntry(short_status_line[:2], short_status_line[3:]) - - with repo_context() as repo: - result = repo.git.status("-s") - lines = list(map(convert_to_status_entry, result.split("\n"))) - changes: GitChanges = { - "staged_files": [], - "unstaged_files": [], - "untracked_files": [], - } - for entry in lines: - if entry.status and entry.status[0] != " " and entry.status != "??": - changes["staged_files"].append(entry.file_path) - if entry.status and entry.status[1] != " " and entry.status != "??": - changes["unstaged_files"].append(entry.file_path) - if entry.status == "??": - changes["untracked_files"].append(entry.file_path) - - return dict(changes) - - -def find_annotations_for_file(file: str): - """ - Parse file for comment-based feature hints and search for file and folder annotations. - This makes use of the feature-annotation system. Not documented further here. - """ - raise NotImplementedError - -# Usage: FEATURE ADD-FROM-STAGED, BLAME, STATUS -def get_features_for_file( - file_path: str, use_annotations: bool = False -) -> List[str]: - """ - Retrieves features for a given file. - - This function determines which features are associated with a specific file - in a Git repository. It can use either feature annotations or the commit history - to determine these associations. - - If the `use_annotations` flag is set to True, the function searches for annotations - in the folder, file, and line levels. Otherwise, it examines the commit history to - identify features associated with the file. - - Args: - file_path (str): The path to the file whose features are to be retrieved. - use_annotations (bool): Flag indicating whether to use annotations for - determining features. Defaults to False. - - Returns: - List[str]: A list of features associated with the file. If no features are - found, an empty list is returned. - """ - features = [] - if use_annotations: - features = find_annotations_for_file(file_path) - return features - - commits = get_commits_for_file(file_name=file_path, branch_name=None) - with branch_folder_list() as (feature_folders, _): - for commit in commits: - for feature in feature_folders: - feature_name = get_feature_name_from_folder(feature) - if commit_in_feature_folder(commit, feature_name): - features.append(feature_name) - return features - -# Usages: FEATURE INFO, FEATURE STATUS -def get_commits_for_feature(feature_uuid: str) -> list[Commit]: - - with repo_context() as repo: - output = repo.git.ls_tree( - "-d", "--name-only", f"{FEATURE_BRANCH_NAME}:{feature_uuid}" - ) - return [repo.commit(x) for x in output.split("\n")] - - -def commit_in_feature_folder(commit: str, feature_folder: str) -> bool: - """ - Check if a commit is present for a feature. - - Args: - commit (str): The commit hash. - feature_folder (str): The path to the feature folder. - - Returns: - bool: True if the commit is present in the feature folder, False otherwise. - """ - assert isinstance( - commit, str - ), f"Expected commit to be a string, but got {type(commit).__name__}" - assert isinstance( - feature_folder, str - ), f"Expected feature_folder to be a string, but got {type(feature_folder).__name__}" - with repo_context() as repo: - commit_obj =repo.commit(commit) - result = commit_obj.hexsha in [x.hexsha for x in get_commits_for_feature(feature_uuid=feature_folder)] - return result - - -def get_feature_for_hunk(file_path: str, hunk: str) -> List[str]: - """ - Retrieves features for specific changes (hunks) in a given file. - - Args: - file_path (str): The path to the file. - hunk (str): The specific changes (hunks) in the file. - - Returns: - List[str]: A list of features associated with the specific hunks in the file. - """ - raise NotImplementedError() - # features = get_features_for_hunk(file_path, hunk) - # return features - - -def get_feature_name_from_folder(feature_folder: str) -> str: - """ - Retrieve the feature name from a feature folder path. - - Args: - feature_folder (str): The path to the feature folder. - - Returns: - str: The name of the feature. - """ - # Extract the feature name from the folder path - return str(feature_folder) diff --git a/git_tool/feature_data/models_and_context/fact_model.py b/git_tool/feature_data/models_and_context/fact_model.py deleted file mode 100644 index bfdaa01..0000000 --- a/git_tool/feature_data/models_and_context/fact_model.py +++ /dev/null @@ -1,71 +0,0 @@ -from datetime import datetime -from enum import Enum -from typing import Optional - -from git import List, Union -from pydantic import BaseModel, ValidationError - -from git_tool.feature_data.models_and_context.repo_context import ( - FEATURE_BRANCH_NAME, - repo_context, -) - - -class ChangeType(str, Enum): - ADDED = "hinzugefügt" - MODIFIED = "geändert" - REMOVED = "entfernt" - - -class ConstraintChangeType(str, Enum): - DELETED = "Abhängigkeit gelöscht" - ADDED = "Abhängigkeit hinzugefügt" - MODIFIED = "Abhängigkeit angepasst" - - -class ChangeDetail(BaseModel): - change_type: ChangeType - description: str - - -class UpdateName(BaseModel): - feature_name: str - - -class ConstraintDetail(BaseModel): - constraint_type: ConstraintChangeType - description: str - - -class ChangeHolder(BaseModel): - code_changes: List[ChangeDetail] - name_change: Optional[UpdateName] - constraint_changes: List[ConstraintDetail] - - -class FeatureFactModel(BaseModel): - commit: str - authors: list[str] - date: datetime - features: list[str] - changes: ChangeHolder - - class Config: - json_encoders = { - datetime: lambda v: v.isoformat(), - } - - -def get_fact_from_featurefile(filename: str) -> FeatureFactModel | None: - print("evaluating", filename) - with repo_context() as repo: - try: - file_content = repo.git.show(f"{FEATURE_BRANCH_NAME}:{filename}") - return FeatureFactModel.model_validate_json(file_content) - except ValidationError as e: - print( - "Validation didn't work", - ) - print(e) - finally: - return None diff --git a/git_tool/feature_data/models_and_context/feature_state.py b/git_tool/feature_data/models_and_context/feature_state.py deleted file mode 100644 index 0f0f131..0000000 --- a/git_tool/feature_data/models_and_context/feature_state.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -To prepare messages and store the set of features used, we need to create a file that contains all feature data. -""" - -from pathlib import Path -from typing import List - -from git_tool.feature_data.models_and_context.repo_context import repo_context - - - -def get_feature_file()-> Path: - """ - Depending on whether the repo is a git worktree or a usual git repo, the resolution of the .git folder - works differently - """ - with repo_context() as repo: - git_repo= Path(repo.git.rev_parse("--show-toplevel")).resolve().joinpath(".git") - if git_repo.is_file(): - return Path(repo.git.rev_parse("--show-toplevel")).resolve().joinpath(".FEATUREINFO") - else: - return git_repo.joinpath("FEATUREINFO") - -def read_staged_featureset() -> List[str]: - """ - Read the list of staged features from the FEATUREINFO file. - - Returns: - List[str]: A list of staged features. - """ - feature_file = get_feature_file() - if not feature_file.exists(): - return [] - with feature_file.open(mode="r", encoding="utf-8") as f: - features = set(line.strip() for line in f.readlines()) - return list(features) - -# Usage: FEATURE ADD, ADD-FROM-STAGED -def write_staged_featureset(features: List[str]): - """ - Write the list of staged features to the FEATUREINFO file. - - Args: - features (List[str]): A list of features to be written. - """ - if not get_feature_file().exists(): - get_feature_file().touch() - with get_feature_file().open(mode="w+", encoding="utf-8") as f: - for feature in features: - f.write(f"{feature}\n") - -def reset_staged_featureset(): - """ - Reset the FEATUREINFO file by clearing its content. - """ - feature_file = get_feature_file() - if feature_file.exists(): - feature_file.unlink() \ No newline at end of file diff --git a/git_tool/feature_data/models_and_context/repo_context.py b/git_tool/feature_data/models_and_context/repo_context.py deleted file mode 100644 index a4ae7b9..0000000 --- a/git_tool/feature_data/models_and_context/repo_context.py +++ /dev/null @@ -1,214 +0,0 @@ -from datetime import datetime, timedelta -import os -import tempfile -from contextlib import contextmanager -from functools import wraps -from pathlib import Path -from typing import Generator, Iterable, Tuple - -import git -from dotenv import load_dotenv -import typer - -load_dotenv(Path(__file__).parents[1].joinpath(".env").absolute()) -FEATURE_BRANCH_NAME = os.getenv("BRANCH_NAME", "feature-metadata") -# FEATURE_BRANCH_NAME = "feature6-metadata" -MAIN_BRANCH_NAME = os.getenv("MAIN_BRANCH_NAME", "main") -REPO_PATH = os.getenv("REPO_PATH", os.getcwd()) -assert FEATURE_BRANCH_NAME is not None -assert MAIN_BRANCH_NAME is not None -assert REPO_PATH is not None - -# print(f"FEATURE_BRANCH_NAME: {FEATURE_BRANCH_NAME}") -# print(f"MAIN_BRANCH_NAME: {MAIN_BRANCH_NAME}") -# print(f"REPO_PATH: {REPO_PATH}") - - -def create_empty_branch(branch_name: str, repo: git.Repo) -> str: - """ - Create an orphan branch without checking it out using fast-import. An orphan branch - does not have any parent commits. - - Args: - branch_name (str): The name of the branch to create. - repo (git.Repo): The Git repository object. - - Returns: - str: The fast-import script as a string. - """ - timestamp = int(datetime.now().timestamp()) - committer_name = os.getenv("GIT_COMMITTER_NAME", "Unknown") - committer_email = os.getenv("GIT_COMMITTER_EMAIL", "unknown@example.com") - - result = [] - result.append(f"commit refs/heads/{branch_name}") - result.append( - f"committer {committer_name} <{committer_email}> {timestamp} +0000" - ) - result.append("data < timedelta(minutes=5)): - update_last_execution_time() - if FEATURE_BRANCH_NAME not in repo.heads: - try: - repo.git.branch(FEATURE_BRANCH_NAME, f"origin/{FEATURE_BRANCH_NAME}") - typer.echo(f"Branch {FEATURE_BRANCH_NAME} created locally from origin.") - except git.GitCommandError: - typer.echo(f"Branch {FEATURE_BRANCH_NAME} does not exist on origin. Creating an empty branch.") - create_empty_branch(FEATURE_BRANCH_NAME, repo) - try: - typer.echo("Fetching new feature-metadata") - repo.git.fetch("origin", FEATURE_BRANCH_NAME) - except: - print("Origin does not have ", FEATURE_BRANCH_NAME) - return func(*args, **kwargs) - - return wrapper - - -@ensure_feature_branch -@contextmanager -def repo_context(repo_path=REPO_PATH): - repo = git.Repo(repo_path) - try: - yield repo - finally: - del repo - - -@contextmanager -def branch_folder_list( - branch: str = FEATURE_BRANCH_NAME, repo_path: str = REPO_PATH -) -> Generator[Tuple[Iterable[Path], git.Repo], None, None]: - """ - Get all folders in a branch. Standard is to list all feature folders - in the feature metadata branch. - - Args: - branch (str): Branch that is analysed for folders - repo_path (str): Path to git repo - - Yields: - Tuple[Iterable[Path], git.Repo]: A tuple where the first element is an - iterable of feature folder paths as pathlib.Path objects, and the second element is the git repository object. - """ - with repo_context(repo_path) as repo: - try: - feature_folders = repo.git.ls_tree( - "-d", "--name-only", branch - ).split("\n") - yield feature_folders, repo - finally: - pass - - -def get_current_branch() -> str: - with repo_context() as repo: - return repo.active_branch.name - - -def sync_feature_branch(): - with repo_context() as repo: - remote_name = "origin" - try: - print(f"Fetching {FEATURE_BRANCH_NAME} from {remote_name}") - repo.git.fetch(remote_name, FEATURE_BRANCH_NAME) - except Exception as e: - print(f"Error fetching the branch: {e}") - return - - try: - print(f"Pushing {FEATURE_BRANCH_NAME} to {remote_name}") - repo.git.push(remote_name, FEATURE_BRANCH_NAME, force_with_lease=True) - except Exception as e: - # print(f"Error pushing the branch: {e}") - print("Warning: Feature Updates could not be pushed to remote") - - -def get_yes_no_input(prompt: str) -> bool: - while True: - response = input(prompt).strip().lower() - if response in ["yes", "y"]: - return True - elif response in ["no", "n"]: - return False - else: - print("Please enter 'yes' or 'no'.") - - -def get_all_commits() -> list[str]: - with repo_context() as repo: - not_string = f"^refs/heads/{FEATURE_BRANCH_NAME}" - all_commits = repo.git.log( - "--all", not_string, "--no-merges", "--pretty=format:%H" - ) - commit_list = all_commits.splitlines() - return commit_list - - -def get_commit_title(commit_id: str) -> str: - with repo_context() as repo: - commit = repo.commit(commit_id) - return commit.summary - - -def get_current_branchname() -> str: - with repo_context() as repo: - try: - return repo.active_branch.name - except TypeError: - return "HEAD detached" diff --git a/git_tool/feature_data/read_feature_data/parse_data.py b/git_tool/feature_data/read_feature_data/parse_data.py deleted file mode 100644 index 29c4433..0000000 --- a/git_tool/feature_data/read_feature_data/parse_data.py +++ /dev/null @@ -1,214 +0,0 @@ -""" - All functions needed to parse data from feature files. - -""" - -import logging -from typing import Generator, List, Optional, Set, Tuple - -from git import Commit - -from git_tool.feature_data.models_and_context.fact_model import FeatureFactModel -from git_tool.feature_data.models_and_context.repo_context import ( - FEATURE_BRANCH_NAME, - branch_folder_list, - repo_context, -) - -# Usages: FEATURE INFO-ALL -def _get_feature_uuids() -> list[str]: - """ - Each feature has its own folder where the foldername is equivalent to the uuid that the - feature has. - Get all directories at the upper level of the Feature data branch - - Returns: - list[str]: UUIDs of Features - """ - with repo_context() as repo: - folder_string = repo.git.ls_tree( - "-d", FEATURE_BRANCH_NAME, name_only=True - ) - folders = folder_string.split() - return folders - - -def _get_associated_files(feature_uuid: str, ref_commit: str) -> list[str]: - return [] - - -def _get_fact_from_featurefile(file: str): - with open(file, "r") as f: - return FeatureFactModel.model_validate_json(f.read()) - - -def get_metadata( - feature_uuid: str, ref_commit: Optional[str] = None -) -> list[FeatureFactModel]: - """ - Get all facts about the feature that are true for ref_commit. - If ref_commit is not specified, use latest commit. - Output describes cummulation of all facts and can be used as foundation to display - feature evolution. - Only facts that are merged in the main branch are used. Currently, the name must be main. - - Args: - feature_uuid (str): Feature Reference - ref_commit (Optional[str]): Commit Identifier which is the last one - - Returns: - CumulatedFactsModel: List of all facts sorted chronologically - """ - facts = [ - _get_fact_from_featurefile(f) - for f in _get_associated_files( - feature_uuid=feature_uuid, ref_commit=ref_commit - ) - ] - facts = [x for x in facts if x is not None] - return facts - - -def get_feature_log(feature_uuid: str): - """ - Output all commits from a feature to the command line. - # TODO Still need to incorpoarte all possible flags of git log somehow - - Args: - feature_uuid (str): Reference for feature - """ - commit_ids = [ - fact.commit for fact in get_metadata(feature_uuid=feature_uuid) - ] - print(commit_ids) - with repo_context() as repo: - print( - repo.git.log( - "--no-walk", - "--decorate", - "--color", - commit_ids, - ) - ) - -# Usages: compare_branches.py (potentially FEATURE BLAME) -def get_features_touched_by_commit(commit: Commit) -> Set[str]: - """ - Retrieves the set of features touched by a given commit. - - Args: - commit (Commit): The commit to analyze. - - Returns: - Set[str]: Set of features touched by the commit. - """ - # Annahme: Diese Funktion wertet die Änderungen in einem Commit aus und extrahiert die Features. - feature_facts = extract_facts_from_commit(commit) - features = set() - for fact in feature_facts: - features.update(fact.features) - return features - - -def get_feature_sets_for_branch(branch_name: str) -> List[Set[str]]: - """ - Retrieves the sets of features touched together in a branch. - - Args: - branch_name (str): The branch to analyze. - - Returns: - List[Set[str]]: List of sets of features touched together in the branch. - """ - with repo_context() as repo: - commits = list(repo.iter_commits(branch_name)) - feature_sets = [ - get_features_touched_by_commit(commit) for commit in commits - ] - # TODO optimize: for each feature check for each commit if that commit is on the branch. else there will be redundant checks - return feature_sets - - -def is_commit_compatible_with_branch( - commit: Commit, branch_name: str -) -> Tuple[bool, Set[str]]: - """ - Checks if a commit is compatible with a branch. - - Args: - commit (Commit): The commit to check. - branch_name (str): The branch to check against. - - Returns: - Tuple[bool, Set[str]]: Whether the commit is compatible and the set of features it touches. - """ - commit_features = get_features_touched_by_commit(commit) - branch_feature_sets = get_feature_sets_for_branch(branch_name) - - for feature_set in branch_feature_sets: - if commit_features.issubset(feature_set): - return (True, commit_features) - return (False, commit_features) - - -def display_results_and_check_warnings(commit: Commit, branch_name: str): - """ - Displays the feature set result to the user and gives warnings or errors depending on the result. - - Args: - commit (Commit): The commit to analyze. - branch_name (str): The branch to check against. - """ - is_compatible, commit_features = is_commit_compatible_with_branch( - commit, branch_name - ) - if is_compatible: - print(f"Commit {commit} is compatible with branch {branch_name}.") - else: - print(f"Commit {commit} is NOT compatible with branch {branch_name}.") - print(f"Touched features: {commit_features}") - logging.warning( - f"Commit {commit} touches features not fully compatible with branch {branch_name}." - ) - - -def extract_facts_from_commit(commit: Commit) -> List[FeatureFactModel]: - """ - Extracts facts from a given commit. - - Args: - commit (Commit): The commit to analyze. - - Returns: - List[FeatureFactModel]: List of facts extracted from the commit. - """ - facts = [] - with branch_folder_list() as (features, repo): - for feature in features: - try: - commits = repo.git.ls_tree( - "-r", "--name-only", FEATURE_BRANCH_NAME, feature - ).split() - associated_w_commit = [x for x in commits if str(commit) in x] - for file in associated_w_commit: - file_content = repo.git.show( - f"{FEATURE_BRANCH_NAME}:{file}" - ) - facts.append( - FeatureFactModel.model_validate_json(file_content) - ) - except: - print("error") - return facts - - -if __name__ == "__main__": - # get_metadata("abc") - # logging.info("Get Metadata success") - # get_feature_log("abc") - ids = _get_feature_uuids() - print(ids) - feature = input(f"Select one of {ids}\n") - # if len(ids) >= 1: - # _resolve_feature_name(ids[0]) - get_feature_log(feature) diff --git a/git_tool/feature_data/utils/fast_import_utils.py b/git_tool/feature_data/utils/fast_import_utils.py deleted file mode 100644 index 75dd546..0000000 --- a/git_tool/feature_data/utils/fast_import_utils.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -Module that wraps git fast-import -> https://git-scm.com/docs/git-fast-import -This is the best way to add commits to a branch which is currently not checked out, -which is exactly what we try to prevent in this setup. -Here, all the boilerplate code goes that creates the commit meta data and makes sure -that the content is added as expected -""" - -from datetime import datetime -from pathlib import Path -from typing import List - -from git import GitCommandError -from pydantic import BaseModel, EmailStr - -from git_tool.feature_data.models_and_context import repo_context - - -class FastImportCommitData(BaseModel): - """ - Datastructure to store the file and its expected contents. - - """ - - permissions: str = "644" # all files have same permissions - file_path: str | Path - content: str - - @property - def content_length(self) -> int: - return len(self.content.encode("utf-8")) - - -class AccumulatedCommitData(BaseModel): - """ - Holds all meta information for the commit as well as - all files that should be added. - Because we are working with immutable filecontents - to ensure absence of merge conflicts, we only have - added contents. - """ - - branch_name: str - committer_name: str - committer_email: EmailStr - commit_datetime: datetime = datetime.now() - message: str - add_files: List[FastImportCommitData] - - @property - def message_length(self) -> int: - return len(self.message.encode("utf-8")) - - @property - def timestamp(self) -> int: - """ - Format according to fast-import specs - """ - return int(self.commit_datetime.timestamp()) - - @property - def timezone(self) -> str: - offset = self.commit_datetime.utcoffset() - if offset is None: - return "+0000" - total_minutes = int(offset.total_seconds() // 60) - hours, minutes = divmod(abs(total_minutes), 60) - sign = "+" if total_minutes >= 0 else "-" - return f"{sign}{hours:02}{minutes:02}" - - def to_partial_fast_import_format(self) -> str: - """ - This function returns the string as expected for one change to the branch. - Please note that this is not a fully viable fast import text strig, as it - is missing the done tag. - - It includes the following information, derived from the object: - - Commit reference: Specifies the branch the commit is for (usually automatically derived, metadata branch). - - Committer: The name and email of the committer along with the timestamp and timezone. - - Commit message length and message: The length of the commit message and the actual message. - - From: Indicates the parent commit reference (if it exists). Done by using the repo context and looking for the last commit on the branch - - File changes: Specifies the permissions, path, and content of the files being added/modified. - - Returns: - str: Fast-import compatible format. Still lacking info. Needs to be used with - to_fast_import_format - """ - result = [] - result.append(f"commit refs/heads/{self.branch_name}") - result.append( - f"committer {self.committer_name} <{self.committer_email}> {self.timestamp} {self.timezone}" - ) - result.append(f"data {self.message_length}") - result.append(self.message) - with repo_context.repo_context() as repo: - try: - from_message: str = ( - f"from {repo.git.rev_parse(f'refs/heads/{self.branch_name}')}" - ) - result.append(from_message) - except GitCommandError: - print( - "This will be the first commit on the feature data branch" - ) - for change in self.add_files: - result.append(f"M {change.permissions} inline {change.file_path}") - # print( - # "Fileline is ", - # f"M {change.permissions} inline {change.file_path}", - # ) - result.append(f"data {change.content_length}") - result.append(change.content) - return "\n".join(result) - - -def to_fast_import_format(commits: list[AccumulatedCommitData]) -> str: - """ - This function converts multiple CommitData objects into a fast-import compatible format. - - Returns: - str: Full fast import format string including the done tag. - """ - result = [commit.to_partial_fast_import_format() for commit in commits] - result.append("done") - return "\n".join(result) + "\n" - - -# Example Code. This cannot be executed in this context -if __name__ == "__main__": - commit_change = FastImportCommitData( - file_path="new2", - content="This bug really sucks2", - ) - - commit = AccumulatedCommitData( - branch_name="test", - committer_name="Tabea", - committer_email="agserm@gmail.com", - message="Added 'This bug really sucks'", - add_files=[commit_change], - ) - - fast_import_data = to_fast_import_format([commit]) - - # Schreibe die Daten in eine Datei - with open("git_fast_import_data.txt", "w") as f: - f.write(fast_import_data) - - print("git fast-import data written to git_fast_import_data.txt") diff --git a/git_tool/finding_features.py b/git_tool/finding_features.py deleted file mode 100644 index 38d9c05..0000000 --- a/git_tool/finding_features.py +++ /dev/null @@ -1,52 +0,0 @@ -import re -from dataclasses import dataclass - -from git import Diff - - -@dataclass -class FeatureMatches: - name: str - code: str - - -def extract_features_from_annotation(text: str) -> list[FeatureMatches]: - """Extract Features as well as information about their location in code depending on - &begin[] und &end[] Tags. - - @param text: content from which features are extracted - """ - # regex for regex101.com - # &begin\[(?.*?)\](?.*?)&end\[\1\] - # with flags gms - feature_pattern = r"&begin\[(?P.*?)\](?P.*?)&end\[(?P=FeatureName)\]" - feature_matches = re.finditer( - pattern=feature_pattern, string=text, flags=re.DOTALL - ) - feature_list = [ - FeatureMatches( - name=match.group("FeatureName"), - code=match.group("FeatureCode").strip(), - ) - for match in feature_matches - ] - - return feature_list - - -def get_features_for_diff(diff: Diff) -> list[FeatureMatches]: - str_diff = ( - diff.diff.decode("utf-8") if isinstance(diff.diff, bytes) else diff.diff - ) - features = extract_features_from_annotation(str_diff) - # features can also be assigned from the file itself as well as from file and folder mappings - # skip that for now - return features - - -def features_for_file_by_annotation(file_name: str) -> list[str]: - assigned_by_file = [] - assigned_by_folder = [] - with open(file_name, "r") as f: - assigned_in_code = extract_features_from_annotation(f.read()) - return assigned_by_file + assigned_by_folder + assigned_in_code diff --git a/git_tool/helpdoc/git-feature.1 b/git_tool/helpdoc/git-feature.1 deleted file mode 100644 index f14d574..0000000 --- a/git_tool/helpdoc/git-feature.1 +++ /dev/null @@ -1,26 +0,0 @@ -.TH git-feature 1 "12 April 2025" "Kim Nguyen" "git-feature(1) Manual Page" -.SH NAME -git-feature - Manage feature metadata and inspect feature-related information - -.SH SYNOPSIS -.P -\fB git feature status \fP -.P -\fB git feature add \fP [-a | --all | -f | --files ] -.P -\fB git feature add-from-staged \fP -.P -\fB git feature commit \fP [--features ] -.P -\fB git feature commits missing \fP -.P -\fB git feature commits list \fP -.P -\fB git feature blame \fP -.P -\fB git feature info-all \fP -.P -\fBgit feature info\fP [--authors | --files | --branches | --updatable | --branch ] - -.SH COMMANDS -With no arguments, shows the help text. Several subcommands are available to perform operations on the feature metadata. \ No newline at end of file diff --git a/git_tool/helpdoc/git-feature.html b/git_tool/helpdoc/git-feature.html deleted file mode 100644 index 04424df..0000000 --- a/git_tool/helpdoc/git-feature.html +++ /dev/null @@ -1,2334 +0,0 @@ - - - - - - - - git-feature(1) - - - - - -
-
-

SYNOPSIS

-
-
-
git feature status
-git feature add <feature_names> (-a | --all | -f <file_name> | --files <file_name>)
-git feature add-from-staged
-git feature commit <commit_id> [--features <feature_name>]
-git feature commits list
-git feature commits missing
-git feature blame <file_name>
-git feature info-all [--push] <name> <newurl> [<oldurl>]
-git feature info [--authors | --files | --branches | --updatable | --branch <branch_name>]
-
-
-
-
-

DESCRIPTION

-
-
-

Associate features with commits and files in a repository, manage feature metadata, and inspect feature-related information.

-
-
-
-
-

COMMANDS

-
-
-

With no subcommands, shows the help text. Several subcommands are available to track and manage feature information.

-
-
-
-
status
-
-

Displays the current status of files in the working directory, showing staged, unstaged, and untracked changes - along with their associated features. This command is intended to be used with the following commands to add - feature information while staging files. It can also be used to aid in the selection of files for the next commit.

-
-
add
-
-

Stage specific <file_names> or all files and associate them with the provided features.

-
-

With -a or --all, you can stage all tracked changes and associate them with the features.

-
-
-

With -f or --files, you can stage a <file_name> and associate it with the features. - You can use this option multiple times to stage multiple files.

-
-
-
add-from-staged
-
-

Associate features with the currently staged files. Currently, it derives the feature list from the previously selected - features for those files and is not fully implemented yet.

-
-
commit
-
-

Associates feature information with an existing commit. This is useful when you want to add feature metadata to a commit - after it has been created.

-
-

With --features, a feature name is manually specified. You can use this option multiple times to associate - multiple features. If this is provided, staged feature information will be ignored. Otherwise, the program will try - to derive the information.

-
-
-
commits
-
-

Provides information about commits that are either already associated, or have not yet been associated with a feature. - This is especially useful when transitioning to feature-based workflows or when tracing commits for specific features. - It helps identifying which commits have feature metadata associated and which do not, ensuring that no commit is left - untracked.

-
-

With the list subcommand, show a list of commits that are already associated with features.

-
-
-

With the missing subcommand, show a list of commits that have not yet been associated with features.

-
-
-
blame
-
-

Display features associated with <file_name> lines. Currently, it shows the associated file and has not yet been fully - implemented yet.

-
-
info-all
-
-

List all available features in the project.

-
-
info
-
-

Shows detailed information about the feature <feature_name>, including authors, files, branches, and update status.

-
-

With --authors, show all authors who contributed to this feature.

-
-
-

With --files, list all files associated with this feature.

-
-
-

With --branches, show all branches where this feature is present.

-
-
-

With --branch <branch_name>, show all branches where this feature is present.

-
-
-
-
-
-
-
-

EXAMPLE WORKFLOWS

-
-
-
    -
  • -

    Assess the file location for feature authentication, inspect its commit history, and list its authors

    -
    -
    -
    $ git feature info "authentication" --files
    -    Files associated with 'authentication':
    -    - src/auth/login.py
    -    - src/auth/logout.py
    -    - src/auth/utils.py
    -
    -$ git feature info "authentication" --log
    -    Recent commits for 'authentication':
    -    − mno7890 : Improved password hashing algorithm
    -    − abc1234 : Implemented login functionality
    -    ...
    -$ git show mno7890
    -    commit mno7890
    -    Author: Bob <bob@company.com >
    -    Date: Mon Apr 01 11:12:13 2024
    -    Improved password hashing algorithm
    -
    -    The one used before had a problem.
    -    diff −−git a/src/auth/utils.pyb/src/auth/utils.py
    -    <diff of the commit>
    -
    -$ git feature info "authentication" --authors
    -    Top contributors to 'authentication':
    -    − Alice Smith (5 commits)
    -    − Bob Johnson (3 commits)
    -
    -
    -
  • -
  • -

    Analyze and stage changes, associate with feature add and commit the changes

    -
    -
    -
    $ git feature status
    -    Unstaged changes:
    -        src/main.py [add]
    -        src/operations.py [subtract, add]
    -        src/utils.py [core]
    -
    -$ git feature add "core" --file src/utils.py
    -    Feature 'core' associated with staged changes.
    -
    -$ git feature status
    -    Unstaged changes:
    -        src/main.py [add]
    -        src/operations.py [subtract, add]
    -
    -    Staged changes:
    -        src/utils.py [core]
    -
    -$ git commit
    -    [main abc1234] Implemented new functionality for core
    -    Feature: core
    -    1 file changed, 50 insertions(+), 10 deletions(-)
    -
    -
    -
    -
  • -
  • -

    Assign feature core to an already existing commit

    -
    -
    -
    $ git log -n 1 --oneline
    -    abc1234 (HEAD -> main) Implemented new functionality for core
    -
    -$ git feature commit "core" abc1234
    -
    -
    -
    -
  • -
  • -

    You find an odd code snippet. Find out the author and which feature it's associated with.

    -
    -
    -
    $ git feature blame --line 3-4
    -    op-select, core (abcd123 Jane Doe 2024-09-10 11) #weird code
    -    op-select, core (abcd123 Jane Doe 2024-09-10 12) #more nonsense
    -
    -
    -
    -
  • -
-
-
-
-
-

GIT

-
-
-

A Git extension built by Tabea Röthemeyer and improved by Kim Nguyen in collaboration with the Software - Engineering Faculty of the Ruhr-Universität Bochum.

-
-
-
-
- - - \ No newline at end of file diff --git a/git_tool/hooks/post-commit b/git_tool/hooks/post-commit deleted file mode 100755 index cc00aae..0000000 --- a/git_tool/hooks/post-commit +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -# Post-Commit Hook: Execute git feature-commit after each commit - -echo "Running feature-post-commit" - -COMMIT_ID=$(git rev-parse HEAD) -git feature commit $COMMIT_ID - -if [ $? -ne 0 ]; then - echo "Error: git feature-commit failed." - exit 1 -fi - -exit 0 diff --git a/git_tool/hooks/pre-commit b/git_tool/hooks/pre-commit deleted file mode 100755 index 3e84af9..0000000 --- a/git_tool/hooks/pre-commit +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -# Pre-Commit Hook: Assert that feature changes were staged and the script does not find reasons to not continue -echo "Running feature-pre-commit" -git feature pre-commit - -if [ $? -ne 0 ]; then - echo "Error: Pre-commit checks failed." - exit 1 # exiting non-zero aborts the commit -fi diff --git a/git_tool/hooks/prepare-commit-msg b/git_tool/hooks/prepare-commit-msg deleted file mode 100755 index 71cd664..0000000 --- a/git_tool/hooks/prepare-commit-msg +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -# Prepare Commit Message Hook: Modify the commit message to include feature information - -COMMIT_MSG_FILE=$1 -COMMIT_SOURCE=$2 -SHA1=$3 - -FEATURE_MSG=$(git feature commit-msg) - -if [ $? -ne 0 ]; then - echo "Error: Failed to generate feature commit message." - exit 0 # Sometimes an overwrite is necessary. still not sure how to handle. but the message is optional -fi - -if [ "$FEATURE_MSG" ]; then - COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") - - echo -e "$FEATURE_MSG\n\n$COMMIT_MSG" > "$COMMIT_MSG_FILE" # Place our message on top -else - echo "Feature information not found: $FEATURE_MSG" -fi diff --git a/git_tool/scripts_for_experiment/set_hooks_path.py b/git_tool/scripts_for_experiment/set_hooks_path.py deleted file mode 100644 index dbd3ea5..0000000 --- a/git_tool/scripts_for_experiment/set_hooks_path.py +++ /dev/null @@ -1,42 +0,0 @@ -import subprocess -import os -import sys -import git_tool - -''' - This script sets the hooks path for git. - It has been added to the pyproject.toml file as "feature-init-hooks" - Originally, the user had to set the hooks path manually, but not anymore. -''' - -def main(): - try: - # Step 1: Get the path to the installed git_tool package - package_path = os.path.abspath(git_tool.__file__) - print(f"Located git_tool at: {package_path}") - - # Step 2: Strip __init__.py and append 'hook' - base_path = os.path.dirname(package_path) - hook_path = os.path.join(base_path, "hooks") - print(f"Using hooks directory: {hook_path}") - - # Step 3: Set git hooksPath - subprocess.run(["git", "config", "core.hooksPath", hook_path], check=True) - print("Git hooks path set successfully.") - - # Step 4: Check the current hooks path - result = subprocess.run( - ["git", "rev-parse", "--git-path", "hooks"], - check=True, - stdout=subprocess.PIPE, - text=True - ) - current_hook_path = result.stdout.strip() - print(f"Git is now using hooks from: {current_hook_path}") - - except subprocess.CalledProcessError as e: - print("Git command failed:", e) - sys.exit(1) - except Exception as e: - print("Something went wrong:", e) - sys.exit(1) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 42e177b..300d3df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,15 @@ [project] -name = "git_tool" -authors = [{ name = "Tabea Röthemeyer", email = "tabea.roethemeyer@rub.de" }, { name = "Kim Nguyen", email = "ho.nguyen@rub.de"}] -license = { file = "LICENSE" } -description = "Support feature-oriented development workflows with git" +name = "git-feature" +version = "2.0.0" +description = "Feature-oriented version control on top of git: annotate code with features, query feature history, derive product variants." readme = "README.md" -version = "1.0.11" -requires-python = ">=3.10" +license = { file = "LICENSE" } +authors = [ + { name = "Tabea Röthemeyer", email = "tabea.roethemeyer@rub.de" }, + { name = "Kim Nguyen", email = "ho.nguyen@rub.de" }, + { name = "Aashutosh Aryal", email = "aashutosh.aryal@edu.ruhr-uni-bochum.de" }, +] +requires-python = ">=3.12" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -13,40 +17,48 @@ classifiers = [ "Development Status :: 3 - Alpha", ] dependencies = [ - "typer>=0.12,<0.13", - "click <8.1.0", - "pydantic[email]>=2.8,<3.0", - "GitPython>=3.1,<4.0", - "python-dotenv>=1.0,<2.0", - "prompt_toolkit>=3.0,<4.0", + "pygit2>=1.15", + "typer>=0.15", + "pydantic>=2.7", + "lark>=1.1", + "python-ulid>=2.6", + "xxhash>=3.4", ] [project.scripts] -git-feature = "git_tool.__main__.py:app" -feature-init-hooks = "git_tool.scripts_for_experiment.set_hooks_path:main" +git-feature = "git_feature.cli:app" + +[dependency-groups] +dev = [ + "pytest>=8.2", + "ruff>=0.5", + "mypy>=1.10", +] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" - [tool.hatch.build.targets.wheel] -packages = ["git_tool"] +packages = ["git_feature"] + +[tool.ruff] +line-length = 100 +target-version = "py312" +extend-exclude = ["example"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.12" +warn_unused_ignores = true +warn_redundant_casts = true +disallow_untyped_defs = true + +[[tool.mypy.overrides]] +module = ["pygit2.*", "xxhash.*"] +ignore_missing_imports = true [tool.pytest.ini_options] -testpaths = ["test"] -addopts = "-s" -pythonpath = ["."] - -[tool.black] -line-length = 80 -target-version = ['py38'] -include = '\.pyi?$' -extend-exclude = ''' -/( - # The following are specific to Black, you probably don't want those. - tests/data/ - | profiling/ - | scripts/generate_schema.py # Uses match syntax -) -''' +testpaths = ["tests"] diff --git a/requirements.dev.txt b/requirements.dev.txt deleted file mode 100644 index b5f8ac8..0000000 --- a/requirements.dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -pytest -black -pylint -build \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0254a3a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -typer -pydantic[email] -GitPython -python-dotenv -prompt_toolkit \ No newline at end of file diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 4e3d766..0000000 --- a/src/main.py +++ /dev/null @@ -1,5 +0,0 @@ -# &begin[test] -def test(): ... - - -# &end[test] diff --git a/test/conftest.py b/test/conftest.py deleted file mode 100644 index a3b0fec..0000000 --- a/test/conftest.py +++ /dev/null @@ -1 +0,0 @@ -from fixtures.git_test_repo import git_repo diff --git a/test/fixtures/git_test_repo.py b/test/fixtures/git_test_repo.py deleted file mode 100644 index c98b4e0..0000000 --- a/test/fixtures/git_test_repo.py +++ /dev/null @@ -1,18 +0,0 @@ -import shutil - -import pytest -from git import Repo - - -@pytest.fixture(scope="module") -def git_repo(tmp_path_factory): - repo_path = tmp_path_factory.mktemp("git_repo") - repo = Repo.init(repo_path) - repo.config_writer().set_value("user", "name", "Test User").release() - repo.config_writer().set_value( - "user", "email", "test@example.com" - ).release() - - yield repo - - shutil.rmtree(repo_path) diff --git a/test/test_finding_features.py b/test/test_finding_features.py deleted file mode 100644 index 12607b9..0000000 --- a/test/test_finding_features.py +++ /dev/null @@ -1,49 +0,0 @@ -from pathlib import Path - -from git_tool import finding_features - -TESTSTRING = """ - Hier ist etwas Code &begin[FEATURE1] - Code für FEATURE1 - weiterer Code für FEATURE1 - &end[FEATURE1] - &begin[FEATURE2] - Code für FEATURE2 - &end[FEATURE2] - """ - - -def test_extract_features(): - features = finding_features.extract_features_from_annotation(TESTSTRING) - feature_names = list(map(lambda x: x.name, features)) - assert feature_names == ["FEATURE1", "FEATURE2"] - - -def test_git_features(git_repo): - # Dateipfad im Repository - repo_path = Path(git_repo.working_tree_dir) - file_path = repo_path / "test.py" - file_path.write_text("# Initial commit\n") - git_repo.index.add([str(file_path)]) - git_repo.index.commit("Initial commit") - - # Schreibe in die Datei mit Feature Annotationen - file_path.write_text( - file_path.read_text() - + "# &begin[Feature1]\n" - + "print('Hello, Feature1!')\n" - + "# &end[Feature1]\n" - ) - head_commit = git_repo.head.commit - diff = head_commit.diff(None, create_patch=True) - assert len(diff) == 1, "Exactly one diff should be created here" - mapped_to_feature = [ - (finding_features.get_features_for_diff(d), d.a_path) for d in diff - ] - found_features = mapped_to_feature[0][0] - assert ( - len(found_features) == 1 - ), "Expecting one Feature that is not committed" - assert ( - found_features[0].name == "Feature1" - ), "Expecting to find the correct name for feature" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1ebc6c6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,76 @@ +"""Shared pytest fixtures.""" + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.identity import ChangeIdMap +from git_feature.store import GitRefStore +from tests.fixtures import RepoBuilder + + +@pytest.fixture +def repo_builder(tmp_path: Path) -> Callable[[str], RepoBuilder]: + """Factory creating named throwaway repositories under tmp_path.""" + + def make(name: str = "repo") -> RepoBuilder: + path = tmp_path / name + path.mkdir() + return RepoBuilder(path) + + return make + + +@dataclass +class AnnotatedRepo: + """Initialized repo with two annotated feature commits and a release branch.""" + + builder: RepoBuilder + base_sha: str + auth_sha: str + ui_sha: str + auth_cid: str + ui_cid: str + + +@pytest.fixture +def annotated_repo( + repo_builder: Callable[[str], RepoBuilder], monkeypatch: pytest.MonkeyPatch +) -> AnnotatedRepo: + """Fixture repo: base → init → auth commit (annotated) → ui commit (annotated). + + A 'release' branch contains the auth commit but not the ui commit. + """ + runner = CliRunner() + builder = repo_builder("annotated") + builder.commit("base", files={"src/core.py": "def core():\n pass\n"}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + base_sha = builder.commit("add model.cfr") + + auth_sha = builder.commit( + "add login", files={"src/auth/login.py": "def login():\n return check()\n"} + ) + builder.branch("release") + ui_sha = builder.commit( + "add render", files={"src/ui/render.py": "def render():\n return draw()\n"} + ) + assert runner.invoke(app, ["annotate", auth_sha, "--feature", "auth"]).exit_code == 0 + assert runner.invoke(app, ["annotate", ui_sha, "--feature", "ui"]).exit_code == 0 + + cid_map = ChangeIdMap(GitRefStore(builder.repo)) + auth_record = cid_map.get(auth_sha) + ui_record = cid_map.get(ui_sha) + assert auth_record is not None and ui_record is not None + return AnnotatedRepo( + builder=builder, + base_sha=base_sha, + auth_sha=auth_sha, + ui_sha=ui_sha, + auth_cid=auth_record.change_id, + ui_cid=ui_record.change_id, + ) diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..58a442f --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,89 @@ +"""Programmatic throwaway git repositories for tests.""" + +from pathlib import Path + +import pygit2 + +_SIG_NAME = "Test User" +_SIG_EMAIL = "test@example.com" + + +class RepoBuilder: + """Builds a real git repository step by step (commit, branch, merge).""" + + def __init__(self, path: Path) -> None: + self.path = path + self.repo = pygit2.init_repository(str(path)) + self._clock = 1_700_000_000 + + @property + def default_branch(self) -> str: + """Name of the branch HEAD was born on.""" + return self.repo.head.shorthand + + def _signature(self) -> pygit2.Signature: + self._clock += 60 + return pygit2.Signature(_SIG_NAME, _SIG_EMAIL, self._clock, 0) + + def write(self, relpath: str, content: str) -> None: + """Write a file inside the working tree, creating parent directories.""" + target = self.path / relpath + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + + def commit( + self, + message: str, + files: dict[str, str] | None = None, + remove: list[str] | None = None, + ) -> str: + """Write the given files, stage everything, and commit; returns the sha.""" + for relpath, content in (files or {}).items(): + self.write(relpath, content) + index = self.repo.index + for relpath in remove or []: + (self.path / relpath).unlink() + index.remove(relpath) + index.add_all() + index.write() + tree = index.write_tree() + parents = [] if self.repo.head_is_unborn else [self.repo.head.target] + sig = self._signature() + oid = self.repo.create_commit("HEAD", sig, sig, message, tree, parents) + return str(oid) + + def amend(self, message: str, files: dict[str, str] | None = None) -> str: + """Amend the HEAD commit (new message and/or files); returns the new sha.""" + for relpath, content in (files or {}).items(): + self.write(relpath, content) + index = self.repo.index + index.add_all() + index.write() + tree = index.write_tree() + head = self.repo[self.repo.head.target].peel(pygit2.Commit) + oid = self.repo.amend_commit(head, "HEAD", message=message, tree=tree) + return str(oid) + + def branch(self, name: str, at: str | None = None) -> None: + """Create a branch at the given sha (default: current HEAD).""" + target = self.repo[at] if at else self.repo[self.repo.head.target] + self.repo.branches.local.create(name, target.peel(pygit2.Commit)) + + def switch(self, name: str) -> None: + """Check out an existing branch.""" + self.repo.checkout(f"refs/heads/{name}") + + def merge(self, other_branch: str, message: str) -> str: + """Merge another branch into the current one (must not conflict).""" + their = self.repo.branches.local[other_branch].peel(pygit2.Commit) + self.repo.merge(their.id) + if self.repo.index.conflicts is not None: + raise RuntimeError(f"fixture merge of {other_branch!r} conflicted") + tree = self.repo.index.write_tree() + sig = self._signature() + oid = self.repo.create_commit( + "HEAD", sig, sig, message, tree, [self.repo.head.target, their.id] + ) + self.repo.state_cleanup() + self.repo.checkout_head(strategy=pygit2.enums.CheckoutStrategy.FORCE) + return str(oid) diff --git a/tests/test_annotate.py b/tests/test_annotate.py new file mode 100644 index 0000000..f94aef7 --- /dev/null +++ b/tests/test_annotate.py @@ -0,0 +1,128 @@ +"""Tests for bulk/scripted `git feature annotate`.""" + +import json +from collections.abc import Callable + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.identity import ChangeIdMap +from git_feature.store import GitRefStore +from tests.fixtures import RepoBuilder + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def builder( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> RepoBuilder: + builder = repo_builder("repo") + builder.commit("base", files={"src/core.py": "def core():\n pass\n"}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + builder.commit("add model.cfr") + return builder + + +def _annotations(builder: RepoBuilder, sha: str) -> list: + store = GitRefStore(builder.repo) + record = ChangeIdMap(store).get(sha) + assert record is not None, "commit should have been lazily minted" + return store.read_annotations(record.change_id) + + +def test_annotate_head_with_one_feature(runner: CliRunner, builder: RepoBuilder) -> None: + sha = builder.commit("auth work", files={"src/auth.py": "def login():\n pass\n"}) + result = runner.invoke(app, ["annotate", "--feature", "auth"]) + assert result.exit_code == 0, result.output + annotations = _annotations(builder, sha) + assert len(annotations) == 1 + assert annotations[0].presence == "auth" + assert annotations[0].anchor.path == "src/auth.py" + + +def test_annotate_mints_change_id_lazily(runner: CliRunner, builder: RepoBuilder) -> None: + # a pre-existing commit with no change-id gets one the moment it is annotated + old_sha = builder.commit("old work", files={"src/old.py": "x = 1\n"}) + builder.commit("newer", files={"src/newer.py": "y = 2\n"}) + assert ChangeIdMap(GitRefStore(builder.repo)).get(old_sha) is None + + result = runner.invoke(app, ["annotate", old_sha, "--feature", "legacy"]) + assert result.exit_code == 0, result.output + assert "minted 1 change-id(s)" in result.output + record = ChangeIdMap(GitRefStore(builder.repo)).get(old_sha) + assert record is not None + assert record.patch_id is not None + assert len(_annotations(builder, old_sha)) == 1 + + +def test_multiple_features_write_one_annotation_each( + runner: CliRunner, builder: RepoBuilder +) -> None: + sha = builder.commit("shared work", files={"src/shared.py": "z = 3\n"}) + result = runner.invoke(app, ["annotate", "--feature", "auth", "--feature", "ui"]) + assert result.exit_code == 0, result.output + presences = sorted(a.presence for a in _annotations(builder, sha)) + assert presences == ["auth", "ui"] + + +def test_presence_expression_is_stored_raw(runner: CliRunner, builder: RepoBuilder) -> None: + sha = builder.commit("gated work", files={"src/gated.py": "g = 4\n"}) + result = runner.invoke(app, ["annotate", "--presence", "auth & premium"]) + assert result.exit_code == 0, result.output + (annotation,) = _annotations(builder, sha) + assert annotation.presence == "auth & premium" + + +def test_path_glob_restricts_hunks(runner: CliRunner, builder: RepoBuilder) -> None: + sha = builder.commit( + "mixed work", + files={"src/auth.py": "a = 1\n", "docs/notes.md": "note\n"}, + ) + result = runner.invoke(app, ["annotate", "--feature", "auth", "--path", "src/*"]) + assert result.exit_code == 0, result.output + paths = [a.anchor.path for a in _annotations(builder, sha)] + assert paths == ["src/auth.py"] + + +def test_range_annotates_every_commit(runner: CliRunner, builder: RepoBuilder) -> None: + start = str(builder.repo.head.target) + sha_one = builder.commit("one", files={"src/a.py": "a = 1\n"}) + sha_two = builder.commit("two", files={"src/b.py": "b = 2\n"}) + result = runner.invoke(app, ["annotate", "--feature", "auth", "--range", f"{start}..HEAD"]) + assert result.exit_code == 0, result.output + assert len(_annotations(builder, sha_one)) == 1 + assert len(_annotations(builder, sha_two)) == 1 + + +def test_rerun_does_not_duplicate(runner: CliRunner, builder: RepoBuilder) -> None: + sha = builder.commit("auth work", files={"src/auth.py": "def login():\n pass\n"}) + runner.invoke(app, ["annotate", "--feature", "auth"]) + result = runner.invoke(app, ["--json", "annotate", "--feature", "auth"]) + assert result.exit_code == 0, result.output + report = json.loads(result.output) + assert report["written"] == 0 + assert report["duplicates"] == 1 + assert len(_annotations(builder, sha)) == 1 + + +def test_unknown_feature_is_auto_created(runner: CliRunner, builder: RepoBuilder) -> None: + builder.commit("new area", files={"src/new.py": "n = 5\n"}) + result = runner.invoke(app, ["annotate", "--feature", "brand-new"]) + assert result.exit_code == 0, result.output + assert "created feature 'brand-new' (auto)" in result.output + feature = GitRefStore(builder.repo).read_feature("brand-new") + assert feature is not None + assert feature.auto_created + + +def test_feature_and_presence_conflict(runner: CliRunner, builder: RepoBuilder) -> None: + result = runner.invoke(app, ["annotate", "--feature", "a", "--presence", "a & b"]) + assert result.exit_code == 2 diff --git a/tests/test_annotation_lifecycle.py b/tests/test_annotation_lifecycle.py new file mode 100644 index 0000000..710c7dd --- /dev/null +++ b/tests/test_annotation_lifecycle.py @@ -0,0 +1,90 @@ +"""An annotation survives amend, rebase, and a foreign cherry-pick end to end.""" + +from collections.abc import Callable +from datetime import UTC, datetime + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.gitio import blob_lines, commit_diff +from git_feature.identity import ChangeIdMap, RegionResolver, anchor_from_hunk +from git_feature.store import Annotation, GitRefStore +from tests.fixtures import RepoBuilder + +BASE = "import os\n\ndef main():\n run()\n" +REGION = "def auth():\n check()\n return token()\n" +WITH_REGION = BASE + "\n" + REGION +MAIN_ADVANCED = "import sys\n" + BASE +REBASED = "import sys\n" + WITH_REGION + + +def test_annotation_survives_amend_rebase_and_cherry_pick( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = CliRunner() + builder = repo_builder("repo") + builder.commit("base", files={"a.py": BASE}) + monkeypatch.chdir(builder.path) + + # init (lazy: no backfill) and track the generated model.cfr + assert runner.invoke(app, ["init"]).exit_code == 0 + builder.commit("track model.cfr") + main = builder.default_branch + + # commit the region on a feature branch; the post-commit hook mints its change-id + builder.branch("feature") + builder.switch("feature") + r1 = builder.commit("add auth region", files={"a.py": WITH_REGION}) + assert runner.invoke(app, ["hook-run", "post-commit"]).exit_code == 0 + store = GitRefStore(builder.repo) + record = ChangeIdMap(store).get(r1) + assert record is not None + + # annotate the region by hand through the store API (annotate command not built yet) + (file_diff,) = [d for d in commit_diff(builder.repo, r1) if d.new_path == "a.py"] + (hunk,) = file_diff.hunks + anchor = anchor_from_hunk(record.change_id, "a.py", hunk) + annotation = Annotation(anchor=anchor, presence="auth", author="test", ts=datetime.now(tz=UTC)) + store.write_annotations(record.change_id, [annotation]) + store.commit("annotate auth region") + + # amend: git would fire post-rewrite with the old/new pair + r2 = builder.amend("add auth region (amended)") + assert runner.invoke(app, ["hook-run", "post-rewrite"], input=f"{r1} {r2}\n").exit_code == 0 + + # main advances; "rebase" the feature commit onto it (fixture-simulated) + post-rewrite + builder.switch(main) + builder.commit("advance main", files={"a.py": MAIN_ADVANCED}) + builder.branch("feature-rebased") + builder.switch("feature-rebased") + r3 = builder.commit("add auth region", files={"a.py": REBASED}) + assert runner.invoke(app, ["hook-run", "post-rewrite"], input=f"{r2} {r3}\n").exit_code == 0 + + # cross-clone cherry-pick: same patch lands on a release branch with no hooks involved + builder.switch(main) + builder.branch("release") + builder.switch("release") + c4 = builder.commit("pick auth region", files={"a.py": REBASED}) + assert runner.invoke(app, ["reconcile"]).exit_code == 0 + + # identity: every incarnation shares one change-id + cid_map = ChangeIdMap(GitRefStore(builder.repo)) + shas = set(cid_map.shas_for(record.change_id)) + assert {r1, r2, r3, c4} <= shas + + # the stored annotation still resolves to the right lines everywhere + stored = GitRefStore(builder.repo).read_annotations(record.change_id) + assert stored == [annotation] + resolver = RegionResolver(builder.repo, cid_map) + for target in ("feature-rebased", "release"): + resolution = resolver.resolve(anchor, target) + assert resolution.confidence == "exact", target + assert resolution.span is not None + start, length = resolution.span + assert start == anchor.new_span[0] + 1 # one line was inserted above during the rebase + lines = blob_lines(builder.repo, target, "a.py") + assert lines is not None + region_lines = lines[start - 1 : start - 1 + length] + assert region_lines[-3:] == ["def auth():", " check()", " return token()"] diff --git a/tests/test_blame.py b/tests/test_blame.py new file mode 100644 index 0000000..e91a557 --- /dev/null +++ b/tests/test_blame.py @@ -0,0 +1,70 @@ +"""Tests for `git feature blame`.""" + +import json + +import pytest +from typer.testing import CliRunner + +import git_feature.identity.region as region_module +from git_feature.cli import app +from tests.conftest import AnnotatedRepo + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_per_line_conditions(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["--json", "blame", "src/auth/login.py"]) + assert result.exit_code == 0, result.output + rows = json.loads(result.output) + assert [row["presence"] for row in rows] == ["auth", "auth"] + assert rows[0]["content"] == "def login():" + + +def test_unannotated_lines_show_dash(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["blame", "src/core.py"]) + assert result.exit_code == 0, result.output + for line in result.output.strip().splitlines(): + assert " - " in line + + +def test_line_filter(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["--json", "blame", "src/auth/login.py", "--line", "2"]) + assert result.exit_code == 0, result.output + (row,) = json.loads(result.output) + assert row["line"] == 2 + assert row["presence"] == "auth" + + +def test_missing_file_fails(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["blame", "nope.py"]) + assert result.exit_code == 1 + + +def test_single_blame_pass_per_file( + runner: CliRunner, + annotated_repo: AnnotatedRepo, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # two annotations on the same file must share one blame pass + builder = annotated_repo.builder + builder.write( + "src/auth/login.py", + "def login():\n return check()\n\ndef logout():\n return clear()\n", + ) + builder.commit("extend auth") + assert runner.invoke(app, ["annotate", "--feature", "auth-extra"]).exit_code == 0 + + calls = {"n": 0} + original = region_module.line_origins + + def counting(repo: object, rev: object, path: str) -> object: + calls["n"] += 1 + return original(repo, rev, path) # type: ignore[arg-type] + + monkeypatch.setattr(region_module, "line_origins", counting) + result = runner.invoke(app, ["blame", "src/auth/login.py"]) + assert result.exit_code == 0, result.output + assert calls["n"] == 1 diff --git a/tests/test_checkout_dryrun.py b/tests/test_checkout_dryrun.py new file mode 100644 index 0000000..9e5b705 --- /dev/null +++ b/tests/test_checkout_dryrun.py @@ -0,0 +1,158 @@ +"""Tests for `git feature checkout --dry-run` (configure + project stages).""" + +import json +from collections.abc import Callable +from dataclasses import dataclass + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.fixtures import RepoBuilder + +MODEL = """\ +abstract Product { + core + auth ? + ui ? + + [ ui => auth ] +} + +Minimal : Product { + [ no auth ] + [ no ui ] +} + +AuthOnly : Product { + [ auth ] + [ no ui ] +} + +Full : Product { + [ auth ] + [ ui ] +} +""" + +BASE = "def core():\n return 1\n" +WITH_AUTH = BASE + "\n\ndef auth():\n return check()\n" +WITH_BOTH = WITH_AUTH + "\n\ndef ui():\n return render()\n" + + +@dataclass +class DryRunRepo: + builder: RepoBuilder + base_sha: str + auth_sha: str + ui_sha: str + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def fixture( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> DryRunRepo: + builder = repo_builder("repo") + base_sha = builder.commit("base", files={"model.cfr": MODEL, "shared.py": BASE}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + auth_sha = builder.commit("auth block", files={"shared.py": WITH_AUTH}) + ui_sha = builder.commit("ui block", files={"shared.py": WITH_BOTH}) + assert runner.invoke(app, ["annotate", auth_sha, "--feature", "auth"]).exit_code == 0 + assert runner.invoke(app, ["annotate", ui_sha, "--feature", "ui"]).exit_code == 0 + return DryRunRepo(builder, base_sha, auth_sha, ui_sha) + + +def _removed_spans(output: str) -> dict[str, list[tuple[list[int], str]]]: + payload = json.loads(output) + removed: dict[str, list[tuple[list[int], str]]] = {} + for decision in payload["decisions"]: + if not decision["included"]: + removed.setdefault(decision["anchor"]["path"], []).append( + (decision["resolved_span"], decision["presence"]) + ) + return removed + + +def test_minimal_removes_both_regions(runner: CliRunner, fixture: DryRunRepo) -> None: + result = runner.invoke(app, ["--json", "checkout", "Minimal", "--dry-run"]) + assert result.exit_code == 0, result.output + removed = _removed_spans(result.output) + spans = sorted(removed["shared.py"]) + # each block is two blank separator lines + two code lines + assert spans == [([3, 4], "auth"), ([7, 4], "ui")] + + +def test_authonly_removes_only_ui(runner: CliRunner, fixture: DryRunRepo) -> None: + result = runner.invoke(app, ["--json", "checkout", "AuthOnly", "--dry-run"]) + assert result.exit_code == 0, result.output + removed = _removed_spans(result.output) + assert removed == {"shared.py": [([7, 4], "ui")]} + + +def test_full_removes_nothing(runner: CliRunner, fixture: DryRunRepo) -> None: + result = runner.invoke(app, ["checkout", "Full", "--dry-run"]) + assert result.exit_code == 0, result.output + assert "would remove: nothing" in result.output + assert "no conflicts" in result.output + + +def test_illegal_instance_fails_before_touching_regions( + runner: CliRunner, fixture: DryRunRepo +) -> None: + broken = MODEL + "\nBroken : Product {\n [ ui ]\n [ no auth ]\n}\n" + fixture.builder.commit("broken instance", files={"model.cfr": broken}) + result = runner.invoke(app, ["checkout", "Broken", "--dry-run"]) + assert result.exit_code == 1 + assert "illegal" in result.output + + +def test_base_uses_that_commits_model_and_history(runner: CliRunner, fixture: DryRunRepo) -> None: + # at the auth commit, the ui change doesn't exist yet: nothing to remove for AuthOnly + result = runner.invoke( + app, ["--json", "checkout", "AuthOnly", "--dry-run", "--base", fixture.auth_sha] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["base_commit"] == fixture.auth_sha + assert _removed_spans(result.output) == {} + assert [d["presence"] for d in payload["decisions"]] == ["auth"] + + +def test_planted_overlap_is_a_conflict(runner: CliRunner, fixture: DryRunRepo) -> None: + # a second annotation claims part of the ui block for 'core' (always on): + # under AuthOnly the same lines would be both kept and removed + result = runner.invoke( + app, ["annotate", fixture.ui_sha, "--feature", "core", "--path", "shared.py"] + ) + assert result.exit_code == 0, result.output + result = runner.invoke(app, ["checkout", "AuthOnly", "--dry-run"]) + assert result.exit_code == 1, result.output + assert "overlap" in result.output + assert "cannot be materialized" in result.output + + +def test_missing_model_at_base_fails(runner: CliRunner, fixture: DryRunRepo) -> None: + # rewrite model.cfr away in a new commit; --base HEAD then has no model + fixture.builder.commit("drop model", remove=["model.cfr"]) + result = runner.invoke(app, ["checkout", "Minimal", "--dry-run"]) + assert result.exit_code == 1 + assert "no model.cfr" in result.output + + +def test_conflicts_block_materialization_too(runner: CliRunner, fixture: DryRunRepo) -> None: + result = runner.invoke( + app, ["annotate", fixture.ui_sha, "--feature", "core", "--path", "shared.py"] + ) + assert result.exit_code == 0, result.output + result = runner.invoke(app, ["checkout", "AuthOnly", "--no-switch"]) + assert result.exit_code == 1 + assert "cannot be materialized" in result.output + assert "refs/heads/variant/AuthOnly" not in fixture.builder.repo.references diff --git a/tests/test_checkout_materialize.py b/tests/test_checkout_materialize.py new file mode 100644 index 0000000..d722b1d --- /dev/null +++ b/tests/test_checkout_materialize.py @@ -0,0 +1,229 @@ +"""Tests for full `git feature checkout`: materialize, verify, refresh, idempotence.""" + +from collections.abc import Callable +from dataclasses import dataclass + +import pygit2 +import pytest +from typer.testing import CliRunner + +import git_feature.domain.derivation.pipeline as pipeline_module +from git_feature.cli import app +from git_feature.gitio import create_commit, read_ref, rewrite_tree, write_ref +from git_feature.identity import RegionResolver, Resolution +from git_feature.store import GitRefStore +from tests.fixtures import RepoBuilder +from tests.test_checkout_dryrun import BASE, MODEL, WITH_AUTH, WITH_BOTH + + +@dataclass +class VariantRepo: + builder: RepoBuilder + base_sha: str + auth_sha: str + ui_sha: str + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def fixture( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> VariantRepo: + builder = repo_builder("repo") + base_sha = builder.commit("base", files={"model.cfr": MODEL, "shared.py": BASE}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + auth_sha = builder.commit("auth block", files={"shared.py": WITH_AUTH}) + ui_sha = builder.commit("ui block", files={"shared.py": WITH_BOTH}) + assert runner.invoke(app, ["annotate", auth_sha, "--feature", "auth"]).exit_code == 0 + assert runner.invoke(app, ["annotate", ui_sha, "--feature", "ui"]).exit_code == 0 + return VariantRepo(builder, base_sha, auth_sha, ui_sha) + + +def _variant_blob(builder: RepoBuilder, instance: str, path: str) -> bytes: + tip = builder.repo.references[f"refs/heads/variant/{instance}"].peel(pygit2.Commit) + return bytes(builder.repo[tip.tree[path].id].data) + + +def test_derived_tree_matches_expected_bytes(runner: CliRunner, fixture: VariantRepo) -> None: + result = runner.invoke(app, ["checkout", "Minimal", "--no-switch"]) + assert result.exit_code == 0, result.output + assert _variant_blob(fixture.builder, "Minimal", "shared.py") == BASE.encode() + + result = runner.invoke(app, ["checkout", "AuthOnly", "--no-switch"]) + assert result.exit_code == 0, result.output + assert _variant_blob(fixture.builder, "AuthOnly", "shared.py") == WITH_AUTH.encode() + + +def test_unaffected_blobs_share_oids_with_base(runner: CliRunner, fixture: VariantRepo) -> None: + assert runner.invoke(app, ["checkout", "Minimal", "--no-switch"]).exit_code == 0 + repo = fixture.builder.repo + variant_tip = repo.references["refs/heads/variant/Minimal"].peel(pygit2.Commit) + head = repo.head.peel(pygit2.Commit) + assert variant_tip.tree["model.cfr"].id == head.tree["model.cfr"].id + assert variant_tip.tree["shared.py"].id != head.tree["shared.py"].id + + +def test_manifest_is_persisted(runner: CliRunner, fixture: VariantRepo) -> None: + assert runner.invoke(app, ["checkout", "Minimal", "--no-switch"]).exit_code == 0 + manifest = GitRefStore(fixture.builder.repo).read_manifest("Minimal") + assert manifest is not None + assert manifest.base_commit == fixture.ui_sha + assert manifest.assignment["auth"] is False + + +def test_default_switches_to_the_variant_branch(runner: CliRunner, fixture: VariantRepo) -> None: + result = runner.invoke(app, ["checkout", "Minimal"]) + assert result.exit_code == 0, result.output + assert "switched to variant/Minimal" in result.output + assert fixture.builder.repo.head.shorthand == "variant/Minimal" + assert (fixture.builder.path / "shared.py").read_text() == BASE + + +def test_existing_variant_requires_refresh(runner: CliRunner, fixture: VariantRepo) -> None: + assert runner.invoke(app, ["checkout", "Minimal", "--no-switch"]).exit_code == 0 + fixture.builder.commit("advance", files={"other.txt": "x\n"}) + result = runner.invoke(app, ["checkout", "Minimal", "--no-switch"]) + assert result.exit_code == 1 + assert "--refresh" in result.output + + +def test_refresh_chains_onto_previous_variant_commit( + runner: CliRunner, fixture: VariantRepo +) -> None: + assert runner.invoke(app, ["checkout", "Minimal", "--no-switch"]).exit_code == 0 + first_tip = read_ref(fixture.builder.repo, "refs/heads/variant/Minimal") + fixture.builder.commit("advance", files={"other.txt": "x\n"}) + result = runner.invoke(app, ["checkout", "Minimal", "--no-switch", "--refresh"]) + assert result.exit_code == 0, result.output + repo = fixture.builder.repo + tip = repo.references["refs/heads/variant/Minimal"].peel(pygit2.Commit) + assert [str(p.id) for p in tip.parents] == [first_tip] + assert "other.txt" in tip.tree # rebuilt from the new base + + +def test_rederiving_at_same_base_is_a_noop(runner: CliRunner, fixture: VariantRepo) -> None: + assert runner.invoke(app, ["checkout", "Minimal", "--no-switch"]).exit_code == 0 + result = runner.invoke(app, ["checkout", "Minimal", "--no-switch"]) + assert result.exit_code == 0, result.output + assert "up to date" in result.output + repo = fixture.builder.repo + tip = repo.references["refs/heads/variant/Minimal"].peel(pygit2.Commit) + assert tip.parents == [] # still the single original variant commit + + +def test_misresolution_is_caught_by_verify_and_blocks_the_ref( + runner: CliRunner, + fixture: VariantRepo, + monkeypatch: pytest.MonkeyPatch, +) -> None: + original = RegionResolver.resolve + + def misresolved(self: RegionResolver, anchor: object, target: object) -> Resolution: + resolution = original(self, anchor, target) # type: ignore[arg-type] + if resolution.span is None: + return resolution + # simulate a bad fuzzy match: the region is "found" at the top of the file + return Resolution((1, resolution.span[1]), "fuzzy") + + monkeypatch.setattr(pipeline_module.RegionResolver, "resolve", misresolved) + result = runner.invoke(app, ["checkout", "Minimal", "--no-switch"]) + assert result.exit_code == 1, result.output + assert "verification failed" in result.output + assert read_ref(fixture.builder.repo, "refs/heads/variant/Minimal") is None + + +def test_verify_only_passes_then_catches_tampering(runner: CliRunner, fixture: VariantRepo) -> None: + assert runner.invoke(app, ["checkout", "Minimal", "--no-switch"]).exit_code == 0 + result = runner.invoke(app, ["checkout", "Minimal", "--verify-only"]) + assert result.exit_code == 0, result.output + assert "verified ok" in result.output + + # tamper: put the removed ui/auth content back on the variant branch + repo = fixture.builder.repo + tip = read_ref(repo, "refs/heads/variant/Minimal") + assert tip is not None + tree = rewrite_tree(repo, tip, {"shared.py": WITH_BOTH.encode()}) + sha = create_commit(repo, tree, "tamper", [tip]) + write_ref(repo, "refs/heads/variant/Minimal", sha) + + result = runner.invoke(app, ["checkout", "Minimal", "--verify-only"]) + assert result.exit_code == 1 + assert "still present" in result.output + + +def test_verify_only_without_a_variant_fails(runner: CliRunner, fixture: VariantRepo) -> None: + result = runner.invoke(app, ["checkout", "Minimal", "--verify-only"]) + assert result.exit_code == 1 + assert "never been derived" in result.output + + +def test_scattered_kept_region_does_not_false_overlap( + runner: CliRunner, fixture: VariantRepo +) -> None: + # a kept region whose lines are interleaved with a removed region must not + # be treated as one big span covering the removed lines + original = "def top():\n return 1\n\ndef bottom():\n return 2\n" + with_auth_inside = ( + "def top():\n return 1\n\ndef auth():\n return check()\n\ndef bottom():\n" + " return 2\n" + ) + core_sha = fixture.builder.commit("core pair", files={"core2.py": original}) + auth_sha = fixture.builder.commit("auth inserted", files={"core2.py": with_auth_inside}) + assert runner.invoke(app, ["annotate", core_sha, "--feature", "core"]).exit_code == 0 + assert runner.invoke(app, ["annotate", auth_sha, "--feature", "auth"]).exit_code == 0 + + result = runner.invoke(app, ["checkout", "Minimal", "--no-switch"]) + assert result.exit_code == 0, result.output + assert _variant_blob(fixture.builder, "Minimal", "core2.py") == original.encode() + + +def test_annotated_file_renamed_after_annotation_still_derives( + runner: CliRunner, fixture: VariantRepo +) -> None: + # the annotated file is renamed later; regions must resolve in the renamed + # file (not crash on the stale anchored path) and splice from it + fixture.builder.commit( + "rename shared to core", files={"core.py": WITH_BOTH}, remove=["shared.py"] + ) + result = runner.invoke(app, ["checkout", "AuthOnly", "--no-switch", "--dry-run"]) + assert result.exit_code == 0, result.output + assert "core.py" in result.output + assert "anchored at shared.py" in result.output + + result = runner.invoke(app, ["checkout", "AuthOnly", "--no-switch"]) + assert result.exit_code == 0, result.output + assert _variant_blob(fixture.builder, "AuthOnly", "core.py") == WITH_AUTH.encode() + + manifest = GitRefStore(fixture.builder.repo).read_manifest("AuthOnly") + assert manifest is not None + moved = [d for d in manifest.decisions if d.resolved_path] + assert moved and all(d.resolved_path == "core.py" for d in moved) + assert all(d.location() == "core.py" for d in moved) + + +def test_rename_target_preferred_over_decoy_with_identical_window( + runner: CliRunner, fixture: VariantRepo +) -> None: + # an alphabetically-earlier file containing an identical copy of the + # region's lines must not steal the resolution from the renamed file + decoy = "\n\ndef ui():\n return render()\n" # the ui region, verbatim + fixture.builder.commit( + "rename shared to zz and add lookalike", + files={"zz.py": WITH_BOTH, "aa.py": decoy}, + remove=["shared.py"], + ) + result = runner.invoke(app, ["checkout", "AuthOnly", "--no-switch"]) + assert result.exit_code == 0, result.output + manifest = GitRefStore(fixture.builder.repo).read_manifest("AuthOnly") + assert manifest is not None + moved = [d for d in manifest.decisions if d.resolved_path] + assert moved and all(d.resolved_path == "zz.py" for d in moved) + assert _variant_blob(fixture.builder, "AuthOnly", "zz.py") == WITH_AUTH.encode() + assert _variant_blob(fixture.builder, "AuthOnly", "aa.py") == decoy.encode() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..581e12a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,44 @@ +"""Smoke tests for the CLI scaffolding: every command exists and runs its stub.""" + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app + +EXPECTED_COMMANDS = [ + "init", + "annotate", + "list", + "info", + "status", + "blame", + "whatfeature", + "coverage", + "model", + "checkout", + "variant", + "store", + "view", + "putback", + "sync", + "reconcile", + "doctor", +] + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_help_lists_all_commands(runner: CliRunner) -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + for name in EXPECTED_COMMANDS: + assert name in result.output, f"command {name!r} missing from --help" + + +def test_hook_run_is_hidden(runner: CliRunner) -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "hook-run" not in result.output diff --git a/tests/test_coverage.py b/tests/test_coverage.py new file mode 100644 index 0000000..9277a60 --- /dev/null +++ b/tests/test_coverage.py @@ -0,0 +1,44 @@ +"""Tests for `git feature coverage`.""" + +import json + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.conftest import AnnotatedRepo + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_counts_over_all_history(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + # 4 commits on the default branch: base, model.cfr, auth (annotated), ui (annotated) + result = runner.invoke(app, ["coverage"]) + assert result.exit_code == 0, result.output + assert "2/4 commits annotated (50%)" in result.output + + +def test_range_limits_commits(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["coverage", f"{annotated_repo.base_sha}..HEAD"]) + assert result.exit_code == 0, result.output + assert "2/2 commits annotated (100%)" in result.output + + +def test_verbose_lists_each_commit(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["coverage", "--verbose"]) + assert result.exit_code == 0, result.output + assert f"+ {annotated_repo.auth_sha[:12]}" in result.output + assert f"- {annotated_repo.base_sha[:12]}" in result.output + + +def test_json_lists_unannotated_shas(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["--json", "coverage"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["total"] == 4 + assert payload["annotated"] == 2 + assert annotated_repo.base_sha in payload["unannotated"] + assert annotated_repo.auth_sha not in payload["unannotated"] diff --git a/tests/test_derivation_lifecycle.py b/tests/test_derivation_lifecycle.py new file mode 100644 index 0000000..f2dc75b --- /dev/null +++ b/tests/test_derivation_lifecycle.py @@ -0,0 +1,50 @@ +"""One journey end to end: init → annotate → model → checkout → verify → idempotent redo.""" + +from collections.abc import Callable + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.fixtures import RepoBuilder +from tests.test_checkout_dryrun import BASE, MODEL, WITH_AUTH, WITH_BOTH + + +def test_derivation_lifecycle( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = CliRunner() + builder = repo_builder("repo") + builder.commit("base", files={"model.cfr": MODEL, "shared.py": BASE}) + source_branch = builder.default_branch + monkeypatch.chdir(builder.path) + + assert runner.invoke(app, ["init"]).exit_code == 0 + auth_sha = builder.commit("auth block", files={"shared.py": WITH_AUTH}) + ui_sha = builder.commit("ui block", files={"shared.py": WITH_BOTH}) + assert runner.invoke(app, ["annotate", auth_sha, "--feature", "auth"]).exit_code == 0 + assert runner.invoke(app, ["annotate", ui_sha, "--feature", "ui"]).exit_code == 0 + assert runner.invoke(app, ["model", "validate"]).exit_code == 0 + + # derive and switch: the working tree becomes the reduced product + result = runner.invoke(app, ["checkout", "AuthOnly"]) + assert result.exit_code == 0, result.output + assert builder.repo.head.shorthand == "variant/AuthOnly" + assert (builder.path / "shared.py").read_text() == WITH_AUTH + + # the derivation is verified and re-verifiable + result = runner.invoke(app, ["checkout", "AuthOnly", "--verify-only"]) + assert result.exit_code == 0, result.output + + # re-deriving at the same base changes nothing + result = runner.invoke(app, ["checkout", "AuthOnly", "--no-switch"]) + assert result.exit_code == 0, result.output + assert "up to date" in result.output + + # back on the source branch everything is still intact + builder.switch(source_branch) + assert (builder.path / "shared.py").read_text() == WITH_BOTH + result = runner.invoke(app, ["variant", "list"]) + assert result.exit_code == 0, result.output + assert "AuthOnly" in result.output and "[current]" in result.output diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..ae96911 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,95 @@ +"""Tests for `git feature doctor`.""" + +import json +from collections.abc import Callable +from datetime import UTC, datetime + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.store import Annotation, GitRefStore, RegionAnchor +from tests.fixtures import RepoBuilder + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def builder( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> RepoBuilder: + builder = repo_builder("repo") + builder.commit("base", files={"a.txt": "a\n"}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init", "--backfill"]).exit_code == 0 + builder.commit("add model.cfr") + return builder + + +def test_healthy_repo_passes(runner: CliRunner, builder: RepoBuilder) -> None: + runner.invoke(app, ["hook-run", "post-commit"]) # map the model.cfr commit + result = runner.invoke(app, ["doctor"]) + assert result.exit_code == 0, result.output + assert "healthy" in result.output + + +def test_uninitialized_repo_fails( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> None: + builder = repo_builder("bare") + builder.commit("base", files={"a.txt": "a\n"}) + monkeypatch.chdir(builder.path) + result = runner.invoke(app, ["doctor"]) + assert result.exit_code == 1 + assert "refs/feature/store missing" in result.output + assert "git feature init" in result.output + + +def test_deleted_hook_is_detected(runner: CliRunner, builder: RepoBuilder) -> None: + (builder.path / ".git" / "hooks" / "post-rewrite").unlink() + result = runner.invoke(app, ["doctor"]) + assert result.exit_code == 1 + assert "post-rewrite" in result.output + assert "reinstall hooks" in result.output + + +def test_unmapped_commits_are_informational(runner: CliRunner, builder: RepoBuilder) -> None: + builder.commit("no hook ran", files={"a.txt": "a\nb\n"}) + result = runner.invoke(app, ["doctor"]) + assert result.exit_code == 0, result.output # lazy minting: info, not a problem + assert "without change-ids" in result.output + assert "reconcile" in result.output + + +def test_dangling_annotation_is_detected(runner: CliRunner, builder: RepoBuilder) -> None: + store = GitRefStore(builder.repo) + anchor = RegionAnchor( + change_id="01JUNKNOWNCHANGE0000000000", + path="a.txt", + old_span=(0, 0), + new_span=(1, 1), + fingerprint="f", + ) + annotation = Annotation(anchor=anchor, presence="ghost", author="test", ts=datetime.now(tz=UTC)) + store.write_annotations(anchor.change_id, [annotation]) + store.commit("plant dangling annotation") + + result = runner.invoke(app, ["doctor"]) + assert result.exit_code == 1 + assert "unknown change-ids" in result.output + + +def test_json_output(runner: CliRunner, builder: RepoBuilder) -> None: + runner.invoke(app, ["hook-run", "post-commit"]) + result = runner.invoke(app, ["--json", "doctor"]) + assert result.exit_code == 0, result.output + report = json.loads(result.output) + assert report["healthy"] is True + assert any(c["name"] == "store" for c in report["checks"]) diff --git a/tests/test_gitio.py b/tests/test_gitio.py new file mode 100644 index 0000000..cee9134 --- /dev/null +++ b/tests/test_gitio.py @@ -0,0 +1,161 @@ +"""Integration tests for the gitio layer against real throwaway repositories.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from git_feature import gitio +from tests.fixtures import RepoBuilder + +Factory = Callable[[str], RepoBuilder] + + +@pytest.fixture +def three_commit_repo(repo_builder: Factory) -> tuple[RepoBuilder, list[str]]: + builder = repo_builder("three") + c1 = builder.commit( + "add a and sub/b", + files={"a.txt": "line1\nline2\n", "sub/b.txt": "b1\n"}, + ) + c2 = builder.commit( + "modify a", + files={"a.txt": "line1\nline2-modified\nline3\n"}, + ) + c3 = builder.commit( + "add c, remove sub/b", + files={"c.txt": "c1\nc2\n"}, + remove=["sub/b.txt"], + ) + return builder, [c1, c2, c3] + + +class TestOpenRepository: + def test_discovers_from_subdirectory(self, repo_builder: Factory) -> None: + builder = repo_builder("discover") + builder.commit("root", files={"sub/deep/x.txt": "x\n"}) + repo = gitio.open_repository(builder.path / "sub" / "deep") + assert Path(repo.path).resolve() == (builder.path / ".git").resolve() + + def test_raises_outside_a_repository(self, tmp_path: Path) -> None: + outside = tmp_path / "plain" + outside.mkdir() + with pytest.raises(gitio.RepositoryNotFound): + gitio.open_repository(outside) + + +class TestCommitDiff: + def test_root_commit_is_all_additions( + self, three_commit_repo: tuple[RepoBuilder, list[str]] + ) -> None: + builder, (c1, _, _) = three_commit_repo + files = {f.new_path: f for f in gitio.commit_diff(builder.repo, c1)} + assert set(files) == {"a.txt", "sub/b.txt"} + a = files["a.txt"] + assert a.status == "A" + assert a.old_path is None + assert a.hunks == ( + gitio.Hunk( + old_span=(0, 0), + new_span=(1, 2), + header="@@ -0,0 +1,2 @@", + added_lines=("line1", "line2"), + removed_lines=(), + ), + ) + + def test_modification_has_tight_spans( + self, three_commit_repo: tuple[RepoBuilder, list[str]] + ) -> None: + builder, (_, c2, _) = three_commit_repo + (file,) = gitio.commit_diff(builder.repo, c2) + assert (file.old_path, file.new_path, file.status) == ("a.txt", "a.txt", "M") + (hunk,) = file.hunks + assert hunk.old_span == (2, 1) + assert hunk.new_span == (2, 2) + assert hunk.removed_lines == ("line2",) + assert hunk.added_lines == ("line2-modified", "line3") + + def test_deletion_and_addition(self, three_commit_repo: tuple[RepoBuilder, list[str]]) -> None: + builder, (_, _, c3) = three_commit_repo + files = {(f.old_path, f.new_path): f for f in gitio.commit_diff(builder.repo, c3)} + added = files[(None, "c.txt")] + assert added.status == "A" + assert added.hunks[0].added_lines == ("c1", "c2") + deleted = files[("sub/b.txt", None)] + assert deleted.status == "D" + assert deleted.hunks[0].removed_lines == ("b1",) + assert deleted.hunks[0].new_span == (0, 0) + + +class TestHistory: + def test_iter_commits_newest_first( + self, three_commit_repo: tuple[RepoBuilder, list[str]] + ) -> None: + builder, shas = three_commit_repo + walked = [str(c.id) for c in gitio.iter_commits(builder.repo)] + assert walked == list(reversed(shas)) + + def test_iter_range(self, three_commit_repo: tuple[RepoBuilder, list[str]]) -> None: + builder, (c1, c2, c3) = three_commit_repo + walked = [str(c.id) for c in gitio.iter_range(builder.repo, f"{c1}..{c3}")] + assert walked == [c3, c2] + + def test_iter_range_rejects_non_range( + self, three_commit_repo: tuple[RepoBuilder, list[str]] + ) -> None: + builder, _ = three_commit_repo + with pytest.raises(ValueError, match="expected 'base..tip'"): + list(gitio.iter_range(builder.repo, "HEAD")) + + def test_iter_all_commits_covers_unmerged_branches(self, repo_builder: Factory) -> None: + builder = repo_builder("branches") + c1 = builder.commit("root", files={"a.txt": "a\n"}) + builder.branch("side") + c2 = builder.commit("on main", files={"b.txt": "b\n"}) + builder.switch("side") + c3 = builder.commit("on side", files={"s.txt": "s\n"}) + walked = {str(c.id) for c in gitio.iter_all_commits(builder.repo)} + assert walked == {c1, c2, c3} + + def test_merge_commit_has_two_parents(self, repo_builder: Factory) -> None: + builder = repo_builder("merging") + builder.commit("root", files={"a.txt": "a\n"}) + main = builder.default_branch + builder.branch("side") + builder.commit("on main", files={"b.txt": "b\n"}) + builder.switch("side") + builder.commit("on side", files={"s.txt": "s\n"}) + builder.switch(main) + merge_sha = builder.merge("side", "merge side") + merge_commit = gitio.resolve_commit(builder.repo, merge_sha) + assert len(merge_commit.parents) == 2 + + +class TestRefIo: + def test_read_missing_ref_returns_none(self, repo_builder: Factory) -> None: + builder = repo_builder("refs") + builder.commit("root", files={"a.txt": "a\n"}) + assert gitio.read_ref(builder.repo, "refs/feature/store") is None + + def test_write_and_read_roundtrip(self, repo_builder: Factory) -> None: + builder = repo_builder("refs") + c1 = builder.commit("root", files={"a.txt": "a\n"}) + gitio.write_ref(builder.repo, "refs/feature/store", c1) + assert gitio.read_ref(builder.repo, "refs/feature/store") == c1 + + def test_compare_and_swap_success_chain(self, repo_builder: Factory) -> None: + builder = repo_builder("refs") + c1 = builder.commit("root", files={"a.txt": "a\n"}) + c2 = builder.commit("second", files={"a.txt": "a2\n"}) + gitio.compare_and_swap_ref(builder.repo, "refs/feature/store", c1, expected_old=None) + gitio.compare_and_swap_ref(builder.repo, "refs/feature/store", c2, expected_old=c1) + assert gitio.read_ref(builder.repo, "refs/feature/store") == c2 + + def test_compare_and_swap_conflict(self, repo_builder: Factory) -> None: + builder = repo_builder("refs") + c1 = builder.commit("root", files={"a.txt": "a\n"}) + c2 = builder.commit("second", files={"a.txt": "a2\n"}) + gitio.write_ref(builder.repo, "refs/feature/store", c2) + with pytest.raises(gitio.RefUpdateConflict): + gitio.compare_and_swap_ref(builder.repo, "refs/feature/store", c1, expected_old=c1) diff --git a/tests/test_hook_run.py b/tests/test_hook_run.py new file mode 100644 index 0000000..abb0f75 --- /dev/null +++ b/tests/test_hook_run.py @@ -0,0 +1,136 @@ +"""Tests for the hook runtime: post-commit minting and post-rewrite migration.""" + +from collections.abc import Callable + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.identity import ChangeIdMap +from git_feature.store import STORE_REF, GitRefStore, StoreMeta +from tests.fixtures import RepoBuilder + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def builder( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> RepoBuilder: + builder = repo_builder("repo") + builder.commit("base", files={"a.txt": "a\n"}) + monkeypatch.chdir(builder.path) + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0, result.output + return builder + + +def _cid_map(builder: RepoBuilder) -> ChangeIdMap: + return ChangeIdMap(GitRefStore(builder.repo)) + + +class TestPostCommit: + def test_mints_change_id_for_head(self, runner: CliRunner, builder: RepoBuilder) -> None: + sha = builder.commit("work", files={"a.txt": "a\nb\n"}) + result = runner.invoke(app, ["hook-run", "post-commit"]) + assert result.exit_code == 0, result.output + record = _cid_map(builder).get(sha) + assert record is not None + assert record.patch_id is not None + + def test_rerun_is_a_noop(self, runner: CliRunner, builder: RepoBuilder) -> None: + builder.commit("work", files={"a.txt": "a\nb\n"}) + runner.invoke(app, ["hook-run", "post-commit"]) + history_before = _store_history_len(builder) + runner.invoke(app, ["hook-run", "post-commit"]) + assert _store_history_len(builder) == history_before + + def test_without_initialized_store_does_nothing( + self, + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, + ) -> None: + builder = repo_builder("bare") + builder.commit("base", files={"a.txt": "a\n"}) + monkeypatch.chdir(builder.path) + result = runner.invoke(app, ["hook-run", "post-commit"]) + assert result.exit_code == 0 + assert STORE_REF not in builder.repo.references + + +class TestPostRewrite: + def test_amend_migrates_change_id(self, runner: CliRunner, builder: RepoBuilder) -> None: + old_sha = builder.commit("work", files={"a.txt": "a\nb\n"}) + runner.invoke(app, ["hook-run", "post-commit"]) + old_record = _cid_map(builder).get(old_sha) + assert old_record is not None + + new_sha = builder.amend("work, amended", files={"a.txt": "a\nb\nc\n"}) + # real git fires post-commit for the amended commit *before* post-rewrite + runner.invoke(app, ["hook-run", "post-commit"]) + result = runner.invoke(app, ["hook-run", "post-rewrite"], input=f"{old_sha} {new_sha}\n") + assert result.exit_code == 0, result.output + new_record = _cid_map(builder).get(new_sha) + assert new_record is not None + assert new_record.change_id == old_record.change_id + + def test_rebase_migrates_all_pairs(self, runner: CliRunner, builder: RepoBuilder) -> None: + builder.branch("feature") + builder.switch("feature") + old_one = builder.commit("one", files={"f.txt": "one\n"}) + runner.invoke(app, ["hook-run", "post-commit"]) + old_two = builder.commit("two", files={"f.txt": "one\ntwo\n"}) + runner.invoke(app, ["hook-run", "post-commit"]) + cid_map = _cid_map(builder) + cid_one = cid_map.get(old_one) + cid_two = cid_map.get(old_two) + assert cid_one is not None and cid_two is not None + + builder.switch(builder.default_branch) + builder.commit("moved on", files={"a.txt": "a\nmoved\n"}) + new_one = builder.commit("one", files={"f.txt": "one\n"}) + new_two = builder.commit("two", files={"f.txt": "one\ntwo\n"}) + pairs = f"{old_one} {new_one}\n{old_two} {new_two}\n" + result = runner.invoke(app, ["hook-run", "post-rewrite"], input=pairs) + assert result.exit_code == 0, result.output + + cid_map = _cid_map(builder) + migrated_one = cid_map.get(new_one) + migrated_two = cid_map.get(new_two) + assert migrated_one is not None and migrated_one.change_id == cid_one.change_id + assert migrated_two is not None and migrated_two.change_id == cid_two.change_id + + def test_pair_for_unmapped_commit_is_skipped( + self, runner: CliRunner, builder: RepoBuilder + ) -> None: + old_sha = builder.commit("never minted", files={"a.txt": "a\nx\n"}) + new_sha = builder.amend("never minted, amended", files={"a.txt": "a\ny\n"}) + result = runner.invoke(app, ["hook-run", "post-rewrite"], input=f"{old_sha} {new_sha}\n") + assert result.exit_code == 0, result.output + assert _cid_map(builder).get(new_sha) is None + + +def test_hook_errors_never_block( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> None: + builder = repo_builder("empty") # unborn HEAD: post-commit has nothing to resolve + monkeypatch.chdir(builder.path) + store = GitRefStore(builder.repo) + store.write_meta(StoreMeta()) + store.commit("init store") + result = runner.invoke(app, ["hook-run", "post-commit"]) + assert result.exit_code == 0 + assert "skipped" in result.output + + +def _store_history_len(builder: RepoBuilder) -> int: + repo = builder.repo + return sum(1 for _ in repo.walk(repo.references[STORE_REF].target)) diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..45e2376 --- /dev/null +++ b/tests/test_identity.py @@ -0,0 +1,115 @@ +"""Tests for change-id minting, the map API, patch-ids, and backfill.""" + +from collections.abc import Callable + +from git_feature.gitio import patch_id +from git_feature.identity import ChangeIdMap, backfill, mint_change_id +from git_feature.store import GitRefStore, MemoryStore +from tests.fixtures import RepoBuilder + + +def test_mint_change_id_is_unique_ulid() -> None: + ids = {mint_change_id() for _ in range(100)} + assert len(ids) == 100 + assert all(len(i) == 26 for i in ids) + + +class TestChangeIdMap: + def test_insert_and_get(self) -> None: + cid_map = ChangeIdMap(MemoryStore()) + record = cid_map.insert("aa" * 20, patch_id="p1") + assert cid_map.get("aa" * 20) == record + assert cid_map.get("bb" * 20) is None + + def test_get_or_mint_is_lazy_and_idempotent(self) -> None: + cid_map = ChangeIdMap(MemoryStore()) + first, minted = cid_map.get_or_mint("aa" * 20) + assert minted + again, minted_again = cid_map.get_or_mint("aa" * 20) + assert not minted_again + assert again == first + + def test_migrate_carries_change_id_to_new_sha(self) -> None: + cid_map = ChangeIdMap(MemoryStore()) + old = cid_map.insert("aa" * 20) + assert cid_map.migrate("aa" * 20, "bb" * 20) + new = cid_map.get("bb" * 20) + assert new is not None + assert new.change_id == old.change_id + assert set(cid_map.shas_for(old.change_id)) == {"aa" * 20, "bb" * 20} + + def test_migrate_overwrites_a_stray_mapping_of_the_new_sha(self) -> None: + # On amend, post-commit mints for the new sha before post-rewrite runs; + # the old sha's change-id must win. + cid_map = ChangeIdMap(MemoryStore()) + old = cid_map.insert("aa" * 20) + cid_map.insert("bb" * 20) # stray id minted by post-commit + assert cid_map.migrate("aa" * 20, "bb" * 20) + new = cid_map.get("bb" * 20) + assert new is not None + assert new.change_id == old.change_id + + def test_migrate_of_unmapped_sha_is_a_noop(self) -> None: + cid_map = ChangeIdMap(MemoryStore()) + assert not cid_map.migrate("aa" * 20, "bb" * 20) + assert cid_map.get("bb" * 20) is None + + +class TestPatchId: + def test_same_patch_on_different_bases_matches( + self, repo_builder: Callable[[str], RepoBuilder] + ) -> None: + builder = repo_builder("repo") + builder.commit("base", files={"a.txt": "one\ntwo\nthree\n"}) + builder.branch("other") + sha_a = builder.commit("change", files={"a.txt": "one\nTWO\nthree\n"}) + builder.switch("other") + builder.commit("drift", files={"b.txt": "unrelated\n"}) + sha_b = builder.commit("change again", files={"a.txt": "one\nTWO\nthree\n"}) + assert sha_a != sha_b + assert patch_id(builder.repo, sha_a) == patch_id(builder.repo, sha_b) + + def test_different_patches_differ(self, repo_builder: Callable[[str], RepoBuilder]) -> None: + builder = repo_builder("repo") + builder.commit("base", files={"a.txt": "one\n"}) + sha_a = builder.commit("x", files={"a.txt": "one\nx\n"}) + sha_b = builder.commit("y", files={"a.txt": "one\nx\ny\n"}) + assert patch_id(builder.repo, sha_a) != patch_id(builder.repo, sha_b) + + def test_whitespace_changes_do_not_affect_patch_id( + self, repo_builder: Callable[[str], RepoBuilder] + ) -> None: + builder = repo_builder("repo") + builder.commit("base", files={"a.txt": "one\n"}) + builder.branch("other") + sha_a = builder.commit("add", files={"a.txt": "one\nnew line\n"}) + builder.switch("other") + sha_b = builder.commit("add spaced", files={"a.txt": "one\nnew line\n"}) + assert patch_id(builder.repo, sha_a) == patch_id(builder.repo, sha_b) + + +class TestBackfill: + def test_maps_all_commits_across_branches( + self, repo_builder: Callable[[str], RepoBuilder] + ) -> None: + builder = repo_builder("repo") + shas = [builder.commit("one", files={"a.txt": "a\n"})] + builder.branch("side") + shas.append(builder.commit("two", files={"a.txt": "a\nb\n"})) + builder.switch("side") + shas.append(builder.commit("three", files={"c.txt": "c\n"})) + + store = GitRefStore(builder.repo) + cid_map = ChangeIdMap(store) + assert backfill(builder.repo, cid_map) == 3 + for sha in shas: + record = cid_map.get(sha) + assert record is not None + assert record.patch_id is not None + + def test_rerun_is_a_noop(self, repo_builder: Callable[[str], RepoBuilder]) -> None: + builder = repo_builder("repo") + builder.commit("one", files={"a.txt": "a\n"}) + cid_map = ChangeIdMap(GitRefStore(builder.repo)) + assert backfill(builder.repo, cid_map) == 1 + assert backfill(builder.repo, cid_map) == 0 diff --git a/tests/test_info.py b/tests/test_info.py new file mode 100644 index 0000000..f67fa7c --- /dev/null +++ b/tests/test_info.py @@ -0,0 +1,70 @@ +"""Tests for `git feature info`.""" + +import json + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.conftest import AnnotatedRepo + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_default_summary_shows_all_sections( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + result = runner.invoke(app, ["info", "auth"]) + assert result.exit_code == 0, result.output + for section in ("files:", "commits:", "authors:", "branches:"): + assert section in result.output + assert "src/auth/login.py" in result.output + assert "add login" in result.output + + +def test_files_flag_limits_output(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["info", "auth", "--files"]) + assert result.exit_code == 0, result.output + assert "src/auth/login.py" in result.output + assert "commits:" not in result.output + + +def test_branches_show_cross_branch_presence( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + # auth commit is on both branches; ui commit only on the default branch + result = runner.invoke(app, ["--json", "info", "ui"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + branches = {row["name"]: row for row in payload["branches"]} + default = annotated_repo.builder.default_branch + assert branches[default]["present"] == 1 + assert branches["release"]["present"] == 0 + + result = runner.invoke(app, ["--json", "info", "auth"]) + payload = json.loads(result.output) + branches = {row["name"]: row for row in payload["branches"]} + assert branches["release"]["present"] == 1 + + +def test_missing_marker_in_text_output(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["info", "ui", "--branches"]) + assert result.exit_code == 0, result.output + assert "[missing 1]" in result.output + + +def test_json_commits_carry_change_ids(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["--json", "info", "auth", "--commits"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + (commit,) = payload["commits"] + assert commit["sha"] == annotated_repo.auth_sha + assert commit["change_id"] == annotated_repo.auth_cid + + +def test_unknown_feature_fails(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["info", "nope"]) + assert result.exit_code == 1 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..93697d3 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,106 @@ +"""Tests for `git feature init`.""" + +import os +import stat +from collections.abc import Callable + +import pygit2 +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.identity import ChangeIdMap +from git_feature.store import STORE_REF, GitRefStore +from tests.fixtures import RepoBuilder + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def builder( + repo_builder: Callable[[str], RepoBuilder], monkeypatch: pytest.MonkeyPatch +) -> RepoBuilder: + builder = repo_builder("repo") + builder.commit("one", files={"a.txt": "a\n"}) + builder.commit("two", files={"a.txt": "a\nb\n"}) + monkeypatch.chdir(builder.path) + return builder + + +def test_init_creates_store_model_and_hooks(runner: CliRunner, builder: RepoBuilder) -> None: + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0, result.output + assert STORE_REF in builder.repo.references + assert GitRefStore(builder.repo).read_meta() is not None + assert (builder.path / "model.cfr").exists() + hooks = builder.path / ".git" / "hooks" + for name in ("post-commit", "post-rewrite"): + assert (hooks / name).is_file() + assert os.access(hooks / name, os.X_OK) + assert (hooks / f"{name}.d" / "50-git-feature").is_file() + + +def test_init_is_lazy_by_default(runner: CliRunner, builder: RepoBuilder) -> None: + runner.invoke(app, ["init"]) + cid_map = ChangeIdMap(GitRefStore(builder.repo)) + assert cid_map.get(str(builder.repo.head.target)) is None + + +def test_init_backfill_maps_all_history(runner: CliRunner, builder: RepoBuilder) -> None: + result = runner.invoke(app, ["init", "--backfill"]) + assert result.exit_code == 0, result.output + cid_map = ChangeIdMap(GitRefStore(builder.repo)) + for commit in builder.repo.walk(builder.repo.head.target): + assert cid_map.get(str(commit.id)) is not None + + +def test_init_preserves_preexisting_hook(runner: CliRunner, builder: RepoBuilder) -> None: + hooks = builder.path / ".git" / "hooks" + hooks.mkdir(exist_ok=True) + planted = hooks / "post-commit" + planted.write_text("#!/bin/sh\necho planted\n") + planted.chmod(planted.stat().st_mode | stat.S_IXUSR) + + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0, result.output + preserved = hooks / "post-commit.d" / "00-preexisting" + assert preserved.is_file() + assert "echo planted" in preserved.read_text() + assert "chained hook dispatcher" in (hooks / "post-commit").read_text() + + +def test_second_init_is_a_clean_noop(runner: CliRunner, builder: RepoBuilder) -> None: + runner.invoke(app, ["init"]) + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0, result.output + assert "already initialized" in result.output + repo = pygit2.Repository(str(builder.path)) + store_history = list(repo.walk(repo.references[STORE_REF].target)) + assert len(store_history) == 1 + + +def test_second_init_repairs_missing_pieces(runner: CliRunner, builder: RepoBuilder) -> None: + runner.invoke(app, ["init"]) + (builder.path / ".git" / "hooks" / "post-commit").unlink() + (builder.path / "model.cfr").unlink() + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0, result.output + assert (builder.path / ".git" / "hooks" / "post-commit").is_file() + assert (builder.path / "model.cfr").exists() + + +def test_init_never_overwrites_model_cfr(runner: CliRunner, builder: RepoBuilder) -> None: + (builder.path / "model.cfr").write_text("// mine\n") + runner.invoke(app, ["init"]) + assert (builder.path / "model.cfr").read_text() == "// mine\n" + + +def test_init_outside_a_repo_fails( + runner: CliRunner, tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path_factory.mktemp("empty")) + result = runner.invoke(app, ["init"]) + assert result.exit_code == 1 diff --git a/tests/test_list.py b/tests/test_list.py new file mode 100644 index 0000000..0e2b3f2 --- /dev/null +++ b/tests/test_list.py @@ -0,0 +1,46 @@ +"""Tests for `git feature list`.""" + +import json + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.conftest import AnnotatedRepo + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_catalog_with_counts(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0, result.output + assert "auth" in result.output + assert "ui" in result.output + assert "(auto)" in result.output # both were auto-created by annotate + + +def test_json_output(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["--json", "list"]) + assert result.exit_code == 0, result.output + rows = {row["name"]: row for row in json.loads(result.output)} + assert rows["auth"]["annotations"] == 1 + assert rows["auth"]["changes"] == 1 + assert rows["auth"]["auto_created"] is True + assert rows["ui"]["annotations"] == 1 + + +def test_empty_catalog( + runner: CliRunner, + repo_builder, # type: ignore[no-untyped-def] + monkeypatch: pytest.MonkeyPatch, +) -> None: + builder = repo_builder("empty") + builder.commit("base", files={"a.txt": "a\n"}) + monkeypatch.chdir(builder.path) + runner.invoke(app, ["init"]) + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0, result.output + assert "no features defined yet" in result.output diff --git a/tests/test_model_validate.py b/tests/test_model_validate.py new file mode 100644 index 0000000..31628ae --- /dev/null +++ b/tests/test_model_validate.py @@ -0,0 +1,109 @@ +"""Tests for `git feature model validate`.""" + +import json + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.conftest import AnnotatedRepo + +MODEL = """\ +abstract Product { + core + auth ? + ui ? + + [ ui => auth ] +} + +Minimal : Product { + [ no auth ] + [ no ui ] +} + +Full : Product { + [ auth ] + [ ui ] +} +""" + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def with_model(annotated_repo: AnnotatedRepo) -> AnnotatedRepo: + annotated_repo.builder.commit("real feature model", files={"model.cfr": MODEL}) + return annotated_repo + + +def test_valid_model_and_annotations_pass(runner: CliRunner, with_model: AnnotatedRepo) -> None: + result = runner.invoke(app, ["model", "validate"]) + assert result.exit_code == 0, result.output + assert "model ok" in result.output + + +def test_syntax_error_points_at_location(runner: CliRunner, with_model: AnnotatedRepo) -> None: + with_model.builder.write("model.cfr", "abstract Product {\n [ a.b ]\n}\n") + result = runner.invoke(app, ["model", "validate"]) + assert result.exit_code == 1 + assert "line 2" in result.output + + +def test_annotation_referencing_unknown_feature_is_flagged( + runner: CliRunner, with_model: AnnotatedRepo +) -> None: + # the stored annotations say auth/ui; shrink the model so 'ui' disappears + with_model.builder.write("model.cfr", "abstract Product {\n core\n auth ?\n}\n") + result = runner.invoke(app, ["model", "validate"]) + assert result.exit_code == 1 + assert "ui" in result.output + assert "unknown feature" in result.output + + +def test_constraint_referencing_unknown_feature_is_flagged( + runner: CliRunner, with_model: AnnotatedRepo +) -> None: + with_model.builder.write( + "model.cfr", + "abstract Product {\n auth ?\n ui ?\n [ ghost => auth ]\n}\n", + ) + result = runner.invoke(app, ["model", "validate"]) + assert result.exit_code == 1 + assert "ghost" in result.output + + +def test_legal_instance_reports_included_and_excluded( + runner: CliRunner, with_model: AnnotatedRepo +) -> None: + result = runner.invoke(app, ["--json", "model", "validate", "Full"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["valid"] is True + assert payload["included"] == ["auth", "core", "ui"] + assert payload["excluded"] == [] + assert payload["assignment"]["ui"] is True + + result = runner.invoke(app, ["model", "validate", "Minimal"]) + assert result.exit_code == 0, result.output + assert "legal configuration" in result.output + assert "excluded: auth, ui" in result.output + + +def test_illegal_instance_names_the_violated_constraint( + runner: CliRunner, with_model: AnnotatedRepo +) -> None: + broken = MODEL + "\nBroken : Product {\n [ ui ]\n [ no auth ]\n}\n" + with_model.builder.write("model.cfr", broken) + result = runner.invoke(app, ["model", "validate", "Broken"]) + assert result.exit_code == 1 + assert "illegal" in result.output + + +def test_unknown_instance_fails(runner: CliRunner, with_model: AnnotatedRepo) -> None: + result = runner.invoke(app, ["model", "validate", "NoSuchInstance"]) + assert result.exit_code == 1 + assert "not found" in result.output diff --git a/tests/test_query_ground_truth.py b/tests/test_query_ground_truth.py new file mode 100644 index 0000000..5783af9 --- /dev/null +++ b/tests/test_query_ground_truth.py @@ -0,0 +1,88 @@ +"""Retro-annotate a repo that predates the tool; every query must agree with ground truth.""" + +import json +from collections.abc import Callable + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.identity import ChangeIdMap +from git_feature.store import GitRefStore +from tests.fixtures import RepoBuilder + + +def test_retroactive_annotation_and_queries( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = CliRunner() + builder = repo_builder("repo") + + # history exists before the tool is ever installed + core_sha = builder.commit("core", files={"src/core.py": "def core():\n pass\n"}) + auth_sha = builder.commit("auth", files={"src/auth.py": "def login():\n return check()\n"}) + ui_sha = builder.commit( + "ui + docs", + files={"src/ui.py": "def render():\n pass\n", "docs/ui.md": "# ui\n"}, + ) + monkeypatch.chdir(builder.path) + + assert runner.invoke(app, ["init"]).exit_code == 0 + model_sha = builder.commit("add model.cfr") + + # retro-annotate: whole commit for auth; only src/* of the mixed commit for ui + assert runner.invoke(app, ["annotate", auth_sha, "--feature", "auth"]).exit_code == 0 + assert ( + runner.invoke(app, ["annotate", ui_sha, "--feature", "ui", "--path", "src/*"]).exit_code + == 0 + ) + + # lazy minting: only the annotated commits got change-ids + cid_map = ChangeIdMap(GitRefStore(builder.repo)) + assert cid_map.get(core_sha) is None + assert cid_map.get(model_sha) is None + assert cid_map.get(auth_sha) is not None + assert cid_map.get(ui_sha) is not None + + # list + result = runner.invoke(app, ["--json", "list"]) + rows = {row["name"]: row for row in json.loads(result.output)} + assert rows["auth"] == { + "name": "auth", + "annotations": 1, + "changes": 1, + "auto_created": True, + "description": "", + } + assert rows["ui"]["annotations"] == 1 + + # info + result = runner.invoke(app, ["--json", "info", "ui"]) + payload = json.loads(result.output) + assert payload["files"] == ["src/ui.py"] # docs/ui.md excluded by --path + assert [c["sha"] for c in payload["commits"]] == [ui_sha] + + # whatfeature + result = runner.invoke(app, ["--json", "whatfeature", auth_sha]) + assert [a["presence"] for a in json.loads(result.output)["annotations"]] == ["auth"] + result = runner.invoke(app, ["whatfeature", core_sha]) + assert "no features recorded" in result.output + + # coverage: core + model unannotated, auth + ui annotated + result = runner.invoke(app, ["--json", "coverage"]) + payload = json.loads(result.output) + assert payload["total"] == 4 + assert payload["annotated"] == 2 + assert set(payload["unannotated"]) == {core_sha, model_sha} + + # blame: every line of src/auth.py belongs to auth + result = runner.invoke(app, ["--json", "blame", "src/auth.py"]) + assert [row["presence"] for row in json.loads(result.output)] == ["auth", "auth"] + + # status: an edit inside the ui region is attributed to ui + builder.write("src/ui.py", "def render():\n return 'fast'\n") + result = runner.invoke(app, ["--json", "status"]) + payload = json.loads(result.output) + assert any("src/ui.py" in loc for loc in payload["features"]["ui"]) + assert payload["unassigned"] == [] diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py new file mode 100644 index 0000000..a2836d4 --- /dev/null +++ b/tests/test_reconcile.py @@ -0,0 +1,119 @@ +"""Tests for `git feature reconcile` and its matching tiers.""" + +import json +from collections.abc import Callable + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.gitio import patch_id +from git_feature.identity import ChangeIdMap +from git_feature.store import GitRefStore +from tests.fixtures import RepoBuilder + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def builder( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> RepoBuilder: + builder = repo_builder("repo") + builder.commit("base", files={"a.txt": "one\ntwo\nthree\n"}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + builder.commit("add model.cfr") # sweep in the generated model so later diffs stay clean + builder.branch("side") + return builder + + +def _mint(builder: RepoBuilder, sha: str) -> str: + store = GitRefStore(builder.repo) + record = ChangeIdMap(store).insert(sha, patch_id=patch_id(builder.repo, sha)) + store.commit(f"mint {sha[:12]}") + return record.change_id + + +def test_cherry_pick_links_by_patch_id(runner: CliRunner, builder: RepoBuilder) -> None: + original = builder.commit("feature work", files={"a.txt": "one\nTWO\nthree\n"}) + change_id = _mint(builder, original) + builder.switch("side") + copy = builder.commit("feature work (picked)", files={"a.txt": "one\nTWO\nthree\n"}) + + result = runner.invoke(app, ["reconcile"]) + assert result.exit_code == 0, result.output + linked = ChangeIdMap(GitRefStore(builder.repo)).get(copy) + assert linked is not None + assert linked.change_id == change_id + + +def test_modified_cherry_pick_links_fuzzily(runner: CliRunner, builder: RepoBuilder) -> None: + original = builder.commit( + "feature work", + files={"b.txt": "alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\n"}, + ) + change_id = _mint(builder, original) + builder.switch("side") + copy = builder.commit( + "feature work (tweaked)", + files={"b.txt": "alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\nTHETA\n"}, + ) + + result = runner.invoke(app, ["reconcile"]) + assert result.exit_code == 0, result.output + assert "fuzzy" in result.output + linked = ChangeIdMap(GitRefStore(builder.repo)).get(copy) + assert linked is not None + assert linked.change_id == change_id + + +def test_dry_run_writes_nothing(runner: CliRunner, builder: RepoBuilder) -> None: + original = builder.commit("feature work", files={"a.txt": "one\nTWO\nthree\n"}) + _mint(builder, original) + builder.switch("side") + copy = builder.commit("feature work (picked)", files={"a.txt": "one\nTWO\nthree\n"}) + + result = runner.invoke(app, ["reconcile", "--dry-run"]) + assert result.exit_code == 0, result.output + assert "would link" in result.output + assert ChangeIdMap(GitRefStore(builder.repo)).get(copy) is None + + +def test_ambiguous_match_needs_interactive(runner: CliRunner, builder: RepoBuilder) -> None: + original = builder.commit("feature work", files={"a.txt": "one\nTWO\nthree\n"}) + cid_a = _mint(builder, original) + # A second known change with the identical patch-id (e.g. imported twice). + store = GitRefStore(builder.repo) + ChangeIdMap(store).insert("ee" * 20, patch_id=patch_id(builder.repo, original)) + store.commit("plant duplicate patch-id") + builder.switch("side") + copy = builder.commit("feature work (picked)", files={"a.txt": "one\nTWO\nthree\n"}) + + result = runner.invoke(app, ["reconcile"]) + assert result.exit_code == 0, result.output + assert "ambiguous" in result.output + assert ChangeIdMap(GitRefStore(builder.repo)).get(copy) is None + + result = runner.invoke(app, ["reconcile", "--interactive"], input="1\n") + assert result.exit_code == 0, result.output + linked = ChangeIdMap(GitRefStore(builder.repo)).get(copy) + assert linked is not None + assert linked.change_id in { + cid_a, + ChangeIdMap(GitRefStore(builder.repo)).get("ee" * 20).change_id, + } # type: ignore[union-attr] + + +def test_unrelated_commits_stay_unmapped(runner: CliRunner, builder: RepoBuilder) -> None: + builder.commit("unrelated", files={"c.txt": "totally different\n"}) + result = runner.invoke(app, ["--json", "reconcile"]) + assert result.exit_code == 0, result.output + report = json.loads(result.output) + assert report["links"] == [] + assert len(report["unmatched"]) >= 1 diff --git a/tests/test_region.py b/tests/test_region.py new file mode 100644 index 0000000..d0a8a19 --- /dev/null +++ b/tests/test_region.py @@ -0,0 +1,125 @@ +"""Tests for region anchors and the two-tier resolver.""" + +from collections.abc import Callable + +import pytest + +from git_feature.gitio import commit_diff, patch_id +from git_feature.identity import ChangeIdMap, RegionResolver, anchor_from_hunk +from git_feature.store import MemoryStore, RegionAnchor +from tests.fixtures import RepoBuilder + +BASE = "def top():\n return 1\n\n\ndef bottom():\n return 2\n" +WITH_REGION = ( + "def top():\n return 1\n\n\ndef region_a():\n x = compute()\n return x\n\n\n" + "def bottom():\n return 2\n" +) + + +@pytest.fixture +def setup( + repo_builder: Callable[[str], RepoBuilder], +) -> tuple[RepoBuilder, ChangeIdMap, RegionAnchor]: + builder = repo_builder("repo") + builder.commit("base", files={"code.py": BASE}) + region_sha = builder.commit("add region", files={"code.py": WITH_REGION}) + + cid_map = ChangeIdMap(MemoryStore()) + record = cid_map.insert(region_sha, patch_id=patch_id(builder.repo, region_sha)) + + (file_diff,) = commit_diff(builder.repo, region_sha) + (hunk,) = file_diff.hunks + anchor = anchor_from_hunk(record.change_id, "code.py", hunk) + return builder, cid_map, anchor + + +def test_anchor_from_hunk_captures_spans( + setup: tuple[RepoBuilder, ChangeIdMap, RegionAnchor], +) -> None: + _, _, anchor = setup + assert anchor.path == "code.py" + assert anchor.new_span[1] == 5 # the region block incl. surrounding blank lines + assert anchor.fingerprint + + +def test_untouched_region_resolves_exactly_in_place( + setup: tuple[RepoBuilder, ChangeIdMap, RegionAnchor], +) -> None: + builder, cid_map, anchor = setup + resolution = RegionResolver(builder.repo, cid_map).resolve(anchor, "HEAD") + assert resolution.confidence == "exact" + assert resolution.span == anchor.new_span + + +def test_edits_above_shift_span_but_stay_exact( + setup: tuple[RepoBuilder, ChangeIdMap, RegionAnchor], +) -> None: + builder, cid_map, anchor = setup + shifted = WITH_REGION.replace("def top():\n", "import os\nimport sys\n\n\ndef top():\n") + builder.commit("edits above", files={"code.py": shifted}) + resolution = RegionResolver(builder.repo, cid_map).resolve(anchor, "HEAD") + assert resolution.confidence == "exact" + assert resolution.span == (anchor.new_span[0] + 4, anchor.new_span[1]) + + +def test_rename_plus_edit_resolves_fuzzily( + setup: tuple[RepoBuilder, ChangeIdMap, RegionAnchor], +) -> None: + builder, cid_map, anchor = setup + edited = WITH_REGION.replace("return 2", "return 42") + builder.commit("rename and edit", files={"renamed.py": edited}, remove=["code.py"]) + resolution = RegionResolver(builder.repo, cid_map).resolve(anchor, "HEAD") + assert resolution.confidence == "fuzzy" + assert resolution.span == anchor.new_span # region itself did not move + + +def test_edit_inside_region_after_rename_still_found_by_similarity( + setup: tuple[RepoBuilder, ChangeIdMap, RegionAnchor], +) -> None: + builder, cid_map, anchor = setup + edited = WITH_REGION.replace("x = compute()", "x = compute(fast=True)") + builder.commit("rename and edit inside", files={"renamed.py": edited}, remove=["code.py"]) + resolution = RegionResolver(builder.repo, cid_map).resolve(anchor, "HEAD") + assert resolution.confidence == "fuzzy" + assert resolution.span == anchor.new_span + + +def test_deleted_region_reports_lost( + setup: tuple[RepoBuilder, ChangeIdMap, RegionAnchor], +) -> None: + builder, cid_map, anchor = setup + builder.commit("remove region", files={"code.py": BASE}) + resolution = RegionResolver(builder.repo, cid_map).resolve(anchor, "HEAD") + assert resolution.confidence == "lost" + assert resolution.span is None + + +def test_partial_survival_reports_moved( + setup: tuple[RepoBuilder, ChangeIdMap, RegionAnchor], +) -> None: + builder, cid_map, anchor = setup + partial = WITH_REGION.replace(" x = compute()\n", "") + builder.commit("shrink region", files={"code.py": partial}) + resolution = RegionResolver(builder.repo, cid_map).resolve(anchor, "HEAD") + assert resolution.confidence == "moved" + assert resolution.span is not None + + +def test_resolution_is_cached_per_anchor_and_target( + setup: tuple[RepoBuilder, ChangeIdMap, RegionAnchor], + monkeypatch: pytest.MonkeyPatch, +) -> None: + builder, cid_map, anchor = setup + resolver = RegionResolver(builder.repo, cid_map) + calls = {"n": 0} + original = RegionResolver._blame_forward + + def counting(self: RegionResolver, a: RegionAnchor, c: object) -> object: + calls["n"] += 1 + return original(self, a, c) # type: ignore[arg-type] + + monkeypatch.setattr(RegionResolver, "_blame_forward", counting) + first = resolver.resolve(anchor, "HEAD") + second = resolver.resolve(anchor, "HEAD") + assert first == second + assert calls["n"] == 1 diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..2bdb179 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,51 @@ +"""Tests for `git feature status`.""" + +import json + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.conftest import AnnotatedRepo + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_clean_tree(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0, result.output + assert "working tree clean" in result.output + + +def test_edit_inside_annotated_region_shows_feature( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + annotated_repo.builder.write("src/auth/login.py", "def login():\n return check_twice()\n") + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0, result.output + assert "auth:" in result.output + assert "src/auth/login.py" in result.output + + +def test_edit_elsewhere_is_unassigned(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + annotated_repo.builder.write("src/core.py", "def core():\n return 'changed'\n") + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0, result.output + assert "unassigned:" in result.output + assert "src/core.py" in result.output + assert "auth:" not in result.output + + +def test_staged_changes_are_included(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + builder = annotated_repo.builder + builder.write("src/ui/render.py", "def render():\n return draw_fast()\n") + index = builder.repo.index + index.add("src/ui/render.py") + index.write() + result = runner.invoke(app, ["--json", "status"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert any("src/ui/render.py" in loc for loc in payload["features"].get("ui", [])) diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..999a9a5 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,173 @@ +"""Round-trip tests for the store backends.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime + +import pygit2 +import pytest + +from git_feature.store import ( + STORE_REF, + Annotation, + ChangeIdRecord, + DerivationManifest, + FeatureDef, + GitRefStore, + MemoryStore, + RegionAnchor, + Store, + StoreMeta, +) +from tests.fixtures import RepoBuilder + +TS = datetime(2026, 7, 15, 12, 0, 0, tzinfo=UTC) + +ANCHOR = RegionAnchor( + change_id="01JCHANGEID000000000000000", + path="src/auth/login.py", + old_span=(0, 0), + new_span=(10, 4), + fingerprint="abc123", +) + + +@dataclass +class Backend: + store: Store + reopen: Callable[[], Store] + history_len: Callable[[], int] + + +@pytest.fixture(params=["memory", "gitref"]) +def backend(request: pytest.FixtureRequest, repo_builder: Callable[[str], RepoBuilder]) -> Backend: + if request.param == "memory": + memory = MemoryStore() + return Backend( + store=memory, + reopen=lambda: memory, + history_len=lambda: len(memory.commits), + ) + builder = repo_builder("store") + + def history_len() -> int: + repo = pygit2.Repository(str(builder.path)) + target = repo.references[STORE_REF].target + return sum(1 for _ in repo.walk(target)) + + return Backend( + store=GitRefStore(builder.repo), + reopen=lambda: GitRefStore(pygit2.Repository(str(builder.path))), + history_len=history_len, + ) + + +def test_round_trip_all_object_kinds(backend: Backend) -> None: + store = backend.store + meta = StoreMeta(tool_version="0.1.0") + feature = FeatureDef(name="auth", description="authentication", owners=["alice"]) + annotations = [ + Annotation(anchor=ANCHOR, presence="auth", author="alice", ts=TS), + Annotation(anchor=ANCHOR, presence="auth & premium", author="bob", ts=TS, note="shared"), + ] + record_aa = ChangeIdRecord(sha="aa" * 20, change_id="01JAAA", patch_id="pa") + record_ab = ChangeIdRecord(sha="ab" + "cd" * 19, change_id="01JBBB") + manifest = DerivationManifest( + instance="minimal", + base_commit="aa" * 20, + assignment={"auth": True, "premium": False}, + created_at=TS, + ) + + store.write_meta(meta) + store.write_feature(feature) + store.write_annotations(ANCHOR.change_id, annotations) + store.put_changeid(record_aa) + store.put_changeid(record_ab) + store.write_manifest("minimal", manifest) + store.commit("initial store content") + + reopened = backend.reopen() + assert reopened.read_meta() == meta + assert reopened.read_feature("auth") == feature + assert reopened.list_features() == ["auth"] + assert reopened.read_annotations(ANCHOR.change_id) == annotations + assert reopened.list_annotated_changes() == [ANCHOR.change_id] + assert reopened.get_changeid("aa" * 20) == record_aa + assert reopened.get_changeid(record_ab.sha) == record_ab + assert sorted(r.sha for r in reopened.iter_changeids()) == sorted( + [record_aa.sha, record_ab.sha] + ) + assert reopened.read_manifest("minimal") == manifest + assert reopened.list_variants() == ["minimal"] + + +def test_missing_objects_read_as_empty(backend: Backend) -> None: + store = backend.store + assert store.read_meta() is None + assert store.read_feature("nope") is None + assert store.read_annotations("nope") == [] + assert store.get_changeid("aa" * 20) is None + assert store.read_manifest("nope") is None + assert store.list_features() == [] + assert list(store.iter_changeids()) == [] + + +def test_staged_writes_visible_before_commit(backend: Backend) -> None: + store = backend.store + feature = FeatureDef(name="auth") + store.write_feature(feature) + assert store.read_feature("auth") == feature + assert store.list_features() == ["auth"] + + +def test_sequential_commits_build_history(backend: Backend) -> None: + store = backend.store + store.write_meta(StoreMeta(tool_version="0.1.0")) + store.commit("first") + store.write_feature(FeatureDef(name="auth")) + store.commit("second") + assert backend.history_len() == 2 + reopened = backend.reopen() + assert reopened.read_meta() is not None + assert reopened.read_feature("auth") is not None + + +class TestGitRefBackend: + def test_concurrent_commit_conflicts(self, repo_builder: Callable[[str], RepoBuilder]) -> None: + from git_feature.gitio import RefUpdateConflict + + builder = repo_builder("conflict") + store_a = GitRefStore(builder.repo) + store_b = GitRefStore(pygit2.Repository(str(builder.path))) + store_a.write_meta(StoreMeta()) + store_a.commit("from a") + store_b.write_feature(FeatureDef(name="auth")) + with pytest.raises(RefUpdateConflict): + store_b.commit("from b") + + def test_changeid_map_is_sharded_by_sha_prefix( + self, repo_builder: Callable[[str], RepoBuilder] + ) -> None: + builder = repo_builder("shards") + store = GitRefStore(builder.repo) + store.put_changeid(ChangeIdRecord(sha="aa" * 20, change_id="01JAAA")) + store.put_changeid(ChangeIdRecord(sha="ab" + "aa" * 19, change_id="01JBBB")) + store.commit("sharded") + repo = pygit2.Repository(str(builder.path)) + tree = repo.references[STORE_REF].peel(pygit2.Commit).tree + shard_names = sorted(entry.name for entry in tree["changeids"]) + assert shard_names == ["map-aa.json", "map-ab.json"] + + def test_store_ref_never_touches_worktree_or_branches( + self, repo_builder: Callable[[str], RepoBuilder] + ) -> None: + builder = repo_builder("isolation") + sha = builder.commit("code", files={"a.txt": "a\n"}) + store = GitRefStore(builder.repo) + store.write_meta(StoreMeta()) + store.commit("store init") + assert str(builder.repo.head.target) == sha + assert list(builder.repo.branches.local) == [builder.default_branch] + assert (builder.path / "a.txt").exists() + assert not (builder.path / "meta.json").exists() diff --git a/tests/test_store_sync.py b/tests/test_store_sync.py new file mode 100644 index 0000000..9ded4d7 --- /dev/null +++ b/tests/test_store_sync.py @@ -0,0 +1,263 @@ +"""Tests for sharing the feature store: 3-way merge, push, and fetch.""" + +from collections.abc import Callable +from pathlib import Path + +import pygit2 +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.store import ( + STORE_REF, + GitRefStore, + MemoryStore, + StoreMergeError, + merge_stores, +) +from git_feature.store.types import ( + Annotation, + ChangeIdRecord, + FeatureDef, + RegionAnchor, + StoreMeta, +) +from tests.fixtures import RepoBuilder + + +def _anchor(path: str = "src/a.py") -> RegionAnchor: + return RegionAnchor( + change_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + path=path, + old_span=(1, 0), + new_span=(1, 2), + fingerprint="abc", + ) + + +def _annotation(presence: str, ts: str = "2026-07-01T00:00:00Z") -> Annotation: + return Annotation(anchor=_anchor(), presence=presence, author="a@example.com", ts=ts) + + +def _store_with( + features: tuple[str, ...] = (), + annotations: dict[str, list[Annotation]] | None = None, + changeids: tuple[ChangeIdRecord, ...] = (), +) -> dict[str, bytes]: + store = MemoryStore() + store.write_meta(StoreMeta()) + for name in features: + store.write_feature(FeatureDef(name=name)) + for change_id, items in (annotations or {}).items(): + store.write_annotations(change_id, items) + for record in changeids: + store.put_changeid(record) + return dict(store._files) + + +def test_merge_unions_disjoint_annotations() -> None: + base = _store_with() + ours = _store_with(features=["auth"], annotations={"cid-1": [_annotation("auth")]}) + theirs = _store_with(features=["ui"], annotations={"cid-2": [_annotation("ui")]}) + + merged, notes = merge_stores(base, ours, theirs) + result = MemoryStore() + result._files = merged + assert result.list_features() == ["auth", "ui"] + assert [a.presence for a in result.read_annotations("cid-1")] == ["auth"] + assert [a.presence for a in result.read_annotations("cid-2")] == ["ui"] + assert notes == [] + + +def test_merge_unions_annotations_of_the_same_change() -> None: + base = _store_with() + shared = _annotation("core") + ours = _store_with(annotations={"cid-1": [shared, _annotation("auth")]}) + theirs = _store_with(annotations={"cid-1": [shared, _annotation("ui")]}) + + merged, _ = merge_stores(base, ours, theirs) + result = MemoryStore() + result._files = merged + presences = sorted(a.presence for a in result.read_annotations("cid-1")) + assert presences == ["auth", "core", "ui"] + + +def test_merge_is_symmetric_when_a_sha_is_minted_twice() -> None: + sha = "d" * 40 + older = "01ARZ3NDEKTSV4RRFFQ69G5FAV" + newer = "01BX5ZZKBKACTAV9WEVGEMMVRZ" + base = _store_with() + ours = _store_with( + changeids=[ChangeIdRecord(sha=sha, change_id=older)], + annotations={older: [_annotation("auth")]}, + ) + theirs = _store_with( + changeids=[ChangeIdRecord(sha=sha, change_id=newer)], + annotations={newer: [_annotation("ui")]}, + ) + + forward, notes = merge_stores(base, ours, theirs) + backward, _ = merge_stores(base, theirs, ours) + assert forward == backward + assert any("minted on both sides" in note for note in notes) + + result = MemoryStore() + result._files = forward + # the older ULID wins and inherits the newer id's annotations + record = result.get_changeid(sha) + assert record is not None and record.change_id == older + presences = sorted(a.presence for a in result.read_annotations(older)) + assert presences == ["auth", "ui"] + assert result.read_annotations(newer) == [] + + +def test_merge_features_field_wise() -> None: + base = _store_with(features=["auth"]) + ours_store = MemoryStore() + ours_store._files = dict(_store_with()) + ours_store.write_feature(FeatureDef(name="auth", description="login", owners=["ann"])) + theirs_store = MemoryStore() + theirs_store._files = dict(_store_with()) + theirs_store.write_feature(FeatureDef(name="auth", owners=["bob"], tags=["security"])) + + merged, _ = merge_stores(base, dict(ours_store._files), dict(theirs_store._files)) + result = MemoryStore() + result._files = merged + feature = result.read_feature("auth") + assert feature is not None + assert feature.description == "login" + assert feature.owners == ["ann", "bob"] + assert feature.tags == ["security"] + + +def test_merge_rejects_mismatched_schema_versions() -> None: + ours = _store_with() + theirs = dict(ours) + theirs["meta.json"] = StoreMeta(schema_version=999).model_dump_json().encode() + with pytest.raises(StoreMergeError): + merge_stores({}, ours, theirs) + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def shared_remote(tmp_path: Path) -> str: + remote_path = tmp_path / "remote.git" + pygit2.init_repository(str(remote_path), bare=True) + return str(remote_path) + + +def _machine( + repo_builder: Callable[[str], RepoBuilder], + runner: CliRunner, + name: str, + remote_url: str, +) -> RepoBuilder: + builder = repo_builder(name) + builder.repo.remotes.create("origin", remote_url) + builder.commit("base", files={"app.py": f"# {name}\ndef run():\n pass\n"}) + return builder + + +def test_push_fetch_and_merge_between_two_machines( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, + shared_remote: str, +) -> None: + ann = _machine(repo_builder, runner, "ann", shared_remote) + bob = _machine(repo_builder, runner, "bob", shared_remote) + + # machine ann initializes, annotates, and shares the store + monkeypatch.chdir(ann.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + ann_sha = ann.commit("auth code", files={"auth.py": "def login():\n pass\n"}) + assert runner.invoke(app, ["annotate", ann_sha, "--feature", "auth"]).exit_code == 0 + assert runner.invoke(app, ["store", "push"]).exit_code == 0 + + # machine bob starts from ann's store instead of initializing its own + monkeypatch.chdir(bob.path) + result = runner.invoke(app, ["store", "fetch"]) + assert result.exit_code == 0, result.output + assert "fetched" in result.output + assert GitRefStore(bob.repo).list_features() == ["auth"] + + # both sides annotate independently -> the stores diverge + bob_sha = bob.commit("ui code", files={"ui.py": "def render():\n pass\n"}) + assert runner.invoke(app, ["annotate", bob_sha, "--feature", "ui"]).exit_code == 0 + monkeypatch.chdir(ann.path) + ann_sha2 = ann.commit("search code", files={"search.py": "def find():\n pass\n"}) + assert runner.invoke(app, ["annotate", ann_sha2, "--feature", "search"]).exit_code == 0 + assert runner.invoke(app, ["store", "push"]).exit_code == 0 + + # bob's push is rejected, fetch merges, then the push goes through + monkeypatch.chdir(bob.path) + result = runner.invoke(app, ["store", "push"]) + assert result.exit_code == 1 + assert "store fetch" in result.output + result = runner.invoke(app, ["store", "fetch"]) + assert result.exit_code == 0, result.output + assert "merged" in result.output + assert GitRefStore(bob.repo).list_features() == ["auth", "search", "ui"] + assert runner.invoke(app, ["store", "push"]).exit_code == 0 + + # ann fast-forwards to the merged store + monkeypatch.chdir(ann.path) + result = runner.invoke(app, ["store", "fetch"]) + assert result.exit_code == 0, result.output + assert "fast-forwarded" in result.output + assert GitRefStore(ann.repo).list_features() == ["auth", "search", "ui"] + + +def test_fetch_reports_up_to_date_and_ahead( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, + shared_remote: str, +) -> None: + ann = _machine(repo_builder, runner, "ann", shared_remote) + monkeypatch.chdir(ann.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + assert runner.invoke(app, ["store", "push"]).exit_code == 0 + + result = runner.invoke(app, ["store", "fetch"]) + assert result.exit_code == 0, result.output + assert "up to date" in result.output + + sha = ann.commit("auth code", files={"auth.py": "def login():\n pass\n"}) + assert runner.invoke(app, ["annotate", sha, "--feature", "auth"]).exit_code == 0 + result = runner.invoke(app, ["store", "fetch"]) + assert result.exit_code == 0, result.output + assert "ahead" in result.output + + +def test_init_adds_the_store_fetch_refspec( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, + shared_remote: str, +) -> None: + ann = _machine(repo_builder, runner, "ann", shared_remote) + monkeypatch.chdir(ann.path) + result = runner.invoke(app, ["init"]) + assert result.exit_code == 0, result.output + assert "fetch refspec" in result.output + refspecs = ann.repo.remotes["origin"].fetch_refspecs + assert f"+{STORE_REF}:refs/feature/incoming/origin" in refspecs + + +def test_push_without_remote_fails_cleanly( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> None: + builder = repo_builder("solo") + builder.commit("base", files={"app.py": "pass\n"}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + result = runner.invoke(app, ["store", "push"]) + assert result.exit_code == 1 + assert "no remote" in result.output diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..58afdc3 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,126 @@ +"""Tests for `git feature sync`: feature-based commit propagation between branches.""" + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.gitio import read_ref +from git_feature.identity import ChangeIdMap +from git_feature.store import GitRefStore +from tests.conftest import AnnotatedRepo + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_dry_run_lists_the_missing_commit_without_moving_the_branch( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + builder = annotated_repo.builder + source = builder.default_branch + builder.switch("release") + before = read_ref(builder.repo, "refs/heads/release") + + result = runner.invoke(app, ["sync", source, "--dry-run"]) + assert result.exit_code == 0, result.output + assert "add render" in result.output + assert "[ui]" in result.output + assert read_ref(builder.repo, "refs/heads/release") == before + + +def test_sync_propagates_the_commit_and_its_change_id( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + builder = annotated_repo.builder + source = builder.default_branch + builder.switch("release") + + result = runner.invoke(app, ["sync", source]) + assert result.exit_code == 0, result.output + assert "synced" in result.output + + new_tip = read_ref(builder.repo, "refs/heads/release") + assert new_tip is not None and new_tip != annotated_repo.auth_sha + record = ChangeIdMap(GitRefStore(builder.repo)).get(new_tip) + assert record is not None + assert record.change_id == annotated_repo.ui_cid + # annotations follow the change-id: the copy reports the same feature + result = runner.invoke(app, ["whatfeature", new_tip]) + assert result.exit_code == 0 + assert "ui" in result.output + # and the worktree was updated, leaving index and workdir clean + assert (builder.path / "src/ui/render.py").exists() + assert builder.repo.status(untracked_files="no") == {} + + +def test_synced_commits_are_not_offered_again( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + builder = annotated_repo.builder + source = builder.default_branch + builder.switch("release") + assert runner.invoke(app, ["sync", source]).exit_code == 0 + + result = runner.invoke(app, ["sync", source]) + assert result.exit_code == 0, result.output + assert "nothing to sync" in result.output + + +def test_feature_filter_restricts_the_selection( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + builder = annotated_repo.builder + source = builder.default_branch + extra_sha = builder.commit( + "add search", files={"src/search/find.py": "def find():\n return scan()\n"} + ) + assert runner.invoke(app, ["annotate", extra_sha, "--feature", "search"]).exit_code == 0 + builder.switch("release") + + result = runner.invoke(app, ["sync", source, "-f", "search", "--dry-run"]) + assert result.exit_code == 0, result.output + assert "add search" in result.output + assert "add render" not in result.output + + result = runner.invoke(app, ["sync", source, "-f", "search"]) + assert result.exit_code == 0, result.output + assert (builder.path / "src/search/find.py").exists() + assert not (builder.path / "src/ui/render.py").exists() + + +def test_conflicting_cherry_pick_applies_nothing( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + builder = annotated_repo.builder + source = builder.default_branch + builder.switch("release") + builder.commit("competing render", files={"src/ui/render.py": "def render():\n pass\n"}) + before = read_ref(builder.repo, "refs/heads/release") + + result = runner.invoke(app, ["sync", source]) + assert result.exit_code == 1 + assert "conflicts" in result.output + assert "nothing was applied" in result.output + assert read_ref(builder.repo, "refs/heads/release") == before + + +def test_sync_requires_a_clean_worktree(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + builder = annotated_repo.builder + source = builder.default_branch + builder.switch("release") + builder.write("src/core.py", "def core():\n return 'dirty'\n") + + result = runner.invoke(app, ["sync", source]) + assert result.exit_code == 1 + assert "uncommitted changes" in result.output + + +def test_sync_refuses_a_variant_branch(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + builder = annotated_repo.builder + builder.branch("variant/Fake") + builder.switch("variant/Fake") + result = runner.invoke(app, ["sync", builder.default_branch]) + assert result.exit_code == 1 + assert "variants are derived" in result.output diff --git a/tests/test_variant_list.py b/tests/test_variant_list.py new file mode 100644 index 0000000..5066e9e --- /dev/null +++ b/tests/test_variant_list.py @@ -0,0 +1,60 @@ +"""Tests for `git feature variant list`.""" + +import json +from collections.abc import Callable + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.fixtures import RepoBuilder +from tests.test_checkout_dryrun import BASE, MODEL, WITH_AUTH, WITH_BOTH + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def builder( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> RepoBuilder: + builder = repo_builder("repo") + builder.commit("base", files={"model.cfr": MODEL, "shared.py": BASE}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + auth_sha = builder.commit("auth block", files={"shared.py": WITH_AUTH}) + ui_sha = builder.commit("ui block", files={"shared.py": WITH_BOTH}) + assert runner.invoke(app, ["annotate", auth_sha, "--feature", "auth"]).exit_code == 0 + assert runner.invoke(app, ["annotate", ui_sha, "--feature", "ui"]).exit_code == 0 + return builder + + +def test_empty_when_nothing_derived(runner: CliRunner, builder: RepoBuilder) -> None: + result = runner.invoke(app, ["variant", "list"]) + assert result.exit_code == 0, result.output + assert "no variants derived yet" in result.output + + +def test_lists_variants_with_base_and_disabled(runner: CliRunner, builder: RepoBuilder) -> None: + assert runner.invoke(app, ["checkout", "Minimal", "--no-switch"]).exit_code == 0 + assert runner.invoke(app, ["checkout", "AuthOnly", "--no-switch"]).exit_code == 0 + result = runner.invoke(app, ["--json", "variant", "list"]) + assert result.exit_code == 0, result.output + rows = {row["name"]: row for row in json.loads(result.output)} + assert rows["Minimal"]["disabled"] == ["auth", "ui"] + assert rows["AuthOnly"]["disabled"] == ["ui"] + assert rows["Minimal"]["branch"] == "variant/Minimal" + assert rows["Minimal"]["base_commit"] == str(builder.repo.head.target) + assert rows["Minimal"]["stale"] is False + + +def test_staleness_flips_when_source_advances(runner: CliRunner, builder: RepoBuilder) -> None: + assert runner.invoke(app, ["checkout", "Minimal", "--no-switch"]).exit_code == 0 + builder.commit("moved on", files={"new.txt": "n\n"}) + result = runner.invoke(app, ["variant", "list"]) + assert result.exit_code == 0, result.output + assert "[stale]" in result.output diff --git a/tests/test_view_putback.py b/tests/test_view_putback.py new file mode 100644 index 0000000..3b356c5 --- /dev/null +++ b/tests/test_view_putback.py @@ -0,0 +1,237 @@ +"""Tests for round-trip editing: `git feature view` and `git feature putback`.""" + +from collections.abc import Callable +from dataclasses import dataclass + +import pygit2 +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from git_feature.gitio import create_commit, read_ref, rewrite_tree, write_ref +from git_feature.identity import ChangeIdMap +from git_feature.store import GitRefStore +from tests.fixtures import RepoBuilder +from tests.test_checkout_dryrun import MODEL + +CORE = "def top():\n return 1\n\ndef bottom():\n return 2\n" +WITH_AUTH = ( + "def top():\n return 1\n\ndef auth():\n return check()\n\ndef bottom():\n return 2\n" +) + + +@dataclass +class ViewRepo: + builder: RepoBuilder + base_sha: str + auth_sha: str + source_branch: str + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def fixture( + repo_builder: Callable[[str], RepoBuilder], + monkeypatch: pytest.MonkeyPatch, + runner: CliRunner, +) -> ViewRepo: + builder = repo_builder("repo") + base_sha = builder.commit("base", files={"model.cfr": MODEL, "app.py": CORE}) + monkeypatch.chdir(builder.path) + assert runner.invoke(app, ["init"]).exit_code == 0 + auth_sha = builder.commit("auth block", files={"app.py": WITH_AUTH}) + assert runner.invoke(app, ["annotate", auth_sha, "--feature", "auth"]).exit_code == 0 + return ViewRepo(builder, base_sha, auth_sha, builder.default_branch) + + +def _source_blob(fixture: ViewRepo, path: str) -> str: + tip = fixture.builder.repo.references[f"refs/heads/{fixture.source_branch}"].peel(pygit2.Commit) + return bytes(fixture.builder.repo[tip.tree[path].id].data).decode() + + +def test_view_switches_and_records_the_session(runner: CliRunner, fixture: ViewRepo) -> None: + result = runner.invoke(app, ["view", "Minimal"]) + assert result.exit_code == 0, result.output + repo = fixture.builder.repo + assert repo.head.shorthand == "variant/Minimal" + assert (fixture.builder.path / "app.py").read_text() == CORE + + session = GitRefStore(repo).read_view("Minimal") + assert session is not None + assert session.source_branch == fixture.source_branch + assert session.base_commit == fixture.auth_sha + assert session.variant_commit == read_ref(repo, "refs/heads/variant/Minimal") + + +def test_putback_of_an_unedited_view_changes_nothing(runner: CliRunner, fixture: ViewRepo) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 0, result.output + assert "nothing to put back" in result.output + tip = read_ref(fixture.builder.repo, f"refs/heads/{fixture.source_branch}") + assert tip == fixture.auth_sha + + +def test_putback_lands_an_edit_past_a_removed_region(runner: CliRunner, fixture: ViewRepo) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + # `bottom` sits below the removed auth block: view line 5, source line 7 + edited = CORE.replace("return 2", "return 99") + fixture.builder.write("app.py", edited) + + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 0, result.output + assert _source_blob(fixture, "app.py") == WITH_AUTH.replace("return 2", "return 99") + + +def test_putback_refreshes_the_view_onto_the_new_source_commit( + runner: CliRunner, fixture: ViewRepo +) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + edited = CORE.replace("return 2", "return 99") + fixture.builder.write("app.py", edited) + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 0, result.output + assert "refreshed" in result.output + + repo = fixture.builder.repo + assert repo.head.shorthand == "variant/Minimal" + # the refreshed view equals the edited view: nothing gained, nothing lost + assert (fixture.builder.path / "app.py").read_text() == edited + # ...and both the index and the worktree are clean (no phantom modifications) + assert repo.status(untracked_files="no") == {} + session = GitRefStore(repo).read_view("Minimal") + assert session is not None + assert session.base_commit == read_ref(repo, f"refs/heads/{fixture.source_branch}") + + # a second putback right away has nothing left to transplant + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 0, result.output + assert "nothing to put back" in result.output + + +def test_putback_insertion_is_anchored_before_the_removed_region( + runner: CliRunner, fixture: ViewRepo +) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + edited = CORE.replace( + "def top():\n return 1\n", "def top():\n return 1\n\ndef extra():\n pass\n" + ) + fixture.builder.write("app.py", edited) + + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 0, result.output + expected = WITH_AUTH.replace( + "def top():\n return 1\n", "def top():\n return 1\n\ndef extra():\n pass\n" + ) + assert _source_blob(fixture, "app.py") == expected + + +def test_putback_transplants_a_new_file(runner: CliRunner, fixture: ViewRepo) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + fixture.builder.write("src/util.py", "def helper():\n return 3\n") + + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 0, result.output + assert _source_blob(fixture, "src/util.py") == "def helper():\n return 3\n" + + +def test_putback_mints_a_change_id_for_the_source_commit( + runner: CliRunner, fixture: ViewRepo +) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + fixture.builder.write("app.py", CORE.replace("return 2", "return 99")) + assert runner.invoke(app, ["putback"]).exit_code == 0 + + repo = fixture.builder.repo + tip = read_ref(repo, f"refs/heads/{fixture.source_branch}") + assert tip is not None + assert ChangeIdMap(GitRefStore(repo)).get(tip) is not None + + +def test_putback_refuses_an_edit_crossing_a_removed_region( + runner: CliRunner, fixture: ViewRepo +) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + # rewrite view lines 2-4 (return 1, blank, def bottom) into one line: in the + # source those lines straddle the removed auth block + edited = "def top():\n return bottom_inline()\n return 2\n" + fixture.builder.write("app.py", edited) + + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 1 + assert "crosses a region removed from this variant" in result.output + assert read_ref(fixture.builder.repo, f"refs/heads/{fixture.source_branch}") == ( + fixture.auth_sha + ) + + +def test_putback_refuses_when_the_source_branch_moved(runner: CliRunner, fixture: ViewRepo) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + repo = fixture.builder.repo + tree = rewrite_tree(repo, fixture.auth_sha, {"other.txt": b"x\n"}) + sha = create_commit(repo, tree, "advance", [fixture.auth_sha]) + write_ref(repo, f"refs/heads/{fixture.source_branch}", sha) + + fixture.builder.write("app.py", CORE.replace("return 2", "return 99")) + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 1 + assert "has moved" in result.output + + +def test_putback_refuses_a_deleted_file(runner: CliRunner, fixture: ViewRepo) -> None: + fixture.builder.commit("second file", files={"doomed.py": "gone = True\n"}) + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + (fixture.builder.path / "doomed.py").unlink() + + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 1 + assert "only edits and new files" in result.output + + +def test_putback_requires_a_view_branch(runner: CliRunner, fixture: ViewRepo) -> None: + result = runner.invoke(app, ["putback"]) + assert result.exit_code == 1 + assert "not on a view branch" in result.output + + +def test_putback_dry_run_reports_the_mapping_without_committing( + runner: CliRunner, fixture: ViewRepo +) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + fixture.builder.write("app.py", CORE.replace("return 2", "return 99")) + + result = runner.invoke(app, ["putback", "--dry-run"]) + assert result.exit_code == 0, result.output + assert "view lines 5 -> source lines 8" in result.output + assert read_ref(fixture.builder.repo, f"refs/heads/{fixture.source_branch}") == ( + fixture.auth_sha + ) + + +def test_view_reopened_from_the_view_branch_refreshes_from_the_source_tip( + runner: CliRunner, fixture: ViewRepo +) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + repo = fixture.builder.repo + tree = rewrite_tree(repo, fixture.auth_sha, {"other.txt": b"x\n"}) + sha = create_commit(repo, tree, "advance", [fixture.auth_sha]) + write_ref(repo, f"refs/heads/{fixture.source_branch}", sha) + + result = runner.invoke(app, ["view", "Minimal"]) + assert result.exit_code == 0, result.output + session = GitRefStore(repo).read_view("Minimal") + assert session is not None + assert session.base_commit == sha + assert (fixture.builder.path / "other.txt").read_text() == "x\n" + + +def test_view_refresh_refuses_with_uncommitted_edits(runner: CliRunner, fixture: ViewRepo) -> None: + assert runner.invoke(app, ["view", "Minimal"]).exit_code == 0 + fixture.builder.write("app.py", CORE.replace("return 2", "return 99")) + result = runner.invoke(app, ["view", "Minimal"]) + assert result.exit_code == 1 + assert "uncommitted edits" in result.output diff --git a/tests/test_whatfeature.py b/tests/test_whatfeature.py new file mode 100644 index 0000000..7e7dc23 --- /dev/null +++ b/tests/test_whatfeature.py @@ -0,0 +1,45 @@ +"""Tests for `git feature whatfeature`.""" + +import json + +import pytest +from typer.testing import CliRunner + +from git_feature.cli import app +from tests.conftest import AnnotatedRepo + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_annotated_commit_lists_its_features( + runner: CliRunner, annotated_repo: AnnotatedRepo +) -> None: + result = runner.invoke(app, ["whatfeature", annotated_repo.auth_sha]) + assert result.exit_code == 0, result.output + assert "auth" in result.output + assert "src/auth/login.py" in result.output + + +def test_head_works_as_rev(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["whatfeature", "HEAD"]) # HEAD is the ui commit + assert result.exit_code == 0, result.output + assert "ui" in result.output + + +def test_unannotated_commit(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["whatfeature", annotated_repo.base_sha]) + assert result.exit_code == 0, result.output + assert "no features recorded" in result.output + + +def test_json_output(runner: CliRunner, annotated_repo: AnnotatedRepo) -> None: + result = runner.invoke(app, ["--json", "whatfeature", annotated_repo.auth_sha]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["sha"] == annotated_repo.auth_sha + assert payload["change_id"] == annotated_repo.auth_cid + (annotation,) = payload["annotations"] + assert annotation["presence"] == "auth" diff --git a/tests/variability/__init__.py b/tests/variability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/variability/test_evaluator.py b/tests/variability/test_evaluator.py new file mode 100644 index 0000000..8f82adb --- /dev/null +++ b/tests/variability/test_evaluator.py @@ -0,0 +1,157 @@ +"""Evaluator spec ported from the Rust proof of concept, plus strictness upgrades.""" + +import pytest + +from git_feature.domain.variability import ( + ConfigurationError, + Evaluator, + ModelError, + parse_model, +) + +# The reference model from the Rust test suite, verbatim. +MODEL = """ +abstract POSSystem { + xor Storage { + SQLite + Postgres + } + Scanner ? + DigitalPayment ? + PhysicalCash ? + ReceiptPrinter ? + + // Dependencies + [ PhysicalCash => ReceiptPrinter ] + [ DigitalPayment || PhysicalCash ] +} + +// Instance 1: A lightweight, digital-only tablet +MinimalistKiosk : POSSystem { + [ SQLite ] + [ DigitalPayment ] + [ no PhysicalCash ] + [ no Scanner ] + [ no ReceiptPrinter ] +} + +// Instance 2: A robust, hardware-heavy checkout +FullServiceStation : POSSystem { + [ Postgres ] + [ DigitalPayment ] + [ PhysicalCash ] + [ Scanner ] +} +""" + + +def _evaluator(text: str = MODEL) -> Evaluator: + return Evaluator(parse_model(text)) + + +def test_resolves_full_service_station() -> None: + config = _evaluator().resolve_instance("FullServiceStation") + assert config.included == { + "Postgres", + "DigitalPayment", + "PhysicalCash", + "Scanner", + "ReceiptPrinter", + } + assert config.excluded == {"SQLite"} + + +def test_resolves_minimalist_kiosk() -> None: + config = _evaluator().resolve_instance("MinimalistKiosk") + assert config.included == {"SQLite", "DigitalPayment"} + assert config.excluded == {"Postgres", "PhysicalCash", "Scanner", "ReceiptPrinter"} + + +def test_unknown_instance_errors() -> None: + with pytest.raises(ModelError): + _evaluator().resolve_instance("NoSuchInstance") + + +# --- beyond the Rust suite: total assignments, legality, inference --- + + +def test_assignment_is_total_over_the_feature_tree() -> None: + assignment = _evaluator().resolve_assignment("MinimalistKiosk") + assert assignment["SQLite"] is True + assert assignment["Postgres"] is False + assert assignment["Scanner"] is False + assert assignment["Storage"] is True # structural group node is active + assert "MinimalistKiosk" not in assignment # the instance is not a feature + + +def test_mandatory_children_are_forced_on() -> None: + evaluator = _evaluator( + """ +abstract App { + core + extras ? +} + +Plain : App {} +""" + ) + config = evaluator.resolve_instance("Plain") + assert "core" in config.included + assert "extras" in config.excluded + + +def test_xor_group_infers_the_other_choice_off() -> None: + config = _evaluator().resolve_instance("FullServiceStation") + assert "SQLite" in config.excluded # Postgres chosen, xor forces SQLite off + + +def test_implies_propagates() -> None: + # FullServiceStation selects PhysicalCash but never mentions ReceiptPrinter: + # [ PhysicalCash => ReceiptPrinter ] must pull it in. + config = _evaluator().resolve_instance("FullServiceStation") + assert "ReceiptPrinter" in config.included + + +def test_violated_constraint_is_a_hard_error() -> None: + evaluator = _evaluator( + """ +abstract App { + a ? + b ? + [ a => b ] +} + +Broken : App { + [ a ] + [ no b ] +} +""" + ) + with pytest.raises(ConfigurationError): + evaluator.resolve_instance("Broken") + + +def test_xor_overselection_is_a_hard_error() -> None: + evaluator = _evaluator( + """ +abstract App { + xor Mode { + fast + safe + } +} + +Both : App { + [ fast ] + [ safe ] +} +""" + ) + with pytest.raises(ConfigurationError): + evaluator.resolve_instance("Both") + + +def test_instance_without_super_type_errors() -> None: + evaluator = _evaluator("Lonely\n") + with pytest.raises(ModelError): + evaluator.resolve_instance("Lonely") diff --git a/tests/variability/test_parser.py b/tests/variability/test_parser.py new file mode 100644 index 0000000..ef2c1b5 --- /dev/null +++ b/tests/variability/test_parser.py @@ -0,0 +1,133 @@ +"""Parser tests: the feature-modeling subset parses; everything beyond it is rejected.""" + +import pytest + +from git_feature.domain.variability import ModelParseError, parse_model +from git_feature.domain.variability.ast import And, Implies, Not, Or, Ref + + +def test_feature_tree_shape() -> None: + module = parse_model( + """ +abstract Shop { + core + payments ? + xor Storage { + SQLite + Postgres + } +} +""" + ) + (shop,) = module.clafers + assert shop.name == "Shop" + assert shop.is_abstract + names = [child.name for child in shop.children] + assert names == ["core", "payments", "Storage"] + core, payments, storage = shop.children + assert core.card is None + assert payments.card == "?" + assert storage.gcard == "xor" + assert [c.name for c in storage.children] == ["SQLite", "Postgres"] + + +def test_instance_with_super_type_and_constraints() -> None: + module = parse_model( + """ +abstract App { a ? b ? } +Full : App { + [ a ] + [ !b ] +} +""" + ) + full = module.clafers[1] + assert full.super_type == "App" + assert full.constraints == [Ref("a"), Not(Ref("b"))] + + +def test_boolean_operators_and_precedence() -> None: + module = parse_model("abstract M { x? y? z? } I : M { [ x || y && !z ] }") + (expr,) = module.clafers[1].constraints + assert expr == Or(Ref("x"), And(Ref("y"), Not(Ref("z")))) + + +def test_no_and_not_are_negation() -> None: + module = parse_model("abstract M { x? } I : M { [ no x ] [ not x ] }") + assert module.clafers[1].constraints == [Not(Ref("x")), Not(Ref("x"))] + + +def test_implies_and_parens() -> None: + module = parse_model("abstract M { x? y? z? } I : M { [ (x || y) => z ] }") + (expr,) = module.clafers[1].constraints + assert expr == Implies(Or(Ref("x"), Ref("y")), Ref("z")) + + +def test_cardinalities() -> None: + module = parse_model( + """ +abstract M { + one_ + opt ? + many * + some + + ranged 1..3 + open 2..* +} +""" + ) + cards = {c.name: c.card for c in module.clafers[0].children} + assert cards == { + "one_": None, + "opt": "?", + "many": "*", + "some": "+", + "ranged": "1..3", + "open": "2..*", + } + + +def test_group_cardinality_as_range() -> None: + module = parse_model("abstract M { 1..2 Group { a b c } }") + (group,) = module.clafers[0].children + assert group.gcard == "1..2" + + +def test_comments_are_ignored() -> None: + module = parse_model( + """ +// line comment +abstract M { + a ? // trailing + /* block + comment */ + b ? +} +""" + ) + assert [c.name for c in module.clafers[0].children] == ["a", "b"] + + +@pytest.mark.parametrize( + "text", + [ + "enum Color = red | green", # enums + "abstract M { ref -> Target }", # reference clafers + "abstract M { x = 5 }", # initializers + "abstract M { [ x + y ] }", # arithmetic + "abstract M { [ x > 3 ] }", # comparisons + 'abstract M { [ x = "str" ] }', # strings + "abstract M { [ a.b ] }", # dotted joins + "abstract M { [ lone x ] }", # set quantifiers + ], + ids=["enum", "reference", "init", "arithmetic", "comparison", "string", "join", "quantifier"], +) +def test_constructs_beyond_the_subset_are_rejected(text: str) -> None: + with pytest.raises(ModelParseError): + parse_model(text) + + +def test_parse_error_carries_location() -> None: + with pytest.raises(ModelParseError) as excinfo: + parse_model("abstract M {\n [ a.b ]\n}") + assert "line 2" in str(excinfo.value) diff --git a/tests/variability/test_presence.py b/tests/variability/test_presence.py new file mode 100644 index 0000000..5ad8995 --- /dev/null +++ b/tests/variability/test_presence.py @@ -0,0 +1,79 @@ +"""Table-driven tests for the presence-condition algebra.""" + +import pytest + +from git_feature.domain.variability.ast import And, Not, Or, Ref +from git_feature.domain.variability.presence import ( + PresenceError, + UnknownFeatureError, + eval_presence, + lint_presence, + parse_presence, + satisfiable, +) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("auth", Ref("auth")), + ("!auth", Not(Ref("auth"))), + ("a & b", And(Ref("a"), Ref("b"))), + ("a && b", And(Ref("a"), Ref("b"))), + ("a | b", Or(Ref("a"), Ref("b"))), + ("a || b", Or(Ref("a"), Ref("b"))), + ("a & b | c", Or(And(Ref("a"), Ref("b")), Ref("c"))), # & binds tighter + ("a & (b | c)", And(Ref("a"), Or(Ref("b"), Ref("c")))), + ("!a & b", And(Not(Ref("a")), Ref("b"))), + ("!(a | b)", Not(Or(Ref("a"), Ref("b")))), + ("feature-x", Ref("feature-x")), + ], +) +def test_parse(text: str, expected: object) -> None: + assert parse_presence(text) == expected + + +@pytest.mark.parametrize( + "text", + ["", "a &", "& a", "a b", "(a", "a)", "a ? b", "a => b"], +) +def test_parse_errors(text: str) -> None: + with pytest.raises(PresenceError): + parse_presence(text) + + +@pytest.mark.parametrize( + ("text", "assignment", "expected"), + [ + ("auth", {"auth": True}, True), + ("auth", {"auth": False}, False), + ("!auth", {"auth": False}, True), + ("a & b", {"a": True, "b": False}, False), + ("a | b", {"a": True, "b": False}, True), + ("a & !b | c", {"a": True, "b": True, "c": True}, True), + ], +) +def test_eval(text: str, assignment: dict[str, bool], expected: bool) -> None: + assert eval_presence(parse_presence(text), assignment) is expected + + +def test_unknown_feature_is_an_error_not_false() -> None: + with pytest.raises(UnknownFeatureError) as excinfo: + eval_presence(parse_presence("auth & typo"), {"auth": True}) + assert excinfo.value.names == {"typo"} + + +def test_satisfiability() -> None: + assert satisfiable(parse_presence("a & b")) + assert not satisfiable(parse_presence("a & !a")) + assert satisfiable(parse_presence("a | !a")) + + +def test_lint_catches_unknown_and_unsatisfiable() -> None: + assert lint_presence("auth", ["auth"]) == [] + problems = lint_presence("auth & ghost", ["auth"]) + assert len(problems) == 1 and "ghost" in problems[0] + problems = lint_presence("a & !a", ["a"]) + assert len(problems) == 1 and "unsatisfiable" in problems[0] + problems = lint_presence("a &", ["a"]) + assert len(problems) == 1 diff --git a/tests/variability/test_source.py b/tests/variability/test_source.py new file mode 100644 index 0000000..40ce3ff --- /dev/null +++ b/tests/variability/test_source.py @@ -0,0 +1,36 @@ +"""Tests for reading model.cfr from worktree vs a commit tree.""" + +from collections.abc import Callable + +from git_feature.domain.variability import model_text, parse_model +from tests.fixtures import RepoBuilder + +OLD_MODEL = "abstract App {\n a ?\n}\n\nOnlyA : App {\n [ a ]\n}\n" +NEW_MODEL = "abstract App {\n a ?\n b ?\n}\n\nBoth : App {\n [ a ]\n [ b ]\n}\n" + + +def test_model_reads_from_worktree_and_from_a_commit( + repo_builder: Callable[[str], RepoBuilder], +) -> None: + builder = repo_builder("repo") + old_sha = builder.commit("old model", files={"model.cfr": OLD_MODEL}) + builder.commit("new model", files={"model.cfr": NEW_MODEL}) + builder.write("model.cfr", NEW_MODEL + "// dirty edit\n") + + worktree = model_text(builder.repo) + assert worktree is not None and "dirty edit" in worktree + + at_old = model_text(builder.repo, old_sha) + assert at_old is not None + module = parse_model(at_old) + assert [c.name for c in module.clafers] == ["App", "OnlyA"] + + at_head = model_text(builder.repo, "HEAD") + assert at_head is not None and "Both" in at_head + + +def test_missing_model_returns_none(repo_builder: Callable[[str], RepoBuilder]) -> None: + builder = repo_builder("bare") + builder.commit("no model here", files={"a.txt": "a\n"}) + assert model_text(builder.repo) is None + assert model_text(builder.repo, "HEAD") is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c3e4d23 --- /dev/null +++ b/uv.lock @@ -0,0 +1,759 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "git-feature" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "lark" }, + { name = "pydantic" }, + { name = "pygit2" }, + { name = "python-ulid" }, + { name = "typer" }, + { name = "xxhash" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "lark", specifier = ">=1.1" }, + { name = "pydantic", specifier = ">=2.7" }, + { name = "pygit2", specifier = ">=1.15" }, + { name = "python-ulid", specifier = ">=2.6" }, + { name = "typer", specifier = ">=0.15" }, + { name = "xxhash", specifier = ">=3.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.10" }, + { name = "pytest", specifier = ">=8.2" }, + { name = "ruff", specifier = ">=0.5" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pygit2" +version = "1.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/44/415aa93422b4bfc21a6448acb7e16280d5f33a9a3fae38a384e37b046ae4/pygit2-1.19.3.tar.gz", hash = "sha256:a543e6d4ebb43825564935758dc234e770016fed673b84370d46ae9580558831", size = 810489, upload-time = "2026-06-13T08:06:04.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/9c/9bc9a8d727b2ae8ae77306ecfb77a5dcf836da4997d4f7053c893f8e0d11/pygit2-1.19.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d696ee240cc8b84e747b04443d85768c859ef0b88abe4a3505dc0b8e2a953ca0", size = 5708797, upload-time = "2026-06-13T08:05:00.097Z" }, + { url = "https://files.pythonhosted.org/packages/63/7e/9099ea2f90791549185f8ac737a8b448e05cf3882cc79928a380be9bf38c/pygit2-1.19.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86318a704bef836067fa9ac06c56591738d548dcdf2ea24ec227257e3f8115f8", size = 5700295, upload-time = "2026-06-13T08:05:01.676Z" }, + { url = "https://files.pythonhosted.org/packages/39/84/e9610f041dc43699fca8af55050f9bf9fe0d340ab9902ab8e3fa67bece23/pygit2-1.19.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc3ddb3e5d16cf662d2d0faeb53259e31ae584cc990cd76df909beeb4a36736a", size = 6036822, upload-time = "2026-06-13T08:05:03.473Z" }, + { url = "https://files.pythonhosted.org/packages/64/bd/419ec17df3f1f1182de8c8fe800b698ff2a489cc50ec2d34990092bf3d61/pygit2-1.19.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73820fb34339fe3c52731d1c46c298a3239ee75b61bc3a2d9b45f8b8873acc47", size = 4638181, upload-time = "2026-06-13T08:05:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/90/5f/aecca9c7f4ffdc1facc4b2402ea40073848e67c8c607a5f0698e1e8a61e4/pygit2-1.19.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f6ad50608f51b053bf6c321ae2c31cc57f15e4131a4609261c44e29bdae7cb", size = 5799910, upload-time = "2026-06-13T08:05:06.48Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2a/e131af7752f75f70e04f7f3ae724481d0e2db9c6c688a2081ffb5948657e/pygit2-1.19.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b2c63c7a371dbd5825e5fb7069b759c79aa5dec97806d49cb2c9f9a8a373209d", size = 6042766, upload-time = "2026-06-13T08:05:08.052Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/978ee5233379b2aa725c290a7cc3baca9b141f4fc37d55d9a473631b529b/pygit2-1.19.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:977eb52cd4134ff60d3faa168327a03f8724278c38bfcd347d8e3a11041ee9cd", size = 5771434, upload-time = "2026-06-13T08:05:09.398Z" }, + { url = "https://files.pythonhosted.org/packages/26/33/7c506492fffe3e92d9e911fae43a4b4a3dee43682b71b7793d586c25acfc/pygit2-1.19.3-cp312-cp312-win32.whl", hash = "sha256:14a2734d793d2d937d3bfc9d35b280ec83fc97a91b1934b1013554d41e6d3d70", size = 945030, upload-time = "2026-06-13T08:05:11.187Z" }, + { url = "https://files.pythonhosted.org/packages/82/ff/57ad08d1e87ae6e4208f80923ce1a6ea8a03ea776376e39d64816637b168/pygit2-1.19.3-cp312-cp312-win_amd64.whl", hash = "sha256:a7caeaf46aaa8e51c512af9ac1377f388e4836c9d042e134bbc5e22519fbd1bf", size = 1254825, upload-time = "2026-06-13T08:05:12.315Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/476c217a4ab3c9b4abf2506fb1135e54b19d205ce7ddecb1d961bf93a9dc/pygit2-1.19.3-cp312-cp312-win_arm64.whl", hash = "sha256:2aafa010e3ac227913f398c8a680bce419e396dfe651685fffb779ed1c109a00", size = 969650, upload-time = "2026-06-13T08:05:13.438Z" }, + { url = "https://files.pythonhosted.org/packages/7f/12/1e0f0fc54a24ddfdabb0da0bafb3237c82b5628a8c5258ba66fb8d39b5b2/pygit2-1.19.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9238bf7a8a953f4faedcbe90eb097b760a2709c7e7ab38edcf18dc2d0ce9dcff", size = 5708790, upload-time = "2026-06-13T08:05:14.755Z" }, + { url = "https://files.pythonhosted.org/packages/82/12/9093b7b81de7fe869f6ae51319a05b9f0a8b2567674e0d61fd54aacb833f/pygit2-1.19.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f999a4995504a6c14443de9481b7c6c54802663fe80c7504e1b946348f43786", size = 5700256, upload-time = "2026-06-13T08:05:16.363Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/a5133f79cf86e8a0cadf9f424cdd5b912ba47410c7254c0237b6e5290ddb/pygit2-1.19.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0918bc9b7597f243aa4b9c785912ae853e9aee4f572bcd5c9c591fcb703542f7", size = 6037556, upload-time = "2026-06-13T08:05:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fc/3aa049d88eaa51f14f1fbfc0b70ac4c2362d3306dbeb8d1e5e3ca301e1dc/pygit2-1.19.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18403fee0171867be5fc1c1415bcd5d5a7fab51299bdd44bcf467c22a868fd22", size = 4638864, upload-time = "2026-06-13T08:05:19.325Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b7/5e772367db14f800048fa2a0981abd45baae6dcd8b83453da6ac0f92cead/pygit2-1.19.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cacece712337029092a98cf1af8d244e92fa8b36e5a64d7e14212ff73ea821e8", size = 5801146, upload-time = "2026-06-13T08:05:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/07/79/075b37b9a971cc7df9721add0e03cded83cab9e7d9736a80820357b2a972/pygit2-1.19.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49d7c494bdaa5a8da366e4c4b7c822e52bd33bb6f22c67f8585b5396e91f8ac7", size = 6043951, upload-time = "2026-06-13T08:05:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/94/76/72e13c487d013f21f97690f0662cb03533d91aad39e0c7c4c8cd3e4453d0/pygit2-1.19.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2bbe74fbe553c6f991512391822c27c9321e3a0b45cab872f45c8d458393eee3", size = 5772767, upload-time = "2026-06-13T08:05:23.487Z" }, + { url = "https://files.pythonhosted.org/packages/a1/24/5ba37f80fcb303bcf1a3813631e5b5dddfbac771cce7efc0b03428f4fa2e/pygit2-1.19.3-cp313-cp313-win32.whl", hash = "sha256:3ab184751ca7a92007dbd8ae4ae4ac3b7228ef60ae2d2e18129b44bbe5c0aa4c", size = 945026, upload-time = "2026-06-13T08:05:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/ba/48/4ae19f2e4dc8ff2ddc2fd64de7fd1d26aeb2b453069d6c9ee251dd567983/pygit2-1.19.3-cp313-cp313-win_amd64.whl", hash = "sha256:522d9c6ab9206880dceaae5516720c0f0df706859d2ba97f49adcaeea2d661bd", size = 1254793, upload-time = "2026-06-13T08:05:26.174Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7b/473ca9e35dfd82c758b700a6cf4ecc107b849674e91ab219383063ebb12f/pygit2-1.19.3-cp313-cp313-win_arm64.whl", hash = "sha256:c77d5a1334ca3f1432c83457e03be8759d8e9040e0d25c59c738f2318545654a", size = 969666, upload-time = "2026-06-13T08:05:27.351Z" }, + { url = "https://files.pythonhosted.org/packages/99/15/ec8a777b497853edab4e150c9ea46019b6c709b3c2b06fd06454a1aaa8c6/pygit2-1.19.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:214140f2201474e9d8e3889e5635e323aa90a58e6ae224a9de56be83d3e9b747", size = 5710849, upload-time = "2026-06-13T08:05:28.71Z" }, + { url = "https://files.pythonhosted.org/packages/fa/be/a8c449fba4c70239d6e9d0023b1fdd783c2dbb52872fef9c4de1652e930a/pygit2-1.19.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:46fc9fd234b2a38176625af6bfd2dbad99ab937379a980761c59a343bdff4910", size = 5700188, upload-time = "2026-06-13T08:05:30.466Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/8e92b4008324137878e9f9e130b4e349956d2a2b719521b8ce45aad6a0de/pygit2-1.19.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb9f46b561ee53f22cb479e7fdefd7b8ae478d12656436a48166c2ad81a3c021", size = 6039510, upload-time = "2026-06-13T08:05:31.892Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/baf3a0944f54acc27ceaa22b47ca52cd64c8bb1ec69473f80d3f4bafe54d/pygit2-1.19.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9f393932fadd8e74f26d7ebb58da649e7a188d9156b732dd6c9f0dfc273d4430", size = 4643029, upload-time = "2026-06-13T08:05:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/69/1b/0b045e74fe01b29af28a5d54a9c89693db97e7e6cbd700f8086f4294e85e/pygit2-1.19.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9521a9b45de3affd82618e94f7e5116685e695e65fcbf67b2f1e44a086c0f4a", size = 5803099, upload-time = "2026-06-13T08:05:34.873Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/0d4eb93b7088316b9885b9c29b6a971009a20bf93aebd5762d3b809d72bf/pygit2-1.19.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:241f0fcd2aaf025e9fb5e1887eac5f244cb6b0a6e83dc27d673fa634ab0e0ee3", size = 6044603, upload-time = "2026-06-13T08:05:36.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/5a486a8aa9a1aa6a1ed5189b71b1b96e92bd164cc3e4d010e285032a2662/pygit2-1.19.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1729caee68cdf35c0cdb913de3850724438e4bdf480742da4f21a3e86d83662", size = 5773603, upload-time = "2026-06-13T08:05:37.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/ff/e6a5da1fe3b104fc153160bfe56a85298a6c81b5411ee8de97e4039d3a2f/pygit2-1.19.3-cp314-cp314-win32.whl", hash = "sha256:2c300d234ef15e4c557a7e9ac880163606b364d0d4e20ddbf1204b4ee9f9b61c", size = 965699, upload-time = "2026-06-13T08:05:39.151Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a1/a8f21afe574f22fde3d04fb102c4e75b5a1077fa1ea1ee3f7b072aa8f858/pygit2-1.19.3-cp314-cp314-win_amd64.whl", hash = "sha256:0ff9f187b01d6629c14ad069b100e147093b70279496984faa012783c5832b66", size = 1288277, upload-time = "2026-06-13T08:05:40.315Z" }, + { url = "https://files.pythonhosted.org/packages/95/65/f5268ac09c91f25e349a658cd9722b3528b1c4d6b18aab7472132e067912/pygit2-1.19.3-cp314-cp314-win_arm64.whl", hash = "sha256:cdfbf879ee0d468d7929eb26e35db7442b56a411af1d5b7d28e858cccf71598a", size = 997247, upload-time = "2026-06-13T08:05:41.629Z" }, + { url = "https://files.pythonhosted.org/packages/e9/98/9825e08b78a58bc99e979e322b8c776e6a4b49dcebf68471b650bf97e1ed/pygit2-1.19.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79a3b565e20bbbbf21d29de1675a18e0e556545a2c738f38c0ac0435269f56bd", size = 5714170, upload-time = "2026-06-13T08:05:43.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/00/8836f806512ac0a9e8613b2b446a0fe2578834c01c2234ca6d6f2abd6751/pygit2-1.19.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:828856512070cda223c19046b02af50135a5b0efa274a80fbf7c82d773f8e89f", size = 5703231, upload-time = "2026-06-13T08:05:44.997Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9b/117fcbd26d30729e9f46889747823e51cb5597d191c100da298faa79bb90/pygit2-1.19.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eec2b1d50bf52d2139d3d478d6d5f5ee56103da4a6dbd786344e8a85254dd07b", size = 6097836, upload-time = "2026-06-13T08:05:46.269Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/c1fa28b4e3064ef0e06e49af3cbcbf50b62f36fab3f9832c15cd46d0a175/pygit2-1.19.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbfa10f22670e7fef13bf249c32aebcafe23115790155d6b71efafb9aa7d4cba", size = 4697771, upload-time = "2026-06-13T08:05:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c5/1d72a285fe794e2ce87ebe4b107c7cc713917e6e62b24264580b966740ea/pygit2-1.19.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d22c2940bde27f9ffe095aac87e7c8b8a9c787f91782c7463387d39a2592d04c", size = 5854885, upload-time = "2026-06-13T08:05:48.988Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/250da1cff67e38af607792ed98a49c64b5980d5332d5f26c1882f722cefe/pygit2-1.19.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ed9f15b4b0b80191669fdf5b702b697ffbc245d24995befa80e46bef3aea25e", size = 6100802, upload-time = "2026-06-13T08:05:50.395Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/1208276e01868b7ec1eb4c108dedabee114e24809eecf08820f9a52c3435/pygit2-1.19.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:40f1253e042d0a5dfed16eec92b5b28a529c2873f4ee77ca60d1ea7b79a14600", size = 5824124, upload-time = "2026-06-13T08:05:51.787Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/04d44e2ff972a8a7846d48ae288f717a058331d6e056a652cafbcd7f6867/pygit2-1.19.3-cp314-cp314t-win32.whl", hash = "sha256:245d627a8ae3f35dafd9ce5b35538ddb5ba0c3fe6be8d2f9aa410f93a9184b83", size = 968784, upload-time = "2026-06-13T08:05:53.107Z" }, + { url = "https://files.pythonhosted.org/packages/40/89/d42e745838fa0d9072bf425adf5b590b521b7a196d3b92f40951af9e4514/pygit2-1.19.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8d2e3044ac1b09ef9120deb1896a56423c6dd17eb5ac4012c3c49dd590ee4910", size = 1291338, upload-time = "2026-06-13T08:05:54.546Z" }, + { url = "https://files.pythonhosted.org/packages/cf/10/61513d337a9241e9ce54edfd8fc1d8f2b0f36cbfa37de227ded0ffdbdc96/pygit2-1.19.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ef2790c9ff7e7673d7262e9e059c5af86c09197037073fd004d22a63e4151291", size = 998330, upload-time = "2026-06-13T08:05:55.888Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-ulid" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/7e/0d6c82b5ccc71e7c833aed43d9e8468e1f2ff0be1b3f657a6fcafbb8433d/python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636", size = 93175, upload-time = "2025-08-18T16:09:26.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "xxhash" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc", size = 34680, upload-time = "2026-07-06T10:44:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215", size = 32397, upload-time = "2026-07-06T10:44:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478", size = 220549, upload-time = "2026-07-06T10:44:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc", size = 241186, upload-time = "2026-07-06T10:44:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8", size = 264852, upload-time = "2026-07-06T10:44:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56", size = 242663, upload-time = "2026-07-06T10:44:48.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170", size = 473510, upload-time = "2026-07-06T10:44:50.695Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc", size = 220469, upload-time = "2026-07-06T10:44:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c", size = 310290, upload-time = "2026-07-06T10:44:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d", size = 238173, upload-time = "2026-07-06T10:44:55.693Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb", size = 269026, upload-time = "2026-07-06T10:44:57.424Z" }, + { url = "https://files.pythonhosted.org/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061", size = 224970, upload-time = "2026-07-06T10:44:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887", size = 240820, upload-time = "2026-07-06T10:45:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e", size = 300619, upload-time = "2026-07-06T10:45:02.716Z" }, + { url = "https://files.pythonhosted.org/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975", size = 443267, upload-time = "2026-07-06T10:45:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a", size = 217338, upload-time = "2026-07-06T10:45:06.304Z" }, + { url = "https://files.pythonhosted.org/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e", size = 31970, upload-time = "2026-07-06T10:45:07.823Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724", size = 32741, upload-time = "2026-07-06T10:45:09.42Z" }, + { url = "https://files.pythonhosted.org/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396", size = 29234, upload-time = "2026-07-06T10:45:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, + { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, + { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, + { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, + { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, + { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, + { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, + { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba", size = 38430, upload-time = "2026-07-06T10:46:31.496Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17", size = 36558, upload-time = "2026-07-06T10:46:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd", size = 31181, upload-time = "2026-07-06T10:46:34.793Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5", size = 32192, upload-time = "2026-07-06T10:46:36.393Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf", size = 34691, upload-time = "2026-07-06T10:46:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9", size = 34807, upload-time = "2026-07-06T10:46:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711", size = 32410, upload-time = "2026-07-06T10:46:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920", size = 220564, upload-time = "2026-07-06T10:46:43.463Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc", size = 241462, upload-time = "2026-07-06T10:46:45.509Z" }, + { url = "https://files.pythonhosted.org/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62", size = 264491, upload-time = "2026-07-06T10:46:47.655Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f", size = 242905, upload-time = "2026-07-06T10:46:49.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e", size = 473873, upload-time = "2026-07-06T10:46:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b", size = 220765, upload-time = "2026-07-06T10:46:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d", size = 310478, upload-time = "2026-07-06T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b", size = 238393, upload-time = "2026-07-06T10:46:59.87Z" }, + { url = "https://files.pythonhosted.org/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc", size = 268704, upload-time = "2026-07-06T10:47:01.806Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f", size = 225015, upload-time = "2026-07-06T10:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1", size = 240951, upload-time = "2026-07-06T10:47:05.816Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e", size = 300751, upload-time = "2026-07-06T10:47:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231", size = 443480, upload-time = "2026-07-06T10:47:10Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe", size = 217657, upload-time = "2026-07-06T10:47:12.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab", size = 32690, upload-time = "2026-07-06T10:47:14.204Z" }, + { url = "https://files.pythonhosted.org/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f", size = 33460, upload-time = "2026-07-06T10:47:16.021Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5", size = 30092, upload-time = "2026-07-06T10:47:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c", size = 35024, upload-time = "2026-07-06T10:47:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230", size = 32697, upload-time = "2026-07-06T10:47:21.649Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489", size = 226044, upload-time = "2026-07-06T10:47:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5", size = 249899, upload-time = "2026-07-06T10:47:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e", size = 274892, upload-time = "2026-07-06T10:47:27.931Z" }, + { url = "https://files.pythonhosted.org/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6", size = 252243, upload-time = "2026-07-06T10:47:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339", size = 482191, upload-time = "2026-07-06T10:47:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542", size = 226877, upload-time = "2026-07-06T10:47:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08", size = 319794, upload-time = "2026-07-06T10:47:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775", size = 246202, upload-time = "2026-07-06T10:47:39.676Z" }, + { url = "https://files.pythonhosted.org/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8", size = 275628, upload-time = "2026-07-06T10:47:41.99Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e", size = 231390, upload-time = "2026-07-06T10:47:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99", size = 250600, upload-time = "2026-07-06T10:47:47.611Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0", size = 310032, upload-time = "2026-07-06T10:47:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07", size = 448882, upload-time = "2026-07-06T10:47:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799", size = 223728, upload-time = "2026-07-06T10:47:55.093Z" }, + { url = "https://files.pythonhosted.org/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497", size = 33150, upload-time = "2026-07-06T10:47:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684", size = 34005, upload-time = "2026-07-06T10:47:59.434Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" }, +]