diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..29c2824 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install + run: uv sync --python 3.11 + + - name: Ruff + run: uv run ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b8d8efe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +# Publishes to PyPI when a version tag such as v0.2.0 is pushed. +# Needs a PYPI_API_TOKEN secret (Settings > Secrets and variables > Actions). +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: read + +jobs: + check: + name: Check version and tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Tag must match the package version + shell: bash + run: | + tag="${GITHUB_REF_NAME#v}" + project=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + module=$(python3 -c "import re; print(re.search(r'__version__ = \"(.+?)\"', open('src/commitrocket/__init__.py').read()).group(1))") + echo "tag=$tag pyproject=$project __init__=$module" + if [ "$tag" != "$project" ] || [ "$tag" != "$module" ]; then + echo "::error::Tag v$tag must match the version in pyproject.toml ($project) and src/commitrocket/__init__.py ($module)." + exit 1 + fi + + - name: Install + run: uv sync --python 3.11 + + - name: Lint + run: uv run ruff check . + + - name: Unit tests + run: uv run pytest -m "not integration" + + - name: Integration tests + run: uv run pytest -m integration + + publish: + name: Build and publish + needs: check + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Build + run: uv build + + - name: Publish to PyPI + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: uv publish + + - name: GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: dist/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..70ff4cd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: ${{ matrix.os }} / Python ${{ matrix.python }} + runs-on: ${{ matrix.os }} + # Python 3.15 may still be a pre-release; don't let it block the build. + continue-on-error: ${{ matrix.python == '3.15' }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, python: "3.11" } + - { os: ubuntu-latest, python: "3.12" } + - { os: ubuntu-latest, python: "3.13" } + - { os: ubuntu-latest, python: "3.14" } + - { os: ubuntu-latest, python: "3.15" } + - { os: windows-latest, python: "3.11" } + - { os: macos-latest, python: "3.11" } + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install + run: uv sync --python ${{ matrix.python }} + + - name: Unit tests + run: uv run pytest -m "not integration" -n auto + + - name: Integration tests + run: uv run pytest -m integration -n auto diff --git a/.gitignore b/.gitignore index 6f596e1..7a58a33 100644 --- a/.gitignore +++ b/.gitignore @@ -218,3 +218,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +CLAUDE.md +.temp/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cc323f8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +# Install once with: uv run pre-commit install +# Integration tests (marker "integration") are slower and run only in CI. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.17.2 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + + - repo: local + hooks: + - id: pytest + name: pytest (unit tests) + entry: uv run pytest -m "not integration" -n auto -q + language: system + types: [python] + pass_filenames: false diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..716ac5d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +## [Unreleased] + +## [0.1.0] - 2026-10-24 + +### Added +- CommitRocket is now an installable package with a `commitrocket` command. +- Files over 100 MiB are detected up front, and the run stops with a hint to use `--use-lfs` instead of failing at push time. +- The overall progress bar shows an ETA based on the average speed of the whole run so far, updating live during each upload. +- `--use-lfs` stores files over 100 MiB with Git LFS during the run, without committing any LFS config, and removes the local setup afterwards. +- `--report FILE` writes a machine-readable JSON summary of every run. +- `--notify` and `--notify-webhook` tell you when a run ends, via a desktop notification or a web hook. +- `commitrocket doctor` checks Git, git-lfs, your settings, the remote and the repository before a run. +- Settings can be saved in `commitrocket.toml` or `[tool.commitrocket]` in `pyproject.toml`. +- `--include`, `--exclude`, `--include-from`, `--exclude-from` and `.commitrocketignore` choose which files are handled, using `.gitignore`-style patterns. +- `--remote` and `--branch` choose where to push, `--no-push` only commits, and `--message-template` customises commit messages. +- `--lfs-commit-rules` commits LFS rules per file type so clones get the real files. +- A failed `--use-lfs` run now keeps the LFS hooks until its commits are pushed, so LFS files are never published as bare pointers. +- `--repo` now defaults to the current folder. +- A `--log-file` or `--report` inside the repository is no longer committed, which previously made a run commit its own log forever. +- `--dry-run` shows the planned batches and commit messages without changing anything. +- Failed pushes are retried with growing waits (`--retries`, default 3); rejections are not retried. +- Interrupted or failed runs can be resumed by running the same command again. +- `--post-buffer-mib` raises Git's HTTP buffer for large pushes over HTTPS. +- Each push is checked against the remote, and LFS files are confirmed as pointers before upload. +- `--lfs-threshold-mib` sets the size above which `--use-lfs` applies (default 100); `0` sends every file through LFS. +- Each run writes a log file to the system's standard log folder; `--level` sets the detail and `--log-file` overrides the location. +- `--batch-type` chooses how files are grouped: `deterministic` (default, unchanged), `sequential` or `soft`. +- `--compression-threads`, `--lfs-threads` and `--lfs-batch-mib` speed up pushes; the thread settings apply to the repository only and are restored afterwards. +- Leftover local commits containing files over 100 MiB are detected at startup, with the command to clean them up. diff --git a/README.md b/README.md index 0f5b7b4..6fc5396 100644 --- a/README.md +++ b/README.md @@ -1 +1,425 @@ -# CommitRocket \ No newline at end of file +# CommitRocket + +Got a huge pile of changes that a single push would choke on? CommitRocket splits them +into size-limited batches, commits each batch, and pushes as it goes, with live progress. + +## Install + +``` +pip install commitrocket +``` + +Requires Python 3.11+ and [Git](https://git-scm.com/). + +## Use + +``` +commitrocket --repo path/to/repo --message "Add assets" +``` + +Each batch is committed as `Add assets #1`, `Add assets #2`, and so on, and pushed +right away. + +Before it starts, CommitRocket checks that you are on a branch with an upstream and have +nothing staged, then asks for confirmation. + +| Option | What it does | +| --- | --- | +| `--repo` | The Git repository to work on (default: the current folder) | +| `--message` | Commit message used for every batch (required, here or in a config file) | +| `--message-template` | Shape of each commit message (default `{message} #{n}`, see below) | +| `--number` | Number to start the `#` suffix from (default 1) | +| `--config` | Read settings from this TOML file (see "Config file") | +| `--remote` / `--branch` | Push to this remote / remote branch instead of the upstream | +| `--no-push` | Commit every batch but don't push them | +| `--include` / `--exclude` | Only handle / leave alone files matching a pattern (repeatable) | +| `--include-from` / `--exclude-from` | Read those patterns from a file | +| `--limit-mib` | Target size of each batch in MiB (default 100) | +| `--batch-type` | How files are picked: `deterministic` (default), `sequential` or `soft` (see below) | +| `--dry-run` | Show the planned batches and commit messages without changing anything | +| `--retries` | Retry a failed push this many times with growing waits (default 3, `0` = never) | +| `--post-buffer-mib` | Raise Git's `http.postBuffer` for this run, for large pushes over HTTPS | +| `--max-mib` | Largest single file allowed, in MiB (default 3x the limit) | +| `--force` | Skip the confirmation prompt | +| `--compression-threads` | Threads Git uses to compress data while pushing (0 = automatic) | +| `--lfs-threads` | How many Git LFS files upload at the same time | +| `--lfs-batch-mib` | Total size of LFS files pushed together (default: same as `--limit-mib`) | +| `--level` | Detail written to the log file: `DEBUG`, `INFO` (default), `WARNING` or `ERROR` | +| `--log-file` | Write the log to this file instead of the default folder | +| `--lfs-threshold-mib` | With `--use-lfs`, files larger than this use LFS (default 100, `0` = every file) | +| `--use-lfs` | Store files over 100 MiB with Git LFS for this run (see below) | +| `--lfs-commit-rules` | With `--use-lfs`, also commit LFS rules so clones get the real files | +| `--report` | Write a JSON summary of the run to this file | +| `--notify` | Show a desktop notification when the run ends | +| `--notify-webhook` | Send a JSON summary to this web address when the run ends | + +There is also `commitrocket doctor`, which checks your setup (see "Check your setup"). + +### Choosing which files to handle + +`--include` and `--exclude` take `.gitignore`-style patterns and can be repeated: + +| Pattern | Matches | +| --- | --- | +| `*.mp4` | Every `.mp4` file, in any folder (this is how you select by extension) | +| `raw/` | The `raw` folder and everything in it | +| `/notes.txt` | Only `notes.txt` in the repository root | +| `videos/**/*.mov` | `.mov` files anywhere under `videos` | +| `!keep.mp4` | Re-includes a file matched by an earlier pattern in the same list | + +With `--include`, only matching files are handled. Anything matching `--exclude` is left +alone, and exclusion always wins. Files that are filtered out are not staged, committed or +counted as skipped; they simply stay pending. + +For longer lists, put one pattern per line in a file (blank lines and `#` comments are +ignored) and pass it with `--include-from` or `--exclude-from`. A file named +`.commitrocketignore` in the repository root is read automatically as a permanent list of +excludes. + +``` +commitrocket --repo . --message "Videos" --include "*.mp4" --include "*.mov" --exclude "raw/" +commitrocket --repo . --message "Videos" --include-from patterns.txt +``` + +### Config file + +Save your usual options so you don't have to retype them. CommitRocket reads +`commitrocket.toml` in the repository folder, or else the `[tool.commitrocket]` table of +`pyproject.toml`; `--config FILE` points at a different file. Option names are the same as +on the command line, with `-` or `_`. Options typed on the command line win over the file, +and `--include`/`--exclude` lists are combined. + +```toml +# commitrocket.toml +message = "Videos" +use-lfs = true +limit-mib = 50 +batch-type = "sequential" +exclude = ["raw/", "*.tmp"] +message-template = "{message} #{n} ({files} files, {size:.0f} MiB)" +``` + +```toml +# or in pyproject.toml +[tool.commitrocket] +message = "Videos" +retries = 5 +``` + +### Commit message templates + +`--message-template` decides the message of each batch. The default is `{message} #{n}`. + +| Placeholder | Value | +| --- | --- | +| `{message}` | The `--message` text | +| `{n}` | The commit number (starts at `--number`) | +| `{batch}` | The batch's position in this run, starting at 1 | +| `{files}` | Number of files in the batch | +| `{size}` | Size of the batch in MiB (e.g. `{size:.1f}`) | +| `{first}` | Path of the first file in the batch | +| `{date}` | Today's date, `YYYY-MM-DD` | + +``` +commitrocket --repo . --message "Course" --message-template "{message} part {n}: {files} files ({size:.0f} MiB)" +``` + +### Pushing somewhere else, or not at all + +By default each batch is pushed to the branch's upstream. `--remote` and `--branch` choose +another destination, for example a backup remote or a different branch; HEAD is pushed to +that remote branch. With `--no-push` the batches are committed locally and a later run +pushes them (the "interrupted runs" resume does this). `--no-push` can't be combined with +`--use-lfs`, because the large files would stay on your machine while a later plain +`git push` published pointers without them. + +### Report and notifications + +`--report out.json` writes a machine-readable summary every time, including when the run +fails, is interrupted or is a dry run, so scripts and CI can read the result: + +```json +{ + "schema_version": 1, + "status": "success", + "exit_code": 0, + "target": "origin/main", + "duration_seconds": 412.7, + "totals": { "batches": 3, "files": 42, "size_bytes": 1234567890, "deferred_files": 0 }, + "batches": [ + { "number": 1, "message": "Videos #1", "commit": "e57cffb...", "file_count": 14, + "size_bytes": 412345678, "lfs_file_count": 2, "pushed": true, "verified": true, + "files": ["..."] } + ], + "deferred": [], + "error": null, + "log_file": "..." +} +``` + +`status` is one of `success`, `completed_with_deferred_files`, `dry_run`, `cancelled`, +`aborted` (stopped before doing anything, for example files needing `--use-lfs`), +`interrupted` and `failed`. The report also lists your settings, what was resumed, the LFS +files and the commit that holds the LFS rules. It never contains the webhook address. + +Long runs can tell you when they are done: + +| Option | What happens | +| --- | --- | +| `--notify` | A desktop notification: a toast on Windows, a notification on macOS, `notify-send` on Linux. If none of these works (for example over SSH), the terminal bell sounds instead. | +| `--notify-webhook URL` | A JSON message is POSTed to the address. It has `text` and `content` fields, so Slack, Teams and Discord incoming webhooks work, plus `status`, `batches`, `files` and `duration_seconds` for your own endpoint. [ntfy](https://ntfy.sh) also works. | + +Notifications are sent when the run ends, whether it succeeded or not, and never for +`--dry-run`. A notification that can't be delivered never changes the run's result. + +### Check your setup + +``` +commitrocket doctor +commitrocket doctor --repo path/to/repo --remote backup --offline +``` + +Checks Git and git-lfs, your config file, the log folder, the repository, branch and push +target, whether the remote is reachable and accepts a push (a dry run), staged and +unpushed files, and pending files over 100 MiB. It changes nothing. Use `--offline` to skip +the checks that contact the remote. The exit code is 1 if any check fails. + +### Examples + +Run these from inside the repository (`--repo .`), or point `--repo` at any path. + +**Push everything in 100 MiB batches (the default)** +``` +commitrocket --repo . --message "Add assets" +``` + +**Leave it running and get told when it's done** +``` +commitrocket --repo . --message "Videos" --use-lfs --notify +commitrocket --repo . --message "Videos" --notify-webhook https://ntfy.sh/my-topic +``` + +**Results for scripts and CI** +``` +commitrocket --repo . --message "Nightly" --force --report result.json +``` + +**Check everything is ready** +``` +commitrocket doctor +``` + +**Only some files, or everything except some** +``` +commitrocket --repo . --message "Videos" --include "*.mp4" --exclude "raw/" +``` + +**Push to a different remote or branch** +``` +commitrocket --repo . --message "Backup" --remote backup --branch main +``` + +**Commit now, push later** +``` +commitrocket --repo . --message "Add assets" --no-push +commitrocket --repo . --message "Add assets" # later: pushes the saved commits +``` + +**Videos in LFS that clones can download too** +``` +commitrocket --repo . --message "Videos" --use-lfs --lfs-commit-rules +``` + +**See what would happen first** + +Prints every batch, its commit message and size, and any files that would be skipped. +Nothing is staged, committed, pushed or installed. +``` +commitrocket --repo . --message "Add assets" --use-lfs --dry-run +``` + +**Flaky connection or "RPC failed" errors** + +Failed pushes are retried automatically (3 times by default, after 5, 15 and 45 +seconds). A bigger HTTP buffer helps with timeouts on large uploads over HTTPS. +``` +commitrocket --repo . --message "Videos" --retries 5 --post-buffer-mib 500 +``` + +**Smaller batches for a slow or unreliable connection** + +Each push is shorter, so a failure costs less to repeat. +``` +commitrocket --repo . --message "Add assets" --limit-mib 50 +``` + +**Continue numbering after an earlier run** + +If the last commit was `Videos #21`, the next batch becomes `Videos #22`. +``` +commitrocket --repo . --message "Videos" --number 22 +``` + +**Run without prompts (scripts, scheduled jobs)** +``` +commitrocket --repo . --message "Nightly sync" --force +``` + +**Keep files in order, such as numbered lectures** + +`sequential` never skips ahead, so `01`, `02`, `03` land in commits in that order. +``` +commitrocket --repo . --message "Course videos" --batch-type sequential +``` + +**Fewer, larger pushes** + +`soft` lets a batch run slightly past the limit instead of leaving gaps. +``` +commitrocket --repo . --message "Add assets" --batch-type soft --limit-mib 100 --max-mib 300 +``` + +**Files over 100 MiB (videos, archives, datasets)** + +GitHub rejects these, so use LFS. Without `--use-lfs`, CommitRocket stops before +committing anything and lists the files. +``` +commitrocket --repo . --message "Videos" --use-lfs +``` + +**Many large files, faster uploads** + +Group several LFS files per push and upload four at a time. +``` +commitrocket --repo . --message "Videos" --use-lfs --lfs-batch-mib 500 --lfs-threads 4 +``` + +**Also move mid-size files (over 50 MiB) to LFS** + +GitHub warns about files above 50 MiB, even though it accepts them. +``` +commitrocket --repo . --message "Assets" --use-lfs --lfs-threshold-mib 50 +``` + +**Send every file through LFS** + +Use with care: clones will show pointer files for all content. +``` +commitrocket --repo . --message "Archive" --use-lfs --lfs-threshold-mib 0 +``` + +**Allow bigger individual files** + +Files above `--max-mib` (default 3x the limit) are skipped and reported at the end. +``` +commitrocket --repo . --message "Videos" --use-lfs --max-mib 500 +``` + +**Find out why a push failed** + +The log records every Git command and the push output. Its path is printed at the end. +``` +commitrocket --repo . --message "Add assets" --level DEBUG +commitrocket --repo . --message "Add assets" --level DEBUG --log-file push-debug.log +``` + +### Logs + +Every run writes its own log file, and the path is shown at the end of the run. Logs are +kept here by default: + +| System | Folder | +| --- | --- | +| Windows | `%LOCALAPPDATA%\CommitRocket\Logs\` | +| macOS | `~/Library/Logs/CommitRocket/` | +| Linux | `~/.local/state/commitrocket/` (or `$XDG_STATE_HOME/commitrocket/`) | + +Files are named `commitrocket-YYYYMMDD-HHMMSS.log`. Use `--level DEBUG` to also record +every Git command and the push output, which helps when a push fails. Old logs are not +deleted automatically. + +### Batch types + +Files are always taken in alphabetical order. For files A=60, B=50, C=30, D=20 MiB with a +100 MiB limit: + +| Type | How it picks | Batches | +| --- | --- | --- | +| `deterministic` (default) | Fills each batch, skipping a file that doesn't fit and trying later ones | A + C, then B + D | +| `sequential` | Strict order; a batch ends at the first file that doesn't fit | A, then B + C + D | +| `soft` | Strict order; keeps adding until the batch reaches or passes the limit, never above `--max-mib` in total | A + B, then C + D | + +Files larger than the batch limit (but under the maximum) get a batch of their own. +Files over the maximum are skipped and listed at the end so you can decide what to do. + +### Large files with Git LFS + +Hosts like GitHub reject files over 100 MiB. With `--use-lfs`, CommitRocket stores those +files with Git LFS instead: + +1. Installs git-lfs if it is missing (asks first, unless `--force`). +2. Routes each file over 100 MiB through LFS using a rule that only exists on your + machine. Nothing extra is committed to your repository. +3. Pushes your files in batches as usual. +4. Removes the local LFS setup when it finishes, even if the run fails. + +The large files are stored in LFS on the remote. Because no tracking rules are committed, +a fresh clone will show small pointer files for them until LFS tracking is set up there. + +By default only files over 100 MiB use LFS. Lower the cut-off with `--lfs-threshold-mib` +(for example `50`), or use `0` to send every file through LFS. Take care with `0`: because +no tracking rules are committed, anyone cloning the repository will see small pointer +files in place of all your content, and LFS storage and bandwidth quotas on the host are +used up quickly. Git's own settings files (`.gitattributes`, `.gitignore`, `.lfsconfig`) +always stay as normal files. The threshold cannot be above 100, since larger files are +rejected by hosts. + +To let clones receive the real files, add `--lfs-commit-rules`. CommitRocket then commits +one rule per file type (for example `*.mp4`) to `.gitattributes` as ` (LFS rules)`, +pushes it first, and your files follow as LFS pointers. Because rules are per type, every +file of that type is stored in LFS from then on, including small ones and future ones. +Files without an extension get an exact-path rule. `.gitattributes` must have no +uncommitted changes beforehand. + +If a run fails after committing LFS files but before pushing them, the LFS hooks are left +installed so the files can still be uploaded. Run CommitRocket again to finish; avoid a +plain `git push` unless the hooks are still installed. + +Each LFS file normally gets its own push. To upload several at once, raise +`--lfs-batch-mib` (for example `--lfs-batch-mib 500 --lfs-threads 4`). + +The thread settings only change the repository's own Git configuration for the duration +of the run and are put back afterwards. Your global Git configuration is never touched. + +If you run CommitRocket without `--use-lfs` and a file is over 100 MiB, it stops before +committing anything and tells you to use the flag. + +### Interrupted or failed runs + +If a run stops, whether it failed or you pressed Ctrl+C, just run the same command again. +CommitRocket picks up where it stopped: + +- Commits that were made but not pushed are pushed first (they are listed in the plan, and + the usual confirmation applies). +- Files left staged by the interrupted batch are recognised as its own and committed with + their original message. Files you staged yourself still stop the run. +- Numbering continues after the interrupted batch. + +### Checks after each push + +After every push CommitRocket confirms that the remote branch now points at your latest +commit, and stops with an error if it does not. With LFS, it also confirms right after each +commit that the large files really were turned into LFS pointers, before uploading them, so +a misconfiguration is caught before a doomed upload. + +### Commits left over from a failed push + +If an earlier run left local commits that contain files over 100 MiB, GitHub will keep +rejecting them. CommitRocket detects this at the start, changes nothing, and shows the +command to drop those commits while keeping your files: + +``` +git reset --mixed origin/main +``` + +Then run the same CommitRocket command again. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ca99e0b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,165 @@ +# Architecture + +Source lives in `src/commitrocket/`: + +| Module | Responsibility | +| --- | --- | +| `cli.py` | argparse, option validation, builds the `Console`, calls `runner.run` | +| `runner.py` | Preflight checks, confirmation, and the batch → commit → push loop; returns an exit code | +| `batching.py` | `select_batch` / `estimate_batch_count` (size-based packing) | +| `git.py` | `run_git` wrapper and pending/staged file helpers | +| `push.py` | Streams `git push --progress`; `parse_progress_line` is pure and unit tested | +| `ui.py` | Rich progress bar, run-info panel, final summary | +| `lfs.py` | `--use-lfs`: candidate detection (> 100 MiB), git-lfs install, `LfsSession` (enable / revert / uninstall) | +| `errors.py` | `CommitRocketError`, `GitError` | + +The `Console` is created in `cli.py` and passed down, so the logic is usable without a +global. + +Exit codes: `0` success, `1` error or cancelled, `2` bad repo path or deferred files +remain, `130` interrupted. + +## LFS flow (`--use-lfs`) + +1. Pending files above `--lfs-threshold-mib` (default and maximum 100, `HOST_LIMIT_MIB`) + and within `--max-mib` are the LFS candidates. Without `--use-lfs`, any file above the + 100 MiB host limit aborts the run (exit 2) before committing; with it and no + candidates, LFS is skipped. A threshold of 0 selects every non-empty file. +2. `ensure_git_lfs` installs git-lfs via winget / brew / apt-get after confirmation. +3. `LfsSession.enable` runs `git lfs install --local` and appends one quoted, glob-escaped + rule per file to `.git/info/attributes` (resolved with `rev-parse --git-path`). With a + threshold of 0 it writes a single `*` rule plus unset rules for `.gitattributes`, + `.gitignore` and `.lfsconfig` (`all_files_rules`) instead. Git + applies it at `git add`; it is never committed, so no extra commits or pushes. +4. `LfsSession.close`, called from `finally` (so also on error or Ctrl+C), restores the + original attributes file and runs `git lfs uninstall --local`. + +## Dry run, retries, resume and verification + +- **`--dry-run`**: `runner.run` does all read-only preflight (including resume detection + and the over-100 MiB checks), then `batching.plan_batches` simulates the real selection + loop and `ui.print_dry_run` shows it. It returns 0 before any prompt, LFS install, config + change, journal write or commit. +- **`--retries`**: `push.push_with_retries` wraps `push_with_progress`. A failed push + raises `PushError` whose `retryable` flag is false when the output matches + `NON_RETRYABLE_MARKERS` (host rejections, auth). Otherwise it waits 5 s, 15 s, 45 s... + The same retry count applies to the post-push `ls-remote`. +- **Resume**: `journal.Journal` (`.git/commitrocket-journal.json`) is written before `git + add` and cleared after `git commit`. At startup, staged files that are a subset of the + journal's files are committed with its message and number; other staged files abort. + Unpushed commits (after the >100 MiB stuck-commit check) are pushed before new batches, + and numbering continues from `max(--number, journal number + 1)`. +- **`--post-buffer-mib`**: sets `http.postBuffer` through `LocalConfig` for HTTP(S) + remotes only (checked with `git remote get-url`), restored afterwards. +- **Verification**: `verify.verify_remote_head` compares `git ls-remote ` + with local HEAD after every push (remote and ref come from `branch..remote/merge`). + `verify.find_non_pointers` checks committed LFS files by object size and content + (`cat-file --batch-check` / `--batch`) before pushing. It replaces a + `git lfs push --dry-run` check, which only lists local objects and cannot tell what the + server already has. + +## Options, config, filters, templates, targets, doctor + +- **`RunOptions`** (`options.py`) carries every setting from `cli.py` to `runner.run`. +- **Config** (`config.py`): `cli.run_main` locates the file with a small pre-parser + (`--repo`, `--config`), `find_config` reads `commitrocket.toml` or + `[tool.commitrocket]` (via `tomllib`), and `apply_config` validates each value against + the argparse action (type, choices, flag, append list) and installs it with + `parser.set_defaults`, so the command line wins. `--include`/`--exclude` are `append` + actions, so command-line values are added to the config's lists. +- **Filters** (`filters.py`): `FileFilter` uses `pathspec` (`gitwildmatch`, i.e. + `.gitignore` semantics). Include narrows, exclude wins; `.commitrocketignore` and + `--*-from` files are read with `read_patterns`. `runner.list_pending` applies the filter + everywhere pending files are listed. +- **Templates** (`template.py`): `str.format` with fixed fields; `validate_template` + renders sample values at startup so bad templates fail before any work. +- **Push target** (`git.PushTarget` / `resolve_target`): explicit `--remote/--branch`, + else `branch..remote/merge`, else the local branch name. Pushes use + `git push HEAD:refs/heads/`. "Unpushed" means + `refs/remotes//..HEAD`, or `HEAD --not --remotes=` when that + branch was never pushed. +- **`--no-push`**: skips push and verification; rejected together with `--use-lfs`. +- **`--lfs-commit-rules`**: `LfsSession.write_committed_rules` appends one rule per + extension (`committed_rule_lines`; exact path for extension-less or odd names) to + `.gitattributes` after checking it is clean. The runner commits it as + ` (LFS rules)` and pushes it before the first batch. Local-only rules in + `.git/info/attributes` are not used in this mode; only the hooks are installed. +- **LFS hook safety**: `verify.unpushed_has_lfs_pointers` scans unpushed blobs for LFS + pointers. If any exist, resume installs the hooks before pushing, and a failed run keeps + them installed (`LfsSession.close(uninstall_hooks=False)`) so a later push still uploads + the data. +- **Doctor** (`doctor.py`): `collect_checks` returns `(status, name, detail)` rows + (pass/warn/fail/skip) and `run_doctor` prints them; exit 1 if any failed. Network checks + are `git ls-remote` and `git push --dry-run`, skipped with `--offline`. `cli.main` + dispatches `commitrocket doctor ...` before normal argument parsing, so existing + invocations are unchanged. + +## Report and notifications + +`report.RunReport` is created in `cli.run_main` and filled by `runner.run`: it sets +`status` (`success`, `completed_with_deferred_files`, `dry_run`, `cancelled`, `aborted`, +`interrupted`, `failed`) at every exit path, records each batch (commit SHA, message, +sizes, `pushed`, `verified`) as it happens, and holds the deferred files, resume counts and +LFS details. `RunReport.write` writes JSON via a temporary file and `os.replace`, so a +reader never sees a half-written report. The settings come from `asdict(RunOptions)`; +the webhook URL is deliberately not part of `RunOptions`, so it can't leak into a report. + +`cli.run_main` calls `finish`, writes the report and then `notify.send_notifications` +(skipped for `--dry-run`), all before logging is closed. Desktop notifications use only +built-in tools and pass the text through environment variables (`CR_TITLE`, `CR_BODY`) to +avoid quoting problems: Windows PowerShell's `Windows.UI.Notifications` toast (using +PowerShell's own AppUserModelID, which Windows already trusts), `osascript` on macOS, +`notify-send` on Linux, then the terminal bell. The webhook is a stdlib `urllib` POST +restricted to http(s) URLs with a 10 s timeout. Delivery errors are logged and printed, +never raised. + +## Logging + +`logs.setup_logging` (called from `cli.main`) attaches one `FileHandler` to the +`commitrocket` logger, with `propagate = False` so nothing reaches the console, which is +Rich's job. The file is `commitrocket-.log` in `default_log_dir()` (per OS) or +`--log-file`. If the file can't be opened the run continues without logging. Modules use +`logging.getLogger(__name__)`. INFO records the run, each batch and its outcome; DEBUG adds +file lists, every git command (`run_git`, output truncated to 2000 chars) and the +push output (progress lines are thinned to one per phase change plus the final one). The +log path is printed after the run on every exit path. + +## ETA + +`eta.EtaEstimator` (not Rich's `TimeRemainingColumn`) computes the ETA as remaining bytes +divided by the run's average speed: bytes completed, including the part of the current +batch already uploaded, over the time since the first batch started. It reports nothing +for the first 5 seconds. The estimator is attached to the overall task as a task field, +and `ui.EtaColumn` calls it on every Rich refresh, so the countdown ticks without needing +progress events. The push row has no estimator and shows no ETA. + +## Batch types + +`batching.select_batch(..., batch_type)`: `deterministic` is the original algorithm (a file +above the limit goes first and alone, otherwise first-fit that skips misfits). +`sequential` and `soft` share `_select_in_order`, which walks eligible files in order and +stops at the first that doesn't fit; `soft` instead stops once the total reaches the limit +or the next file would pass `--max-mib`. A first file above the limit always travels alone +(or with consecutive LFS files up to `--lfs-batch-mib`). Files above `--max-mib` are +deferred in every mode, and `estimate_batch_count` uses the same rules. + +## Threads and LFS grouping + +`--compression-threads` and `--lfs-threads` map to `pack.threads` and +`lfs.concurrenttransfers`. `git.LocalConfig` writes them with `git config --local`, +remembers any previous local value, and restores or unsets it in `finally`. The global +config is never read for writing. `select_batch` groups LFS files up to +`--lfs-batch-mib` (default `--limit-mib`, which means one file per push unless raised) so +git-lfs has several files to upload concurrently. A group always has at least one file. + +## Stuck-commit check + +At startup, `find_large_unpushed_blobs` inspects `@{upstream}..HEAD` +(`rev-list --objects` piped to `cat-file --batch-check`). If any blob is over 100 MiB the +run exits 2 without changing anything and prints `git reset --mixed `. +LFS pointer blobs are tiny, so commits already converted to LFS pass. + +## Development + +See [development.md](development.md) for setup, test markers, pre-commit, the GitHub +workflows and the release process. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..71287b5 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,66 @@ +# Development + +## Setup + +``` +uv sync # creates .venv with the dev tools +uv run pre-commit install # lint + unit tests before every commit +``` + +## Tests + +Tests are marked, so you can pick what to run: + +| Command | What runs | +| --- | --- | +| `uv run pytest` | Everything | +| `uv run pytest -m "not integration"` | Unit tests only (fast; what pre-commit runs) | +| `uv run pytest -m integration` | End-to-end tests in `tests/integration/` | + +Pre-commit and the `tests.yml` workflow run pytest in parallel with `pytest-xdist` +(`-n auto`, one worker per CPU core). Add `-n auto` locally for speed, or leave it off +when debugging a single test, since output and breakpoints are easier without workers. + +Integration tests drive the real CLI against a local bare repository acting as the remote, +so they need only Git. They carry `pytestmark = pytest.mark.integration`; new end-to-end +tests belong in `tests/integration/` with the same marker. Markers are registered in +`pyproject.toml` and `--strict-markers` is on, so a typo in a marker name is an error. + +## Lint + +``` +uv run ruff check . +``` + +## Pre-commit + +`.pre-commit-config.yaml` runs `ruff check --fix` and the unit tests +(`pytest -m "not integration"`) on each commit. Integration tests are deliberately left to +CI. Run the hooks on everything with `uv run pre-commit run --all-files`, and bump hook +versions with `uv run pre-commit autoupdate`. + +## GitHub workflows + +| Workflow | When | What | +| --- | --- | --- | +| `lint.yml` | Pushes to `main`, pull requests | `ruff check` | +| `tests.yml` | Pushes to `main`, pull requests | Unit then integration tests on Ubuntu with Python 3.11, 3.12, 3.13, 3.14 and 3.15, and on Windows and macOS with 3.11. The 3.15 job may fail without failing the build while that version is a pre-release. | +| `release.yml` | Pushing a tag like `v0.2.0` | Checks the tag against the package version, re-runs lint and all tests, builds with `uv build`, publishes to PyPI and creates a GitHub release with the files attached | + +## Releasing + +1. Update `version` in `pyproject.toml` and `__version__` in + `src/commitrocket/__init__.py` (they must match), and move the `[Unreleased]` entries in + `CHANGELOG.md` under the new version. +2. Commit and push to `main`. +3. Tag and push the tag: + + ``` + git tag v0.2.0 + git push origin v0.2.0 + ``` + +One-time setup: create a PyPI API token and save it as the repository secret +`PYPI_API_TOKEN`. The publish job uses a GitHub environment named `pypi`, which you can +configure with required reviewers if you want a manual approval before each release. Scope +the token to this project once the first release has created it on PyPI. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dbd430c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "commitrocket" +version = "0.1.0" +description = "Batch Git changes by size, commit each batch, and push with live progress." +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.11" +dependencies = ["rich>=13", "pathspec>=0.12"] + +[project.scripts] +commitrocket = "commitrocket.cli:main" + +[dependency-groups] +dev = ["pytest>=8", "pytest-xdist>=3", "ruff>=0.6", "pre-commit>=3"] + +[tool.uv] +package = true + +[tool.hatch.build.targets.wheel] +packages = ["src/commitrocket"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "--strict-markers" +markers = [ + "integration: end-to-end runs against a local bare repository (slower; excluded from pre-commit, run in CI)", +] + +[tool.ruff] +line-length = 88 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/src/commitrocket/__init__.py b/src/commitrocket/__init__.py new file mode 100644 index 0000000..243c56c --- /dev/null +++ b/src/commitrocket/__init__.py @@ -0,0 +1,3 @@ +"""CommitRocket: batch Git changes by size, commit each batch, and push.""" + +__version__ = "0.1.0" diff --git a/src/commitrocket/__main__.py b/src/commitrocket/__main__.py new file mode 100644 index 0000000..c20a675 --- /dev/null +++ b/src/commitrocket/__main__.py @@ -0,0 +1,3 @@ +from commitrocket.cli import main + +raise SystemExit(main()) diff --git a/src/commitrocket/batching.py b/src/commitrocket/batching.py new file mode 100644 index 0000000..b6d5b03 --- /dev/null +++ b/src/commitrocket/batching.py @@ -0,0 +1,184 @@ +from commitrocket.git import get_file_size + +MIB = 1024 * 1024 + +SEQUENTIAL = "sequential" +DETERMINISTIC = "deterministic" +SOFT = "soft" +BATCH_TYPES = (SEQUENTIAL, DETERMINISTIC, SOFT) + + +def _select_in_order( + eligible, limit_bytes, max_bytes, lfs_files, lfs_limit_bytes, soft +): + """Take files strictly in order, stopping at the first one that doesn't fit. + + soft: keep adding until the batch reaches or passes limit_bytes, as long as + the total stays within max_bytes. Otherwise the batch never passes limit_bytes + (a single larger file travels alone). + """ + first_path, first_size = eligible[0] + selected = [] + total_bytes = 0 + + if first_path in lfs_files and first_size > limit_bytes: + # Consecutive LFS files are grouped so they can upload concurrently. + for path, size in eligible: + if path not in lfs_files or size <= limit_bytes: + break + if selected and total_bytes + size > lfs_limit_bytes: + break + selected.append(path) + total_bytes += size + return selected, total_bytes + + for path, size in eligible: + if selected: + if soft and (total_bytes >= limit_bytes or total_bytes + size > max_bytes): + break + if not soft and total_bytes + size > limit_bytes: + break + selected.append(path) + total_bytes += size + + return selected, total_bytes + + +def select_batch( + repo, + files, + limit_bytes, + max_bytes, + lfs_files=frozenset(), + lfs_limit_bytes=0, + batch_type=DETERMINISTIC, +): + """Choose the next batch of files. + + Files exceeding max_bytes are deferred. batch_type decides how the rest are + picked: + + - deterministic: a file above limit_bytes goes first and alone, otherwise + files are packed first-fit, skipping any that don't fit. + - sequential: strict order; stops at the first file that doesn't fit. + - soft: strict order; the limit may be crossed (up to max_bytes in total). + + LFS files above limit_bytes are grouped up to lfs_limit_bytes (always at + least one file) so their uploads can run concurrently. + """ + sized_files = [(path, get_file_size(repo, path)) for path in files] + + deferred = [(path, size) for path, size in sized_files if size > max_bytes] + + eligible = [(path, size) for path, size in sized_files if size <= max_bytes] + + if eligible and batch_type != DETERMINISTIC: + selected, total_bytes = _select_in_order( + eligible, + limit_bytes, + max_bytes, + lfs_files, + lfs_limit_bytes, + soft=batch_type == SOFT, + ) + return selected, deferred, total_bytes + + # Process a larger-than-normal file by itself. + large_files = [(path, size) for path, size in eligible if size > limit_bytes] + + if large_files: + lfs_large = [(path, size) for path, size in large_files if path in lfs_files] + if lfs_large: + selected = [] + total_bytes = 0 + for path, size in lfs_large: + if not selected or total_bytes + size <= lfs_limit_bytes: + selected.append(path) + total_bytes += size + return selected, deferred, total_bytes + + path, size = large_files[0] + return [path], deferred, size + + # Pack ordinary files without exceeding the normal batch limit. + selected = [] + total_bytes = 0 + + for path, size in eligible: + if total_bytes + size <= limit_bytes: + selected.append(path) + total_bytes += size + + return selected, deferred, total_bytes + + +def plan_batches( + repo, + files, + limit_bytes, + max_bytes, + lfs_files=frozenset(), + lfs_limit_bytes=0, + batch_type=DETERMINISTIC, +): + """All batches a run would create, plus the files deferred for being too large. + + Returns ([(paths, total_bytes), ...], [(path, size), ...]). + """ + remaining = list(files) + batches = [] + deferred = [] + + while remaining: + selected, deferred, total_bytes = select_batch( + repo, + remaining, + limit_bytes, + max_bytes, + lfs_files, + lfs_limit_bytes, + batch_type, + ) + + if not selected: + break + + batches.append((selected, total_bytes)) + selected_set = set(selected) + remaining = [path for path in remaining if path not in selected_set] + + return batches, deferred + + +def estimate_batch_count( + repo, + files, + limit_bytes, + max_bytes, + lfs_files=frozenset(), + lfs_limit_bytes=0, + batch_type=DETERMINISTIC, +): + """Estimate batches using the same selection rules as execution.""" + remaining = list(files) + batches = 0 + + while remaining: + selected, _, _ = select_batch( + repo, + remaining, + limit_bytes, + max_bytes, + lfs_files, + lfs_limit_bytes, + batch_type, + ) + + if not selected: + break + + selected_set = set(selected) + remaining = [path for path in remaining if path not in selected_set] + batches += 1 + + return batches diff --git a/src/commitrocket/cli.py b/src/commitrocket/cli.py new file mode 100644 index 0000000..ccec711 --- /dev/null +++ b/src/commitrocket/cli.py @@ -0,0 +1,405 @@ +import argparse +import logging +import sys + +from rich.console import Console + +from commitrocket import __version__ +from commitrocket.batching import BATCH_TYPES, DETERMINISTIC +from commitrocket.config import ConfigError, apply_config, find_config +from commitrocket.doctor import run_doctor +from commitrocket.lfs import LFS_THRESHOLD_MIB +from commitrocket.logs import ( + DEFAULT_LEVEL, + LOG_LEVELS, + close_logging, + default_log_dir, + setup_logging, +) +from commitrocket.notify import is_valid_webhook, send_notifications +from commitrocket.options import RunOptions +from commitrocket.report import RunReport +from commitrocket.runner import run +from commitrocket.template import DEFAULT_TEMPLATE, PLACEHOLDERS, validate_template + +DEFAULT_LIMIT_MIB = 100 +DEFAULT_RETRIES = 3 + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="commitrocket", + description="Batch Git changes, commit each batch, and push " + "with progress reporting. Run 'commitrocket doctor' to check your setup.", + allow_abbrev=False, + ) + parser.add_argument("--version", action="version", version=__version__) + parser.add_argument( + "--repo", default=".", help="Path to the Git repository (default: .)" + ) + parser.add_argument( + "--config", + default=None, + help="Read settings from this TOML file instead of commitrocket.toml / " + "[tool.commitrocket] in pyproject.toml. Command-line options win", + ) + parser.add_argument( + "--message", + default=None, + help="Base commit message (required, here or in the config file)", + ) + parser.add_argument( + "--message-template", + default=DEFAULT_TEMPLATE, + help=f"Commit message for each batch (default: '{DEFAULT_TEMPLATE}'). " + f"Placeholders: {', '.join(PLACEHOLDERS)}", + ) + parser.add_argument( + "--number", + type=int, + default=1, + help="Starting commit suffix number (default: 1)", + ) + parser.add_argument("--force", action="store_true", help="Skip confirmation") + parser.add_argument( + "--remote", + default=None, + help="Remote to push to (default: the branch's upstream remote)", + ) + parser.add_argument( + "--branch", + default=None, + help="Remote branch to push to (default: the branch's upstream branch, " + "or the current branch name)", + ) + parser.add_argument( + "--no-push", + action="store_true", + help="Commit every batch but don't push; a later run pushes them", + ) + parser.add_argument( + "--include", + action="append", + default=None, + metavar="GLOB", + help="Only handle files matching this .gitignore-style pattern " + "(repeatable), e.g. '*.mp4' or 'videos/**'", + ) + parser.add_argument( + "--exclude", + action="append", + default=None, + metavar="GLOB", + help="Leave files matching this pattern alone (repeatable); wins over " + "--include. Patterns in .commitrocketignore are always excluded", + ) + parser.add_argument( + "--include-from", + action="append", + default=None, + metavar="FILE", + help="Read --include patterns from a file, one per line (# comments)", + ) + parser.add_argument( + "--exclude-from", + action="append", + default=None, + metavar="FILE", + help="Read --exclude patterns from a file, one per line (# comments)", + ) + parser.add_argument( + "--limit-mib", + type=int, + default=DEFAULT_LIMIT_MIB, + help=f"Normal batch limit in MiB (default: {DEFAULT_LIMIT_MIB})", + ) + parser.add_argument( + "--max-mib", + type=int, + default=None, + help="Maximum individual file size in MiB (default: 3x --limit-mib)", + ) + parser.add_argument( + "--use-lfs", + action="store_true", + help="Track files above 100 MiB with Git LFS for this run, " + "installing git-lfs if needed and reverting the setup afterwards", + ) + parser.add_argument( + "--lfs-commit-rules", + action="store_true", + help="With --use-lfs, also commit LFS rules to .gitattributes (one per " + "file extension) so clones get real files and future files of the same " + "type use LFS", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show the batches and commit messages that would be created, " + "without staging, committing or pushing anything", + ) + parser.add_argument( + "--retries", + type=int, + default=DEFAULT_RETRIES, + help="Retry a failed push this many times, waiting 5 s, 15 s, 45 s... " + f"(default: {DEFAULT_RETRIES}). Rejections such as file-size limits are " + "never retried", + ) + parser.add_argument( + "--post-buffer-mib", + type=int, + default=None, + help="Raise Git's http.postBuffer to this many MiB for this run " + "(repository config only; HTTP/HTTPS remotes). Helps with 'RPC failed' " + "and HTTP 408/500 errors on large pushes", + ) + parser.add_argument( + "--report", + default=None, + metavar="FILE", + help="Write a machine-readable JSON summary of the run to this file " + "(also written when the run fails, is interrupted or is a dry run)", + ) + parser.add_argument( + "--notify", + action="store_true", + help="Show a desktop notification when the run ends (Windows toast, " + "macOS notification, Linux notify-send; terminal bell if none works)", + ) + parser.add_argument( + "--notify-webhook", + default=None, + metavar="URL", + help="POST a JSON summary to this http(s) URL when the run ends " + "(Slack, Discord, Teams, ntfy or your own endpoint)", + ) + parser.add_argument( + "--level", + type=str.upper, + choices=LOG_LEVELS, + default=DEFAULT_LEVEL, + help=f"Log level written to the log file (default: {DEFAULT_LEVEL}); " + "DEBUG also records every git command and the push output", + ) + parser.add_argument( + "--log-file", + default=None, + help="Write the log here instead of the default per-user log folder " + f"({str(default_log_dir()).replace('%', '%%')})", + ) + parser.add_argument( + "--batch-type", + choices=BATCH_TYPES, + default=DETERMINISTIC, + help="How files are picked for a batch: 'deterministic' packs first-fit " + "and skips files that don't fit; 'sequential' takes files strictly in " + "order; 'soft' takes files in order and may pass --limit-mib, up to " + "--max-mib (default: deterministic)", + ) + parser.add_argument( + "--compression-threads", + type=int, + default=None, + help="Threads Git uses to compress objects when pushing (pack.threads; " + "0 = auto). Set in the repository's local config for this run only", + ) + parser.add_argument( + "--lfs-threads", + type=int, + default=None, + help="Concurrent Git LFS uploads (lfs.concurrenttransfers). Set in the " + "repository's local config for this run only", + ) + parser.add_argument( + "--lfs-threshold-mib", + type=int, + default=LFS_THRESHOLD_MIB, + help="With --use-lfs, files larger than this go through Git LFS " + f"(default: {LFS_THRESHOLD_MIB}, the most hosts accept; 0 = every file)", + ) + parser.add_argument( + "--lfs-batch-mib", + type=int, + default=None, + help="Max total size of LFS files pushed together so they can upload " + "concurrently (default: same as --limit-mib)", + ) + return parser + + +def validate(parser, args): + """Reject invalid combinations; returns the effective --max-mib.""" + if not args.message or not args.message.strip(): + parser.error("--message is required (or set 'message' in the config file)") + if args.limit_mib <= 0: + parser.error("--limit-mib must be positive") + if args.number < 0: + parser.error("--number cannot be negative") + if args.compression_threads is not None and args.compression_threads < 0: + parser.error("--compression-threads cannot be negative") + if args.lfs_threads is not None and args.lfs_threads <= 0: + parser.error("--lfs-threads must be positive") + if not 0 <= args.lfs_threshold_mib <= LFS_THRESHOLD_MIB: + parser.error( + f"--lfs-threshold-mib must be between 0 and {LFS_THRESHOLD_MIB}; " + "larger files would be rejected by the host" + ) + if args.retries < 0: + parser.error("--retries cannot be negative") + if args.post_buffer_mib is not None and args.post_buffer_mib <= 0: + parser.error("--post-buffer-mib must be positive") + if args.lfs_batch_mib is not None and args.lfs_batch_mib <= 0: + parser.error("--lfs-batch-mib must be positive") + if args.no_push and args.use_lfs: + parser.error( + "--no-push cannot be combined with --use-lfs: the large files would " + "stay local and a later plain 'git push' would publish LFS pointers " + "without their data" + ) + if args.lfs_commit_rules and not args.use_lfs: + parser.error("--lfs-commit-rules requires --use-lfs") + + if args.notify_webhook and not is_valid_webhook(args.notify_webhook): + parser.error("--notify-webhook must be an http:// or https:// URL") + + template_error = validate_template(args.message_template) + if template_error: + parser.error(template_error) + + max_mib = args.max_mib if args.max_mib is not None else args.limit_mib * 3 + if max_mib < args.limit_mib: + parser.error("--max-mib must be greater than or equal to --limit-mib") + return max_mib + + +def run_main(argv): + parser = build_parser() + + # Find the config file first: its values become the parser's defaults, so + # anything given on the command line still wins. + pre = argparse.ArgumentParser(add_help=False, allow_abbrev=False) + pre.add_argument("--repo", default=".") + pre.add_argument("--config") + known, _ = pre.parse_known_args(argv) + + config_path = None + try: + config_path, values = find_config(known.repo, known.config) + if values: + apply_config(parser, values, config_path) + except ConfigError as exc: + parser.error(str(exc)) + + args = parser.parse_args(argv) + max_mib = validate(parser, args) + + options = RunOptions( + repo=args.repo, + message=args.message, + message_template=args.message_template, + number=args.number, + force=args.force, + remote=args.remote, + branch=args.branch, + no_push=args.no_push, + include=args.include or [], + exclude=args.exclude or [], + include_from=args.include_from or [], + exclude_from=args.exclude_from or [], + limit_mib=args.limit_mib, + max_mib=max_mib, + batch_type=args.batch_type, + use_lfs=args.use_lfs, + lfs_commit_rules=args.lfs_commit_rules, + lfs_threshold_mib=args.lfs_threshold_mib, + lfs_threads=args.lfs_threads, + lfs_batch_mib=args.lfs_batch_mib, + compression_threads=args.compression_threads, + dry_run=args.dry_run, + retries=args.retries, + post_buffer_mib=args.post_buffer_mib, + report=args.report, + ) + + console = Console() + log_path = setup_logging(args.level, args.log_file) + options.log_file = str(log_path) if log_path else None + if log_path is None: + console.print( + "[yellow]Could not open a log file; continuing without one.[/yellow]" + ) + if config_path: + console.print(f"[dim]Using config: {config_path}[/dim]") + + report = RunReport(options) + try: + code = run(console, options, report) + report.finish(code, log_path) + + if args.report: + try: + report_path = report.write(args.report) + console.print(f"\n[dim]Report: {report_path}[/dim]") + except OSError as exc: + console.print(f"[yellow]Could not write the report: {exc}[/yellow]") + logging.getLogger(__name__).warning("Report not written: %s", exc) + + if (args.notify or args.notify_webhook) and not args.dry_run: + send_notifications( + console, report, desktop=args.notify, webhook=args.notify_webhook + ) + finally: + close_logging() + + if log_path: + console.print(f"\n[dim]Log file: {log_path}[/dim]") + return code + + +def doctor_main(argv): + parser = argparse.ArgumentParser( + prog="commitrocket doctor", + description="Check that Git, git-lfs, the repository, the remote and " + "your settings are ready for a run. Changes nothing.", + allow_abbrev=False, + ) + parser.add_argument("--repo", default=".", help="Repository to check (default: .)") + parser.add_argument("--remote", default=None, help="Remote to check") + parser.add_argument("--branch", default=None, help="Remote branch to check") + parser.add_argument("--config", default=None, help="Config file to check") + parser.add_argument( + "--offline", + action="store_true", + help="Skip the checks that contact the remote", + ) + args = parser.parse_args(argv) + + console = Console() + log_path = setup_logging(DEFAULT_LEVEL, None) + try: + code = run_doctor( + console, + args.repo, + remote=args.remote, + branch=args.branch, + offline=args.offline, + config_path=args.config, + ) + finally: + close_logging() + + if log_path: + console.print(f"\n[dim]Log file: {log_path}[/dim]") + return code + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if argv and argv[0] == "doctor": + return doctor_main(argv[1:]) + return run_main(argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/commitrocket/config.py b/src/commitrocket/config.py new file mode 100644 index 0000000..19a2782 --- /dev/null +++ b/src/commitrocket/config.py @@ -0,0 +1,99 @@ +import tomllib +from pathlib import Path + +from commitrocket.errors import CommitRocketError + +CONFIG_NAME = "commitrocket.toml" +PYPROJECT_NAME = "pyproject.toml" + +# Options that make no sense in a config file. +NOT_CONFIGURABLE = {"config", "repo", "help", "version"} + + +class ConfigError(CommitRocketError): + """The config file could not be read or holds an invalid value.""" + + +def find_config(repo, explicit=None): + """Return (path, values) from the config file, or (None, {}) if there is none. + + Looked up in the repository folder: commitrocket.toml first, then the + [tool.commitrocket] table of pyproject.toml. --config overrides the lookup. + """ + if explicit: + path = Path(explicit).expanduser() + if not path.is_file(): + raise ConfigError(f"Config file not found: {path}") + return path, _load(path) + + folder = Path(repo).expanduser() + plain = folder / CONFIG_NAME + if plain.is_file(): + return plain, _load(plain) + + pyproject = folder / PYPROJECT_NAME + if pyproject.is_file(): + values = _load(pyproject) + if values: + return pyproject, values + + return None, {} + + +def _load(path): + try: + with open(path, "rb") as handle: + data = tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise ConfigError(f"Cannot read config file {path}: {exc}") from exc + + if path.name == PYPROJECT_NAME: + data = data.get("tool", {}).get("commitrocket", {}) + return data + + +def apply_config(parser, values, source): + """Use config values as the parser's defaults, so the command line wins.""" + actions = {action.dest: action for action in parser._actions} + defaults = {} + + for key, value in values.items(): + dest = key.replace("-", "_") + action = actions.get(dest) + if action is None or dest in NOT_CONFIGURABLE: + raise ConfigError(f"{source}: unknown option '{key}'") + defaults[dest] = _coerce(action, key, value, source) + + parser.set_defaults(**defaults) + + +def _coerce(action, key, value, source): + where = f"{source}: '{key}'" + + if action.nargs == 0: # a flag such as --force + if not isinstance(value, bool): + raise ConfigError(f"{where} must be true or false") + return value + + if _is_append(action): + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise ConfigError(f"{where} must be a list of strings") + return value + + if isinstance(value, (bool, list, dict)): + raise ConfigError(f"{where} has the wrong type") + + if action.type is not None: + try: + value = action.type(value) + except (TypeError, ValueError) as exc: + raise ConfigError(f"{where} is invalid: {exc}") from exc + + if action.choices and value not in action.choices: + choices = ", ".join(map(str, action.choices)) + raise ConfigError(f"{where} must be one of: {choices}") + return value + + +def _is_append(action): + return type(action).__name__ == "_AppendAction" diff --git a/src/commitrocket/doctor.py b/src/commitrocket/doctor.py new file mode 100644 index 0000000..1e24479 --- /dev/null +++ b/src/commitrocket/doctor.py @@ -0,0 +1,199 @@ +import logging +import subprocess +import sys +from pathlib import Path + +from rich.table import Table + +from commitrocket.batching import MIB +from commitrocket.config import ConfigError, find_config +from commitrocket.errors import CommitRocketError, GitError +from commitrocket.filters import IGNORE_FILE +from commitrocket.git import ( + find_large_unpushed_blobs, + get_file_size, + get_pending_files, + get_staged_files, + get_unpushed_commits, + resolve_target, + run_git, +) +from commitrocket.journal import Journal +from commitrocket.lfs import HOST_LIMIT_MIB, is_lfs_available +from commitrocket.logs import default_log_dir + +logger = logging.getLogger(__name__) + +PASS, WARN, FAIL, SKIP = "pass", "warn", "fail", "skip" +STYLES = {PASS: "green", WARN: "yellow", FAIL: "bold red", SKIP: "dim"} +NETWORK_TIMEOUT = 60 + + +def _first_line(text): + lines = [line for line in text.strip().splitlines() if line.strip()] + return lines[0] if lines else "no details" + + +def _remote_check(repo, args, label): + try: + result = subprocess.run( + ["git", "-C", str(repo), *args], + text=True, + capture_output=True, + timeout=NETWORK_TIMEOUT, + ) + except subprocess.TimeoutExpired: + return FAIL, f"{label} timed out after {NETWORK_TIMEOUT} s" + if result.returncode != 0: + return FAIL, _first_line(result.stderr or result.stdout) + return PASS, label + + +def _check_log_dir(): + folder = default_log_dir() + try: + folder.mkdir(parents=True, exist_ok=True) + probe = folder / ".doctor-probe" + probe.write_text("ok", encoding="utf-8") + probe.unlink() + except OSError as exc: + return WARN, f"{folder} is not writable ({exc})" + return PASS, str(folder) + + +def collect_checks(repo, remote=None, branch=None, offline=False, config_path=None): + """Run every check and return [(status, name, detail), ...].""" + results = [] + + def add(status, name, detail): + results.append((status, name, detail)) + logger.info("doctor: %s %s - %s", status, name, detail) + + add(PASS, "Python", sys.version.split()[0]) + + try: + git_version = subprocess.run( + ["git", "--version"], text=True, stdout=subprocess.PIPE + ).stdout.strip() + except OSError: + add(FAIL, "Git", "git was not found on PATH") + return results + add(PASS, "Git", git_version) + + if is_lfs_available(): + add(PASS, "git-lfs", "installed") + else: + add(WARN, "git-lfs", "not installed (needed only for --use-lfs)") + + try: + path, values = find_config(repo, config_path) + detail = f"{path} ({len(values)} setting(s))" if path else "none (defaults)" + add(PASS, "Config file", detail) + except ConfigError as exc: + add(FAIL, "Config file", str(exc)) + + status, detail = _check_log_dir() + add(status, "Log folder", detail) + + try: + root = Path(run_git(repo, "rev-parse", "--show-toplevel").stdout.strip()) + except (GitError, OSError): + add(FAIL, "Repository", f"{repo} is not inside a Git repository") + return results + add(PASS, "Repository", str(root)) + + local_branch = run_git(root, "branch", "--show-current").stdout.strip() + if not local_branch: + add(FAIL, "Branch", "detached HEAD; check out a branch") + return results + add(PASS, "Branch", local_branch) + + try: + target = resolve_target(root, local_branch, remote, branch) + except CommitRocketError as exc: + add(FAIL, "Push target", str(exc)) + return results + add(PASS, "Push target", target.label) + + if offline: + add(SKIP, "Remote access", "skipped (--offline)") + add(SKIP, "Push permission", "skipped (--offline)") + else: + status, detail = _remote_check( + root, ["ls-remote", target.remote, "HEAD"], "remote reachable" + ) + add(status, "Remote access", detail) + status, detail = _remote_check( + root, + ["push", "--dry-run", target.remote, target.refspec], + "push accepted (dry run)", + ) + add(WARN if status == FAIL else status, "Push permission", detail) + + staged = get_staged_files(root) + entry = Journal(root).read() + if staged and not (entry and set(staged) <= set(entry["files"])): + add(WARN, "Staged files", f"{len(staged)} file(s) staged; the run would stop") + elif staged: + add(PASS, "Staged files", f"{len(staged)} file(s) left by an interrupted run") + else: + add(PASS, "Staged files", "none") + + try: + unpushed = get_unpushed_commits(root, target.revs) + large = find_large_unpushed_blobs(root, HOST_LIMIT_MIB * MIB, target.revs) + except GitError: + add(WARN, "Unpushed commits", "could not be inspected (no commits yet?)") + else: + if large: + add( + WARN, + "Unpushed commits", + f"{len(unpushed)} commit(s) contain files over " + f"{HOST_LIMIT_MIB} MiB; run 'git reset --mixed {target.label}'", + ) + else: + add(PASS, "Unpushed commits", f"{len(unpushed)} (will be pushed first)") + + ignore = root / IGNORE_FILE + add(PASS, IGNORE_FILE, "present" if ignore.is_file() else "not used") + + pending = get_pending_files(root) + big = [p for p in pending if get_file_size(root, p) > HOST_LIMIT_MIB * MIB] + if big: + add( + WARN, + "Pending files", + f"{len(pending)} file(s), {len(big)} over {HOST_LIMIT_MIB} MiB " + "(use --use-lfs)", + ) + else: + add(PASS, "Pending files", f"{len(pending)} file(s)") + + return results + + +def run_doctor( + console, repo, remote=None, branch=None, offline=False, config_path=None +): + """Print a health report. Returns 1 if any check failed, else 0.""" + results = collect_checks(repo, remote, branch, offline, config_path) + + table = Table(title="CommitRocket doctor", show_lines=False) + table.add_column("Status") + table.add_column("Check") + table.add_column("Details", overflow="fold") + for status, name, detail in results: + table.add_row(f"[{STYLES[status]}]{status.upper()}[/]", name, detail) + console.print(table) + + failed = [name for status, name, _ in results if status == FAIL] + warned = [name for status, name, _ in results if status == WARN] + if failed: + console.print(f"\n[bold red]{len(failed)} check(s) failed.[/bold red]") + return 1 + if warned: + console.print(f"\n[yellow]OK with {len(warned)} warning(s).[/yellow]") + else: + console.print("\n[bold green]All checks passed.[/bold green]") + return 0 diff --git a/src/commitrocket/errors.py b/src/commitrocket/errors.py new file mode 100644 index 0000000..6d649fc --- /dev/null +++ b/src/commitrocket/errors.py @@ -0,0 +1,14 @@ +class CommitRocketError(RuntimeError): + """Base class for expected, user-reportable failures.""" + + +class GitError(CommitRocketError): + """A git command failed.""" + + +class PushError(GitError): + """A git push failed. retryable is False when trying again cannot help.""" + + def __init__(self, message, retryable=True): + super().__init__(message) + self.retryable = retryable diff --git a/src/commitrocket/eta.py b/src/commitrocket/eta.py new file mode 100644 index 0000000..5eb1b28 --- /dev/null +++ b/src/commitrocket/eta.py @@ -0,0 +1,40 @@ +import time + +# Wait for this much real upload time before trusting the measured speed. +MIN_SECONDS = 5.0 + + +def format_duration(seconds): + seconds = max(0, int(seconds)) + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours}:{minutes:02d}:{secs:02d}" + + +class EtaEstimator: + """ETA from the average speed of the whole run so far. + + Speed is bytes completed (including the part of the current batch already + uploaded) divided by the time since the first batch started, so per-batch + overhead such as `git add`, commit and push handshakes is included. + """ + + def __init__(self, clock=time.monotonic): + self.clock = clock + self.started_at = None + + def start(self): + if self.started_at is None: + self.started_at = self.clock() + + def estimate(self, done_bytes, total_bytes): + """Seconds remaining, or None while there is not enough data yet.""" + if self.started_at is None or done_bytes <= 0: + return None + + elapsed = self.clock() - self.started_at + if elapsed < MIN_SECONDS: + return None + + remaining = max(0, total_bytes - done_bytes) + return remaining / (done_bytes / elapsed) diff --git a/src/commitrocket/filters.py b/src/commitrocket/filters.py new file mode 100644 index 0000000..9bf5587 --- /dev/null +++ b/src/commitrocket/filters.py @@ -0,0 +1,72 @@ +from pathlib import Path + +import pathspec + +from commitrocket.errors import CommitRocketError + +IGNORE_FILE = ".commitrocketignore" + + +def read_patterns(path): + """Pattern lines from a file; blank lines and # comments are skipped.""" + try: + text = Path(path).expanduser().read_text(encoding="utf-8") + except OSError as exc: + raise CommitRocketError(f"Cannot read pattern file {path}: {exc}") from exc + + return [ + line.strip() + for line in text.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +def _spec(lines): + try: + return pathspec.PathSpec.from_lines("gitignore", lines) + except KeyError: + # pathspec before 1.0 only knows the older name for the same syntax. + return pathspec.PathSpec.from_lines("gitwildmatch", lines) + + +class FileFilter: + """Keep only the files that match --include (if any) and not --exclude. + + Patterns use .gitignore syntax: '*.mp4' matches at any depth, 'raw/' a + folder, '/notes.txt' only at the repo root, '**' any number of folders. + Exclusion always wins over inclusion. + """ + + def __init__(self, include=(), exclude=()): + self.include = _spec(include) if include else None + self.exclude = _spec(exclude) if exclude else None + + @classmethod + def from_options(cls, repo, include, exclude, include_from, exclude_from): + include_lines = list(include) + for path in include_from: + include_lines += read_patterns(path) + + exclude_lines = list(exclude) + for path in exclude_from: + exclude_lines += read_patterns(path) + + ignore_file = Path(repo) / IGNORE_FILE + if ignore_file.is_file(): + exclude_lines += read_patterns(ignore_file) + + return cls(include_lines, exclude_lines) + + @property + def active(self): + return self.include is not None or self.exclude is not None + + def keeps(self, path): + if self.exclude is not None and self.exclude.match_file(path): + return False + if self.include is not None and not self.include.match_file(path): + return False + return True + + def apply(self, paths): + return [path for path in paths if self.keeps(path)] diff --git a/src/commitrocket/git.py b/src/commitrocket/git.py new file mode 100644 index 0000000..d47f465 --- /dev/null +++ b/src/commitrocket/git.py @@ -0,0 +1,182 @@ +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from commitrocket.errors import CommitRocketError, GitError + +logger = logging.getLogger(__name__) + +MAX_LOGGED_OUTPUT = 2000 + + +def _logged_output(result): + text = "\n".join(part for part in (result.stdout, result.stderr) if part.strip()) + text = text.strip().replace("\0", "|") + if len(text) > MAX_LOGGED_OUTPUT: + text = text[:MAX_LOGGED_OUTPUT] + "... [truncated]" + return f"\n{text}" if text else "" + + +def run_git(repo, *args, check=True): + result = subprocess.run( + ["git", "-C", str(repo), *args], + text=True, + capture_output=True, + ) + logger.debug( + "git %s -> exit %d%s", " ".join(args), result.returncode, _logged_output(result) + ) + + if check and result.returncode != 0: + error = result.stderr.strip() or result.stdout.strip() + raise GitError(f"git {' '.join(args)} failed:\n{error}") + + return result + + +def get_paths(repo, *args): + return [path for path in run_git(repo, *args).stdout.split("\0") if path] + + +def get_staged_files(repo): + return get_paths(repo, "diff", "--cached", "--name-only", "-z") + + +def get_pending_files(repo): + unstaged = get_paths(repo, "diff", "--name-only", "-z") + untracked = get_paths(repo, "ls-files", "--others", "--exclude-standard", "-z") + return list(dict.fromkeys(unstaged + untracked)) + + +class LocalConfig: + """Set repository-local Git config for a run and put the old values back. + + Only the repo's own .git/config is touched, never the global config. + """ + + def __init__(self, repo): + self.repo = repo + self.saved = {} + + def set(self, key, value): + if key not in self.saved: + current = run_git(self.repo, "config", "--local", "--get", key, check=False) + found = current.returncode == 0 + self.saved[key] = current.stdout.strip() if found else None + run_git(self.repo, "config", "--local", key, str(value)) + + def restore(self): + for key, old in self.saved.items(): + if old is None: + run_git(self.repo, "config", "--local", "--unset", key, check=False) + else: + run_git(self.repo, "config", "--local", key, old, check=False) + self.saved.clear() + + +@dataclass +class PushTarget: + """Where a run pushes to, and how to tell what is not there yet.""" + + remote: str + branch: str + revs: list + + @property + def ref(self): + return f"refs/heads/{self.branch}" + + @property + def refspec(self): + return f"HEAD:{self.ref}" + + @property + def label(self): + return f"{self.remote}/{self.branch}" + + +def resolve_target(repo, local_branch, remote=None, branch=None): + """The remote branch to push to: explicit values first, then the upstream.""" + configured_remote = run_git( + repo, "config", "--get", f"branch.{local_branch}.remote", check=False + ).stdout.strip() + configured_merge = run_git( + repo, "config", "--get", f"branch.{local_branch}.merge", check=False + ).stdout.strip() + + remote = remote or configured_remote + if not remote: + raise CommitRocketError( + "No upstream configured. Configure one, or pass --remote " + "(and optionally --branch)." + ) + + branch = branch or configured_merge.removeprefix("refs/heads/") or local_branch + + tracking_ref = f"refs/remotes/{remote}/{branch}" + exists = run_git( + repo, "rev-parse", "--verify", "--quiet", tracking_ref, check=False + ).returncode + if exists == 0: + revs = [f"{tracking_ref}..HEAD"] + else: + # The branch was never pushed to this remote: everything the remote + # does not already have counts as unpushed. + revs = ["HEAD", "--not", f"--remotes={remote}"] + + return PushTarget(remote, branch, revs) + + +def get_remote_url(repo, remote): + return run_git(repo, "remote", "get-url", remote).stdout.strip() + + +def get_remote_head(repo, remote, ref): + """Commit the remote currently has for ref, or None if it doesn't exist.""" + fields = run_git(repo, "ls-remote", remote, ref).stdout.split() + return fields[0] if fields else None + + +def get_unpushed_commits(repo, revs): + """One-line summaries of local commits that are not on the remote yet.""" + return run_git(repo, "log", "--oneline", *revs).stdout.splitlines() + + +def find_large_unpushed_blobs(repo, threshold_bytes, revs): + """(path, size) of blobs above the threshold in commits not yet pushed.""" + objects = run_git(repo, "rev-list", "--objects", *revs).stdout + if not objects.strip(): + return [] + + result = subprocess.run( + [ + "git", + "-C", + str(repo), + "cat-file", + "--batch-check=%(objecttype) %(objectsize) %(rest)", + ], + input=objects, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + ) + if result.returncode != 0: + raise GitError(f"git cat-file failed:\n{result.stderr.strip()}") + + large = {} + for line in result.stdout.splitlines(): + kind, size, path = (line.split(" ", 2) + ["", ""])[:3] + if kind == "blob" and int(size) > threshold_bytes: + large[path or "(unnamed)"] = int(size) + return sorted(large.items()) + + +def get_file_size(repo, relative_path): + try: + path = Path(repo) / relative_path + return path.stat().st_size if path.is_file() else 0 + except OSError: + return 0 diff --git a/src/commitrocket/journal.py b/src/commitrocket/journal.py new file mode 100644 index 0000000..fc78056 --- /dev/null +++ b/src/commitrocket/journal.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path + +from commitrocket.git import run_git + + +class Journal: + """Remembers the batch being staged so an interrupted run can be resumed. + + It lives inside .git, is written before `git add` and removed once the commit + exists. Staged files that match it are ours; any other staged file is not. + """ + + def __init__(self, repo): + relative = run_git( + repo, "rev-parse", "--git-path", "commitrocket-journal.json" + ).stdout.strip() + self.path = Path(repo) / relative + + def read(self): + try: + return json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + def write(self, files, message, number): + entry = {"files": list(files), "message": message, "number": number} + self.path.write_text(json.dumps(entry), encoding="utf-8") + + def clear(self): + self.path.unlink(missing_ok=True) diff --git a/src/commitrocket/lfs.py b/src/commitrocket/lfs.py new file mode 100644 index 0000000..d50f62d --- /dev/null +++ b/src/commitrocket/lfs.py @@ -0,0 +1,205 @@ +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path, PurePosixPath + +from commitrocket.errors import CommitRocketError +from commitrocket.git import get_file_size, run_git + +# Git hosts reject pushes containing files larger than this. +HOST_LIMIT_MIB = 100 + +# Files above this size go through LFS by default (--lfs-threshold-mib; 0 = all). +LFS_THRESHOLD_MIB = HOST_LIMIT_MIB + +# Files that must stay ordinary blobs even when every file goes through LFS. +LFS_EXCLUDED = (".gitattributes", ".gitignore", ".lfsconfig") + + +def find_lfs_candidates(repo, files, threshold_bytes, max_bytes): + """Files above the LFS threshold that are still within the max file size.""" + return [ + path + for path in files + if threshold_bytes < get_file_size(repo, path) <= max_bytes + ] + + +def attribute_line(path): + """A .gitattributes-style rule that sends exactly this file through LFS.""" + glob_escaped = re.sub(r"([*?\[\]\\])", r"\\\1", path) + quoted = glob_escaped.replace("\\", "\\\\").replace('"', '\\"') + return f'"/{quoted}" filter=lfs diff=lfs merge=lfs -text' + + +def committed_rule_lines(paths): + """Rules for .gitattributes: one per file extension, exact path if it has none. + + Extension rules also cover future files of the same type. + """ + lines = set() + for path in paths: + pure = PurePosixPath(path) + if pure.name in LFS_EXCLUDED: + continue + suffix = pure.suffix + if suffix and not re.search(r"[\s*?\[\]\\\"]", suffix): + lines.add(f"*{suffix} filter=lfs diff=lfs merge=lfs -text") + else: + lines.add(attribute_line(path)) + return sorted(lines) + + +def all_files_rules(): + """Rules sending every file through LFS, except the Git config files.""" + rules = ["* filter=lfs diff=lfs merge=lfs -text"] + rules += [f"{name} !filter !diff !merge !text" for name in LFS_EXCLUDED] + return rules + + +def is_lfs_available(): + result = subprocess.run(["git", "lfs", "version"], capture_output=True) + return result.returncode == 0 + + +def install_command(): + """Package-manager command that installs git-lfs, or None if unsupported.""" + if sys.platform == "win32" and shutil.which("winget"): + return [ + "winget", + "install", + "--id", + "GitHub.GitLFS", + "-e", + "--accept-source-agreements", + "--accept-package-agreements", + ] + if sys.platform == "darwin" and shutil.which("brew"): + return ["brew", "install", "git-lfs"] + if sys.platform.startswith("linux") and shutil.which("apt-get"): + command = ["apt-get", "install", "-y", "git-lfs"] + if os.geteuid() != 0 and shutil.which("sudo"): + command.insert(0, "sudo") + return command + return None + + +def ensure_git_lfs(console, force): + """Make sure the git-lfs binary is available, installing it if needed.""" + if is_lfs_available(): + return + + command = install_command() + if command is None: + raise CommitRocketError( + "git-lfs is not installed and no supported package manager was " + "found. Install it from https://git-lfs.com and try again." + ) + + if not force: + answer = ( + console.input( + "[yellow]git-lfs is not installed. Run " + f"'{' '.join(command)}'? [y/N][/yellow] " + ) + .strip() + .lower() + ) + if answer not in {"y", "yes"}: + raise CommitRocketError("git-lfs is required for --use-lfs.") + + console.print("[bold cyan]Installing git-lfs...[/bold cyan]") + if subprocess.run(command).returncode != 0: + raise CommitRocketError("Installing git-lfs failed.") + + if not is_lfs_available(): + raise CommitRocketError( + "git-lfs was installed but is not on PATH yet. " + "Restart your terminal and run the command again." + ) + + +class LfsSession: + """Route large files through LFS for one run, with nothing committed. + + The rules go into .git/info/attributes, which Git honours but never + commits, and are taken back out by close(). + """ + + def __init__(self, repo): + self.repo = repo + self.attributes_path = None + self.original = None + self.installed = False + + def enable(self, paths, all_files=False): + """Route paths through LFS, or every file when all_files is set.""" + info_path = run_git( + self.repo, "rev-parse", "--git-path", "info/attributes" + ).stdout.strip() + self.attributes_path = Path(self.repo) / info_path + self.original = ( + self.attributes_path.read_bytes() if self.attributes_path.exists() else None + ) + + self.install_hooks() + + lines = all_files_rules() if all_files else map(attribute_line, paths) + rules = "".join(f"{line}\n" for line in lines) + existing = (self.original or b"").decode("utf-8", errors="replace") + if existing and not existing.endswith("\n"): + existing += "\n" + + self.attributes_path.parent.mkdir(parents=True, exist_ok=True) + self.attributes_path.write_text(existing + rules, encoding="utf-8") + + def install_hooks(self): + """Install LFS in this repository only, so pushes upload LFS files.""" + run_git(self.repo, "lfs", "install", "--local") + self.installed = True + + def write_committed_rules(self, paths): + """Append extension rules for paths to .gitattributes, to be committed. + + Returns True if the file changed. + """ + dirty = run_git( + self.repo, "status", "--porcelain", "--", ".gitattributes" + ).stdout.strip() + if dirty: + raise CommitRocketError( + ".gitattributes has uncommitted changes. Commit or discard " + "them before using --lfs-commit-rules." + ) + + target = Path(self.repo) / ".gitattributes" + existing = target.read_text(encoding="utf-8") if target.exists() else "" + known = {line.strip() for line in existing.splitlines()} + missing = [line for line in committed_rule_lines(paths) if line not in known] + if not missing: + return False + + if existing and not existing.endswith("\n"): + existing += "\n" + rules = "".join(f"{line}\n" for line in missing) + target.write_text(existing + rules, encoding="utf-8") + return True + + def close(self, uninstall_hooks=True): + """Restore the attributes file and remove the local LFS setup. + + Pass uninstall_hooks=False to keep the hooks, which unpushed commits + containing LFS files still need to upload their data. + """ + if self.attributes_path is not None: + if self.original is None: + self.attributes_path.unlink(missing_ok=True) + else: + self.attributes_path.write_bytes(self.original) + self.attributes_path = None + + if self.installed and uninstall_hooks: + run_git(self.repo, "lfs", "uninstall", "--local", check=False) + self.installed = False diff --git a/src/commitrocket/logs.py b/src/commitrocket/logs.py new file mode 100644 index 0000000..6e51691 --- /dev/null +++ b/src/commitrocket/logs.py @@ -0,0 +1,57 @@ +import logging +import os +import sys +from datetime import datetime +from pathlib import Path + +LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR") +DEFAULT_LEVEL = "INFO" +LOGGER_NAME = "commitrocket" + + +def default_log_dir(): + """The conventional per-user log folder for the current OS.""" + home = Path.home() + + if sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local") + return base / "CommitRocket" / "Logs" + if sys.platform == "darwin": + return home / "Library" / "Logs" / "CommitRocket" + + state = os.environ.get("XDG_STATE_HOME") + return (Path(state) if state else home / ".local" / "state") / "commitrocket" + + +def setup_logging(level=DEFAULT_LEVEL, log_file=None): + """Log to a file for this run. Returns its path, or None if it can't be opened.""" + logger = logging.getLogger(LOGGER_NAME) + logger.setLevel(getattr(logging, level.upper())) + logger.propagate = False + close_logging() + + if log_file: + path = Path(log_file).expanduser() + else: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + path = default_log_dir() / f"commitrocket-{stamp}.log" + + try: + path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.FileHandler(path, encoding="utf-8") + except OSError: + logger.addHandler(logging.NullHandler()) + return None + + handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s") + ) + logger.addHandler(handler) + return path.resolve() + + +def close_logging(): + logger = logging.getLogger(LOGGER_NAME) + for handler in list(logger.handlers): + handler.close() + logger.removeHandler(handler) diff --git a/src/commitrocket/notify.py b/src/commitrocket/notify.py new file mode 100644 index 0000000..254e4c8 --- /dev/null +++ b/src/commitrocket/notify.py @@ -0,0 +1,161 @@ +import json +import logging +import os +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +from urllib.parse import urlparse + +from commitrocket.eta import format_duration +from commitrocket.report import ( + ABORTED, + CANCELLED, + DEFERRED, + FAILED, + INTERRUPTED, + SUCCESS, +) + +logger = logging.getLogger(__name__) + +# Windows PowerShell's own AppUserModelID, which Windows already trusts to show toasts. +WINDOWS_APP_ID = ( + "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe" +) +WINDOWS_SCRIPT = f""" +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) +$text = $xml.GetElementsByTagName('text') +$text.Item(0).AppendChild($xml.CreateTextNode($env:CR_TITLE)) | Out-Null +$text.Item(1).AppendChild($xml.CreateTextNode($env:CR_BODY)) | Out-Null +$toast = [Windows.UI.Notifications.ToastNotification]::new($xml) +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{WINDOWS_APP_ID}').Show($toast) +""" # noqa: E501 +MACOS_SCRIPT = ( + 'display notification (system attribute "CR_BODY") ' + 'with title (system attribute "CR_TITLE")' +) + +DESKTOP_TIMEOUT = 15 +WEBHOOK_TIMEOUT = 10 + +HEADLINES = { + SUCCESS: "finished", + DEFERRED: "finished, some files were skipped", + CANCELLED: "cancelled", + ABORTED: "stopped before starting", + INTERRUPTED: "interrupted", + FAILED: "failed", +} + + +def build_message(report): + """(title, body) describing how the run ended.""" + title = f"CommitRocket {HEADLINES.get(report.status, report.status)}" + + parts = [] + if report.batches: + mib = report.total_bytes / (1024 * 1024) + verb = "committed" if report.settings.get("no_push") else "pushed" + parts.append( + f"{len(report.batches)} batch(es) {verb}, {report.total_files} file(s), " + f"{mib:.1f} MiB" + ) + if report.deferred: + parts.append(f"{len(report.deferred)} file(s) skipped for size") + if report.error: + parts.append(report.error.strip().splitlines()[0]) + if report.target: + parts.append(f"to {report.target}") + if report.duration_seconds is not None: + parts.append(f"in {format_duration(report.duration_seconds)}") + + return title, "; ".join(parts) or "No changes were made." + + +def _run_quietly(command, env): + result = subprocess.run( + command, + env={**os.environ, **env}, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=DESKTOP_TIMEOUT, + ) + return result.returncode == 0 + + +def notify_desktop(title, body): + """Show a native notification. Returns the method used; 'bell' if none worked.""" + env = {"CR_TITLE": title, "CR_BODY": body} + try: + if sys.platform == "win32" and shutil.which("powershell"): + command = ["powershell", "-NoProfile", "-NonInteractive", "-Command"] + if _run_quietly(command + [WINDOWS_SCRIPT], env): + return "windows-toast" + elif sys.platform == "darwin" and shutil.which("osascript"): + if _run_quietly(["osascript", "-e", MACOS_SCRIPT], env): + return "macos-notification" + elif shutil.which("notify-send"): + command = ["notify-send", "--app-name=CommitRocket", title, body] + if _run_quietly(command, env): + return "notify-send" + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("Desktop notification failed: %s", exc) + + sys.stdout.write("\a") + sys.stdout.flush() + return "bell" + + +def is_valid_webhook(url): + parsed = urlparse(url) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def notify_webhook(url, title, body, report): + """POST a JSON summary. 'text' and 'content' suit Slack/Teams and Discord.""" + payload = { + "text": f"{title}: {body}", + "content": f"{title}: {body}", + "title": title, + "message": body, + "status": report.status, + "exit_code": report.exit_code, + "repo": report.repo, + "target": report.target, + "batches": len(report.batches), + "files": report.total_files, + "size_bytes": report.total_bytes, + "duration_seconds": report.duration_seconds, + } + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Title": title.encode("ascii", "replace").decode("ascii"), + "User-Agent": "commitrocket", + }, + method="POST", + ) + with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT) as response: + return response.status + + +def send_notifications(console, report, desktop=False, webhook=None): + """Notify about the finished run. Failures are reported but never raised.""" + title, body = build_message(report) + + if desktop: + method = notify_desktop(title, body) + logger.info("Desktop notification sent via %s", method) + + if webhook: + try: + status = notify_webhook(webhook, title, body, report) + logger.info("Webhook notified (HTTP %s)", status) + except (urllib.error.URLError, OSError, ValueError) as exc: + logger.warning("Webhook notification failed: %s", exc) + console.print(f"[yellow]Could not notify the webhook: {exc}[/yellow]") diff --git a/src/commitrocket/options.py b/src/commitrocket/options.py new file mode 100644 index 0000000..ad609f5 --- /dev/null +++ b/src/commitrocket/options.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field + +from commitrocket.batching import DETERMINISTIC +from commitrocket.lfs import LFS_THRESHOLD_MIB +from commitrocket.template import DEFAULT_TEMPLATE + + +@dataclass +class RunOptions: + """Everything a run needs, as parsed from the command line and config file.""" + + repo: str = "." + message: str = "" + number: int = 1 + force: bool = False + limit_mib: int = 100 + max_mib: int = 300 + batch_type: str = DETERMINISTIC + message_template: str = DEFAULT_TEMPLATE + remote: str | None = None + branch: str | None = None + no_push: bool = False + dry_run: bool = False + retries: int = 3 + post_buffer_mib: int | None = None + compression_threads: int | None = None + use_lfs: bool = False + lfs_threshold_mib: int = LFS_THRESHOLD_MIB + lfs_threads: int | None = None + lfs_batch_mib: int | None = None + lfs_commit_rules: bool = False + include: list = field(default_factory=list) + exclude: list = field(default_factory=list) + include_from: list = field(default_factory=list) + exclude_from: list = field(default_factory=list) + # Files the run itself writes; they are never committed, even inside the repo. + log_file: str | None = None + report: str | None = None diff --git a/src/commitrocket/push.py b/src/commitrocket/push.py new file mode 100644 index 0000000..8c9d75b --- /dev/null +++ b/src/commitrocket/push.py @@ -0,0 +1,243 @@ +import logging +import os +import re +import subprocess +import time + +from commitrocket.errors import PushError + +logger = logging.getLogger(__name__) + +PERCENT_RE = re.compile(r"(\d{1,3})%") + +UPLOAD_PHASE = "Uploading objects" +LFS_PHASE = "Uploading LFS objects" + +# git-lfs reports e.g. "Uploading LFS objects: 50% (1/2), 52 MB | 3.2 MB/s". +# The percentage counts files, so the transferred byte count is used instead. +LFS_BYTES_RE = re.compile( + r"Uploading LFS objects:.*?\)\s*,\s*([\d.]+)\s*([KMGT]?B)\b", re.IGNORECASE +) +LFS_UNITS = {"B": 1, "KB": 1000, "MB": 1000**2, "GB": 1000**3, "TB": 1000**4} + +# Output that means the host refused the push, so a retry would fail the same way. +NON_RETRYABLE_MARKERS = ( + "[rejected]", + "[remote rejected]", + "remote rejected", + "pre-receive hook declined", + "non-fast-forward", + "fetch first", + "gh001", + "large files detected", + "protected branch", + "authentication failed", + "permission denied", + "access denied", + "repository not found", + "could not read username", +) + +RETRY_BASE_SECONDS = 5 + +PHASES = ( + ("enumerating objects", "Enumerating objects"), + ("counting objects", "Counting objects"), + ("compressing objects", "Compressing objects"), + ("writing objects", UPLOAD_PHASE), + ("resolving deltas", "Resolving deltas"), + ("receiving objects", "Receiving objects"), +) + + +def parse_progress_line(line): + """Return (phase, percent) for a git progress line; percent may be None.""" + match = PERCENT_RE.search(line) + percent = min(100, int(match.group(1))) if match else None + + lower = line.lower() + phase = next((label for key, label in PHASES if key in lower), "Git push") + return phase, percent + + +def is_retryable(output): + """False for failures that trying again cannot fix (rejections, auth).""" + lower = output.lower() + return not any(marker in lower for marker in NON_RETRYABLE_MARKERS) + + +def parse_lfs_bytes(line): + """Bytes transferred so far according to a git-lfs upload line, else None.""" + match = LFS_BYTES_RE.search(line) + if not match: + return None + return int(float(match.group(1)) * LFS_UNITS[match.group(2).upper()]) + + +def push_with_retries( + repo, progress, push_task, console, retries=0, sleep=time.sleep, **kwargs +): + """push_with_progress, retried with growing waits on transient errors.""" + attempt = 0 + while True: + try: + return push_with_progress(repo, progress, push_task, console, **kwargs) + except PushError as exc: + if not exc.retryable or attempt >= retries: + raise + + delay = RETRY_BASE_SECONDS * 3**attempt + attempt += 1 + logger.warning( + "Push failed (attempt %d of %d), retrying in %d s: %s", + attempt, + retries, + delay, + exc, + ) + console.print( + f"[yellow]Push failed. Retrying in {delay} s " + f"(retry {attempt} of {retries})...[/yellow]" + ) + sleep(delay) + + +def push_with_progress( + repo, + progress, + push_task, + console, + on_upload=None, + batch_bytes=None, + remote=None, + refspec=None, +): + """Stream Git push progress into a live Rich progress task. + + on_upload, if given, is called with a rising percent (0-100) of the upload, + covering both the git-lfs upload and the pack transfer. batch_bytes is the + size of the batch being pushed and lets LFS byte counts become a percent. + """ + console.print("\n[bold cyan]Pushing commit...[/bold cyan]") + logger.info("Pushing %s", repo) + + progress.update( + push_task, + description="Git push: Starting", + total=100, + completed=0, + visible=True, + ) + + process = subprocess.Popen( + ["git", "-C", str(repo), "push", "--progress"] + + ([remote, refspec] if remote else []), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=0, + # Without a terminal git-lfs would otherwise print no progress at all. + env={**os.environ, "GIT_LFS_FORCE_PROGRESS": "1"}, + ) + + pending = b"" + messages = [] + best_percent = 0 + logged_phase = None + + try: + while True: + chunk = os.read(process.stdout.fileno(), 4096) + + if not chunk: + break + + pending += chunk + + # Git progress commonly uses carriage returns instead + # of newlines to refresh terminal output. + parts = re.split(rb"[\r\n]+", pending) + pending = parts.pop() + + for raw_line in parts: + line = raw_line.decode("utf-8", errors="replace").strip() + + if not line: + continue + + messages.append(line) + + phase, percent = parse_progress_line(line) + + uploaded = parse_lfs_bytes(line) + if uploaded is not None: + phase = LFS_PHASE + if batch_bytes: + percent = min(100, int(uploaded * 100 / batch_bytes)) + + # Progress lines repeat constantly; keep one per phase change + # plus the final one, and every other line. + if percent is None or phase != logged_phase or percent == 100: + logger.debug("push: %s", line) + logged_phase = phase + + if ( + on_upload + and percent is not None + and phase in (UPLOAD_PHASE, LFS_PHASE) + and percent > best_percent + ): + best_percent = percent + on_upload(percent) + + if percent is not None: + progress.update( + push_task, + description=phase, + completed=percent, + total=100, + visible=True, + ) + else: + progress.update(push_task, description=phase, visible=True) + + # Process any final output without a trailing delimiter. + if pending.strip(): + line = pending.decode("utf-8", errors="replace").strip() + if line: + messages.append(line) + + return_code = process.wait() + + except BaseException: + process.kill() + process.wait() + raise + + finally: + if process.stdout: + process.stdout.close() + + logger.info("git push finished with exit code %d", return_code) + + if return_code != 0: + progress.update(push_task, description="Git push failed", visible=True) + raise PushError( + "git push failed with exit code " + f"{return_code}:\n" + "\n".join(messages[-15:]), + retryable=is_retryable("\n".join(messages)), + ) + + progress.update( + push_task, + description="Git push complete", + completed=100, + total=100, + visible=True, + ) + + # Display non-progress messages such as the remote branch update. + for line in messages: + if not PERCENT_RE.search(line): + console.print(f"[dim]{line}[/dim]") + + progress.update(push_task, visible=False) diff --git a/src/commitrocket/report.py b/src/commitrocket/report.py new file mode 100644 index 0000000..e426fa9 --- /dev/null +++ b/src/commitrocket/report.py @@ -0,0 +1,118 @@ +import json +import os +import time +from dataclasses import asdict +from datetime import UTC, datetime +from pathlib import Path + +from commitrocket import __version__ + +SCHEMA_VERSION = 1 + +SUCCESS = "success" +DEFERRED = "completed_with_deferred_files" +DRY_RUN = "dry_run" +CANCELLED = "cancelled" +ABORTED = "aborted" +INTERRUPTED = "interrupted" +FAILED = "failed" + + +class RunReport: + """Collects what a run did, for --report and for notifications.""" + + def __init__(self, options=None): + self.started_at = datetime.now(UTC) + self._started = time.monotonic() + self.finished_at = None + self.duration_seconds = None + self.status = FAILED + self.exit_code = None + self.error = None + self.repo = None + self.branch = None + self.target = None + self.log_file = None + self.settings = asdict(options) if options else {} + self.resumed = {"staged_files": 0, "unpushed_commits": 0} + self.lfs = {"files": 0, "rules_commit": None} + self.batches = [] + self.deferred = [] + + def abort(self, reason): + self.status = ABORTED + self.error = reason + + def add_batch(self, number, message, files, size_bytes, lfs_files=0, commit=None): + entry = { + "number": number, + "message": message, + "commit": commit, + "file_count": len(files), + "size_bytes": size_bytes, + "lfs_file_count": lfs_files, + "pushed": False, + "verified": False, + "files": list(files), + } + self.batches.append(entry) + return entry + + def set_deferred(self, items): + self.deferred = [{"path": path, "size_bytes": size} for path, size in items] + + def finish(self, exit_code, log_file=None): + self.exit_code = exit_code + self.log_file = str(log_file) if log_file else None + self.finished_at = datetime.now(UTC) + self.duration_seconds = round(time.monotonic() - self._started, 3) + + @property + def total_files(self): + return sum(batch["file_count"] for batch in self.batches) + + @property + def total_bytes(self): + return sum(batch["size_bytes"] for batch in self.batches) + + def to_dict(self): + return { + "schema_version": SCHEMA_VERSION, + "tool": "commitrocket", + "version": __version__, + "status": self.status, + "exit_code": self.exit_code, + "error": self.error, + "started_at": self.started_at.isoformat(), + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + "duration_seconds": self.duration_seconds, + "repo": self.repo, + "branch": self.branch, + "target": self.target, + "dry_run": bool(self.settings.get("dry_run")), + "no_push": bool(self.settings.get("no_push")), + "settings": self.settings, + "resumed": self.resumed, + "lfs": self.lfs, + "totals": { + "batches": len(self.batches), + "files": self.total_files, + "size_bytes": self.total_bytes, + "deferred_files": len(self.deferred), + }, + "batches": self.batches, + "deferred": self.deferred, + "log_file": self.log_file, + } + + def write(self, path): + """Write the report as JSON, replacing any file there in one step.""" + path = Path(path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".tmp") + temporary.write_text( + json.dumps(self.to_dict(), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + os.replace(temporary, path) + return path.resolve() diff --git a/src/commitrocket/runner.py b/src/commitrocket/runner.py new file mode 100644 index 0000000..5f1e5e4 --- /dev/null +++ b/src/commitrocket/runner.py @@ -0,0 +1,674 @@ +import logging +import time +from pathlib import Path + +from rich.panel import Panel + +from commitrocket.batching import ( + MIB, + estimate_batch_count, + plan_batches, + select_batch, +) +from commitrocket.errors import CommitRocketError +from commitrocket.eta import EtaEstimator +from commitrocket.filters import FileFilter +from commitrocket.git import ( + LocalConfig, + find_large_unpushed_blobs, + get_file_size, + get_pending_files, + get_remote_url, + get_staged_files, + get_unpushed_commits, + resolve_target, + run_git, +) +from commitrocket.journal import Journal +from commitrocket.lfs import ( + HOST_LIMIT_MIB, + LfsSession, + committed_rule_lines, + ensure_git_lfs, + find_lfs_candidates, + is_lfs_available, +) +from commitrocket.push import push_with_retries +from commitrocket.report import ( + ABORTED, + CANCELLED, + DEFERRED, + DRY_RUN, + FAILED, + INTERRUPTED, + SUCCESS, + RunReport, +) +from commitrocket.template import render_message +from commitrocket.ui import ( + make_progress, + print_dry_run, + print_final_summary, + print_lfs_required, + print_resume, + print_run_info, + print_stuck_commits, +) +from commitrocket.verify import ( + expected_lfs_paths, + find_non_pointers, + unpushed_has_lfs_pointers, + verify_remote_head, +) + +logger = logging.getLogger(__name__) + + +def own_output_files(repo, options): + """Paths (relative to the repo) of the log and report files this run writes.""" + found = set() + for raw in (options.log_file, options.report): + if not raw: + continue + path = Path(raw).expanduser().resolve() + for candidate in (path, path.with_name(path.name + ".tmp")): + try: + found.add(candidate.relative_to(repo).as_posix()) + except ValueError: + pass # outside the repository + return found + + +def push_target(repo, progress, push_task, console, options, target, **kwargs): + """Push HEAD to the run's remote branch, retrying transient failures.""" + push_with_retries( + repo, + progress, + push_task, + console, + retries=options.retries, + remote=target.remote, + refspec=target.refspec, + **kwargs, + ) + + +def process_batches( + console, + repo, + progress, + task, + push_task, + options, + target, + number, + limit_bytes, + max_bytes, + list_pending, + lfs_files=frozenset(), + lfs_limit_bytes=0, + estimator=None, + journal=None, + check_lfs=None, + verify=None, + report=None, +): + """Commit (and push) batches until only deferred files remain.""" + batch_count = 0 + commit_number = number + + # Progress is measured in bytes so the ETA reflects the real amount of work. + total_work = sum( + size + for size in (get_file_size(repo, path) for path in list_pending()) + if size <= max_bytes + ) + done_bytes = 0 + progress.update(task, total=max(total_work, 1), completed=0) + if estimator: + estimator.start() + + selection = ( + limit_bytes, + max_bytes, + lfs_files, + lfs_limit_bytes, + options.batch_type, + ) + + while True: + pending = list_pending() + if not pending: + break + + selected, _, total_bytes = select_batch(repo, pending, *selection) + + if not selected: + # Only oversized files remain; report them at the end. + break + + remaining_estimate = estimate_batch_count(repo, pending, *selection) + estimated_total = max(batch_count + remaining_estimate, batch_count + 1) + + progress.update( + task, + description=( + f"Batch {batch_count + 1}/{estimated_total}: " + f"commit #{commit_number}" + ), + ) + + console.rule(f"[bold cyan]Batch {batch_count + 1}[/bold cyan]") + console.print( + f"Selected: {len(selected)} file(s), {total_bytes / MIB:.2f} MiB" + ) + logger.info( + "Batch %d/%d: %d file(s), %.2f MiB, commit #%d", + batch_count + 1, + estimated_total, + len(selected), + total_bytes / MIB, + commit_number, + ) + logger.debug("Batch %d files: %s", batch_count + 1, selected) + + commit_message = render_message( + options.message_template, + options.message, + commit_number, + batch_count + 1, + len(selected), + total_bytes / MIB, + selected[0], + ) + + # Lets an interrupted run recognise the files it left staged. + if journal: + journal.write(selected, commit_message, commit_number) + + run_git(repo, "add", "--", *selected) + + if not get_staged_files(repo): + raise CommitRocketError("No staged changes to commit.") + + run_git(repo, "commit", "-m", commit_message) + if journal: + journal.clear() + + entry = None + if report: + entry = report.add_batch( + commit_number, + commit_message, + selected, + total_bytes, + lfs_files=len(lfs_files.intersection(selected)), + commit=run_git(repo, "rev-parse", "HEAD").stdout.strip(), + ) + + # Catch files that did not become LFS pointers before uploading them. + if check_lfs: + check_lfs(selected) + + if not options.no_push: + push_target( + repo, + progress, + push_task, + console, + options, + target, + on_upload=lambda percent, base=done_bytes, size=total_bytes: ( + progress.update(task, completed=base + size * percent / 100) + ), + batch_bytes=total_bytes, + ) + + if entry: + entry["pushed"] = True + + if verify: + verify() + if entry: + entry["verified"] = True + + batch_count += 1 + commit_number += 1 + done_bytes += total_bytes + progress.update(task, completed=done_bytes) + + outcome = "committed" if options.no_push else "committed and pushed" + console.print(f"[green]Batch {batch_count} {outcome}.[/green]") + logger.info("Batch %d %s", batch_count, outcome) + + return batch_count + + +def run(console, options, report=None): + """Batch, commit and push pending changes. Returns a process exit code. + + The outcome is recorded in report (a RunReport) for --report and notifications. + """ + o = options + report = report or RunReport(options) + repo = Path(o.repo).expanduser().resolve() + + if not repo.is_dir(): + console.print(f"[red]Repository directory not found: {repo}[/red]") + report.abort(f"Repository directory not found: {repo}") + return 2 + + started_at = time.monotonic() + lfs = None + config = None + acting = False + completed = False + + try: + root = run_git(repo, "rev-parse", "--show-toplevel") + repo = Path(root.stdout.strip()).resolve() + + branch = run_git(repo, "branch", "--show-current").stdout.strip() + if not branch: + raise CommitRocketError( + "Detached HEAD. Check out a branch before continuing." + ) + + target = resolve_target(repo, branch, o.remote, o.branch) + report.repo = str(repo) + report.branch = branch + report.target = target.label + file_filter = FileFilter.from_options( + repo, o.include, o.exclude, o.include_from, o.exclude_from + ) + + # The run's own log and report must never be committed, or writing them + # would create a new batch forever. + own_files = own_output_files(repo, o) + + def raw_pending(): + return [p for p in get_pending_files(repo) if p not in own_files] + + def list_pending(): + return file_filter.apply(raw_pending()) + + logger.info( + "Run started: repo=%s branch=%s target=%s limit=%d MiB max=%d MiB " + "batch_type=%s use_lfs=%s dry_run=%s no_push=%s retries=%d", + repo, + branch, + target.label, + o.limit_mib, + o.max_mib, + o.batch_type, + o.use_lfs, + o.dry_run, + o.no_push, + o.retries, + ) + + # Files left staged by an interrupted run are ours; anything else is not. + journal = Journal(repo) + entry = journal.read() + staged = get_staged_files(repo) + resume_staged = [] + if staged: + if entry and set(staged) <= set(entry["files"]): + resume_staged = staged + else: + raise CommitRocketError( + f"{len(staged)} file(s) already staged. Commit or unstage " + "them before running this tool." + ) + elif entry and not o.dry_run: + journal.clear() + + # Earlier failed runs can leave commits that the host will reject. + stuck = find_large_unpushed_blobs(repo, HOST_LIMIT_MIB * MIB, target.revs) + if stuck: + logger.warning("Unpushed commits contain large files: %s", stuck) + print_stuck_commits( + console, + get_unpushed_commits(repo, target.revs), + stuck, + target.label, + HOST_LIMIT_MIB, + ) + report.abort( + f"Unpushed commits contain files over {HOST_LIMIT_MIB} MiB: " + + ", ".join(path for path, _ in stuck[:5]) + ) + return 2 + + unpushed = get_unpushed_commits(repo, target.revs) + start_number = ( + max(o.number, entry["number"] + 1) if resume_staged else o.number + ) + + print_run_info( + console, + repo, + branch, + target.label, + o.limit_mib, + o.max_mib, + start_number, + ) + + status = run_git(repo, "status", "--short").stdout.strip() + console.print(Panel(status or "(no pending changes)", title="Pending Changes")) + + if resume_staged or unpushed: + logger.info( + "Resuming: %d staged file(s), %d unpushed commit(s)", + len(resume_staged), + len(unpushed), + ) + print_resume(console, resume_staged, entry, unpushed) + report.resumed = { + "staged_files": len(resume_staged), + "unpushed_commits": len(unpushed), + } + + limit_bytes = o.limit_mib * MIB + max_bytes = o.max_mib * MIB + lfs_limit_bytes = (o.lfs_batch_mib or o.limit_mib) * MIB + + all_pending = raw_pending() + pending_files = list_pending() + if file_filter.active: + left_out = len(all_pending) - len(pending_files) + console.print( + f"[dim]Filters: {len(pending_files)} file(s) selected, " + f"{left_out} left out.[/dim]" + ) + logger.info("Filters kept %d, left out %d", len(pending_files), left_out) + + if not o.use_lfs: + over_host_limit = find_lfs_candidates( + repo, pending_files, HOST_LIMIT_MIB * MIB, max_bytes + ) + if over_host_limit: + # The host would reject the push, so stop before committing anything. + logger.warning( + "Files over the limit need --use-lfs: %s", over_host_limit + ) + print_lfs_required(console, repo, over_host_limit, HOST_LIMIT_MIB) + report.abort( + f"{len(over_host_limit)} file(s) over {HOST_LIMIT_MIB} MiB " + "need --use-lfs" + ) + return 2 + + lfs_paths = [] + lfs_scope = "" + if o.use_lfs: + lfs_paths = find_lfs_candidates( + repo, pending_files, o.lfs_threshold_mib * MIB, max_bytes + ) + report.lfs["files"] = len(lfs_paths) + if not lfs_paths: + lfs_scope = ( + f"--use-lfs: no files above {o.lfs_threshold_mib} MiB; " + "LFS not needed." + ) + else: + which = ( + f"All {len(lfs_paths)} file(s)" + if o.lfs_threshold_mib == 0 + else f"{len(lfs_paths)} file(s) above {o.lfs_threshold_mib} MiB" + ) + if o.lfs_commit_rules: + rule_count = len(committed_rule_lines(lfs_paths)) + lfs_scope = ( + f"{which} will be stored with Git LFS. {rule_count} rule(s) " + "will be committed to .gitattributes first, so they also " + "cover files of the same type added later." + ) + else: + lfs_scope = ( + f"{which} will be stored with Git LFS for this run only; " + "no LFS config is committed." + ) + if o.dry_run and not is_lfs_available(): + lfs_scope += " git-lfs is not installed and would be installed." + + if o.dry_run: + batches, deferred = plan_batches( + repo, + pending_files, + limit_bytes, + max_bytes, + frozenset(lfs_paths), + lfs_limit_bytes, + o.batch_type, + ) + messages = [ + render_message( + o.message_template, + o.message, + start_number + index, + index + 1, + len(paths), + size / MIB, + paths[0], + ) + for index, (paths, size) in enumerate(batches) + ] + logger.info("Dry run: %d batch(es) planned", len(batches)) + for index, (paths, size) in enumerate(batches): + report.add_batch( + start_number + index, + messages[index], + paths, + size, + lfs_files=len(set(paths).intersection(lfs_paths)), + ) + report.set_deferred(deferred) + report.status = DRY_RUN + print_dry_run( + console, batches, deferred, messages, lfs_paths, lfs_scope, o.no_push + ) + return 0 + + if lfs_scope: + console.print(f"[cyan]{lfs_scope}[/cyan]") + + if not o.force: + answer = ( + console.input( + "[yellow]Proceed with staging, committing, and " + f"{'finishing' if o.no_push else 'pushing'}? [y/N][/yellow] " + ) + .strip() + .lower() + ) + if answer not in {"y", "yes"}: + console.print("[yellow]Cancelled.[/yellow]") + logger.info("Cancelled by user at the confirmation prompt") + report.status = CANCELLED + return 1 + + # Unpushed LFS pointers need the LFS hooks, or a push would publish + # pointers whose files were never uploaded. + resume_needs_lfs = ( + not o.no_push + and bool(unpushed) + and unpushed_has_lfs_pointers(repo, target.revs) + ) + if lfs_paths or resume_needs_lfs: + ensure_git_lfs(console, o.force) + + def verify(): + verify_remote_head(repo, target.remote, target.ref, o.retries) + + def check_lfs(selected): + bad = find_non_pointers(repo, expected_lfs_paths(selected, lfs_paths)) + if bad: + raise CommitRocketError( + f"{len(bad)} file(s) were committed as ordinary files instead of " + f"LFS pointers (first: {bad[0]}). Nothing was pushed. Drop the " + f"commit with 'git reset --mixed {target.label}' and report this." + ) + + acting = True + try: + # Repository-local settings only; the previous values are restored below. + config = LocalConfig(repo) + if o.compression_threads is not None: + config.set("pack.threads", o.compression_threads) + logger.info("Set local pack.threads=%d", o.compression_threads) + if o.lfs_threads is not None: + config.set("lfs.concurrenttransfers", o.lfs_threads) + logger.info("Set local lfs.concurrenttransfers=%d", o.lfs_threads) + if o.post_buffer_mib is not None: + url = get_remote_url(repo, target.remote).lower() + if url.startswith(("http://", "https://")): + config.set("http.postBuffer", o.post_buffer_mib * MIB) + logger.info("Set local http.postBuffer=%d MiB", o.post_buffer_mib) + else: + console.print( + "[dim]--post-buffer-mib ignored: remote is not HTTP(S).[/dim]" + ) + logger.info("--post-buffer-mib ignored for non-HTTP remote") + + if lfs_paths or resume_needs_lfs: + lfs = LfsSession(repo) + if lfs_paths and not o.lfs_commit_rules: + lfs.enable(lfs_paths, all_files=o.lfs_threshold_mib == 0) + logger.info( + "LFS enabled locally for %d file(s) (threshold %d MiB)", + len(lfs_paths), + o.lfs_threshold_mib, + ) + logger.debug("LFS files: %s", lfs_paths) + else: + lfs.install_hooks() + + with make_progress(console) as progress: + estimator = EtaEstimator() + task = progress.add_task( + "Preparing batches", total=1, estimator=estimator + ) + push_task = progress.add_task("Git push", total=100, visible=False) + + if resume_staged: + run_git(repo, "commit", "-m", entry["message"]) + journal.clear() + logger.info("Committed resumed batch: %s", entry["message"]) + if find_large_unpushed_blobs( + repo, HOST_LIMIT_MIB * MIB, target.revs + ): + raise CommitRocketError( + "The resumed commit contains files over " + f"{HOST_LIMIT_MIB} MiB. Drop it with " + f"'git reset --mixed {target.label}' and run again." + ) + + if (resume_staged or unpushed) and not o.no_push: + push_target(repo, progress, push_task, console, o, target) + verify() + console.print("[green]Resumed commits pushed.[/green]") + + if o.lfs_commit_rules and lfs_paths: + if lfs.write_committed_rules(lfs_paths): + run_git(repo, "add", "--", ".gitattributes") + rules_message = f"{o.message.rstrip()} (LFS rules)" + run_git(repo, "commit", "-m", rules_message) + logger.info("Committed LFS rules: %s", rules_message) + report.lfs["rules_commit"] = run_git( + repo, "rev-parse", "HEAD" + ).stdout.strip() + console.print("[cyan]Committed LFS rules.[/cyan]") + push_target(repo, progress, push_task, console, o, target) + verify() + + batch_count = process_batches( + console, + repo, + progress, + task, + push_task, + o, + target, + start_number, + limit_bytes, + max_bytes, + list_pending, + frozenset(lfs_paths), + lfs_limit_bytes, + estimator, + journal, + check_lfs if lfs_paths else None, + verify, + report, + ) + completed = True + finally: + if lfs: + keep_hooks = False + if not completed: + try: + keep_hooks = unpushed_has_lfs_pointers(repo, target.revs) + except (RuntimeError, OSError): + keep_hooks = True + lfs.close(uninstall_hooks=not keep_hooks) + if keep_hooks: + console.print( + "[yellow]Git LFS hooks were left installed because unpushed " + "commits contain LFS files. Run CommitRocket again to push " + "them; a plain 'git push' only works while the hooks " + "stay.[/yellow]" + ) + logger.warning("LFS hooks kept: unpushed LFS commits remain") + else: + logger.info("Local LFS setup removed") + if config: + config.restore() + logger.info("Local git config restored") + + code = print_final_summary( + console, + repo, + batch_count, + started_at, + o.max_mib, + pending_files=list_pending, + pushed=not o.no_push, + ) + report.set_deferred( + (path, size) + for path, size in ((p, get_file_size(repo, p)) for p in list_pending()) + if size > max_bytes + ) + report.status = SUCCESS if code == 0 else DEFERRED + logger.info( + "Finished: %d batch(es) in %.1f s, exit code %d", + batch_count, + time.monotonic() - started_at, + code, + ) + return code + + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted by user.[/yellow]") + report.status = INTERRUPTED + report.error = "Interrupted by user" + if acting: + console.print( + "[dim]Run the same command again to resume from where it " + "stopped.[/dim]" + ) + logger.warning("Interrupted by user") + return 130 + except (RuntimeError, OSError) as exc: + console.print(f"\n[bold red]Error:[/bold red] {exc}") + report.status = ABORTED if not acting else FAILED + report.error = str(exc) + if acting: + console.print( + "[dim]Run the same command again to resume from where it " + "stopped.[/dim]" + ) + logger.error("Run failed: %s", exc, exc_info=True) + return 1 diff --git a/src/commitrocket/template.py b/src/commitrocket/template.py new file mode 100644 index 0000000..d52e810 --- /dev/null +++ b/src/commitrocket/template.py @@ -0,0 +1,37 @@ +from datetime import date + +DEFAULT_TEMPLATE = "{message} #{n}" + +PLACEHOLDERS = ("message", "n", "batch", "files", "size", "first", "date") + + +def render_message(template, message, n, batch, files, size_mib, first): + """Fill in a commit message template for one batch. + + {message} the --message text, {n} the commit number, {batch} the position + in this run, {files} the file count, {size} the size in MiB, {first} the + first file's path and {date} today's date (YYYY-MM-DD). + """ + return template.format( + message=message.rstrip(), + n=n, + batch=batch, + files=files, + size=size_mib, + first=first, + date=date.today().isoformat(), + ).strip() + + +def validate_template(template): + """Return an error string if the template can't be rendered, else None.""" + try: + text = render_message(template, "msg", 1, 1, 1, 1.0, "a.txt") + except (KeyError, IndexError, ValueError, AttributeError) as exc: + return ( + f"invalid --message-template ({exc!r}); " + f"available placeholders: {', '.join(PLACEHOLDERS)}" + ) + if not text: + return "--message-template renders an empty commit message" + return None diff --git a/src/commitrocket/ui.py b/src/commitrocket/ui.py new file mode 100644 index 0000000..2c2cb89 --- /dev/null +++ b/src/commitrocket/ui.py @@ -0,0 +1,214 @@ +import time + +from rich.panel import Panel +from rich.progress import ( + BarColumn, + Progress, + ProgressColumn, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeElapsedColumn, +) +from rich.table import Table +from rich.text import Text + +from commitrocket.batching import MIB +from commitrocket.eta import format_duration +from commitrocket.git import get_file_size, get_pending_files + + +class EtaColumn(ProgressColumn): + """Shows the run's own ETA for tasks created with an `estimator` field.""" + + def render(self, task): + estimator = task.fields.get("estimator") + if estimator is None: + return Text("") + + remaining = estimator.estimate(task.completed, task.total or 0) + if remaining is None: + return Text("ETA estimating…", style="dim") + return Text(f"ETA {format_duration(remaining)}", style="progress.remaining") + + +def make_progress(console): + return Progress( + SpinnerColumn(), + TextColumn("[bold cyan]{task.description}"), + BarColumn(), + TaskProgressColumn(), + TimeElapsedColumn(), + EtaColumn(), + console=console, + ) + + +def print_run_info(console, repo, branch, upstream, limit_mib, max_mib, number): + info = Table.grid(padding=(0, 2)) + info.add_column(style="bold cyan") + info.add_column() + info.add_row("Repository", str(repo)) + info.add_row("Branch", branch) + info.add_row("Push target", upstream) + info.add_row("Normal batch limit", f"{limit_mib} MiB") + info.add_row("Maximum file size", f"{max_mib} MiB") + info.add_row("Starting commit #", str(number)) + + console.print(Panel(info, title="Git Batch Push", border_style="cyan")) + + +def print_lfs_required(console, repo, paths, threshold_mib): + table = Table(title=f"Files over {threshold_mib} MiB", show_lines=True) + table.add_column("File", overflow="fold") + table.add_column("Size (MiB)", justify="right") + + for path in sorted(paths): + table.add_row(path, f"{get_file_size(repo, path) / MIB:.2f}") + + console.print(table) + console.print( + f"\n[bold red]These files exceed the {threshold_mib} MiB limit of most " + "Git hosts and the push would be rejected.[/bold red]\n" + "[yellow]Nothing was committed. Run again with [bold]--use-lfs[/bold] " + "to store them with Git LFS.[/yellow]" + ) + + +def print_resume(console, staged, entry, unpushed): + lines = [] + if staged: + lines.append( + f"{len(staged)} file(s) were staged by an interrupted run and will be " + f"committed as: {entry['message']}" + ) + if unpushed: + lines.append(f"{len(unpushed)} unpushed commit(s) will be pushed first:") + lines.extend(f" {commit}" for commit in unpushed[:10]) + if len(unpushed) > 10: + lines.append(f" ... and {len(unpushed) - 10} more") + + console.print(Panel("\n".join(lines), title="Resuming", border_style="yellow")) + + +def print_dry_run( + console, batches, deferred, messages, lfs_files, lfs_scope, no_push=False +): + table = Table(title="Dry run: planned batches", show_lines=True) + table.add_column("Batch", justify="right") + table.add_column("Commit message") + table.add_column("Files", justify="right") + table.add_column("Size (MiB)", justify="right") + table.add_column("Via LFS", justify="right") + + total_files = 0 + total_bytes = 0 + for index, (paths, size) in enumerate(batches): + lfs_count = len(set(paths) & set(lfs_files)) + table.add_row( + str(index + 1), + messages[index], + str(len(paths)), + f"{size / MIB:.2f}", + str(lfs_count) if lfs_count else "-", + ) + total_files += len(paths) + total_bytes += size + + console.print(table) + console.print( + f"{len(batches)} batch(es), {total_files} file(s), " + f"{total_bytes / MIB:.2f} MiB in total." + ) + if lfs_scope: + console.print(f"[cyan]{lfs_scope}[/cyan]") + + if deferred: + details = Table(title="Would be deferred (over --max-mib)", show_lines=True) + details.add_column("File", overflow="fold") + details.add_column("Size (MiB)", justify="right") + for path, size in sorted(deferred): + details.add_row(path, f"{size / MIB:.2f}") + console.print(details) + + if no_push: + console.print("[dim]--no-push: batches would be committed, not pushed.[/dim]") + + console.print( + "\n[bold green]Dry run only: nothing was staged, committed, pushed or " + "changed.[/bold green]" + ) + + +def print_stuck_commits(console, commits, blobs, upstream, threshold_mib): + table = Table(title=f"Unpushed files over {threshold_mib} MiB", show_lines=True) + table.add_column("File", overflow="fold") + table.add_column("Size (MiB)", justify="right") + + for path, size in blobs: + table.add_row(path, f"{size / MIB:.2f}") + + console.print(table) + console.print(f"\n[bold]{len(commits)} unpushed local commit(s):[/bold]") + for commit in commits: + console.print(f" {commit}") + + console.print( + f"\n[bold red]These commits already contain files over {threshold_mib} " + "MiB, so pushing them would be rejected.[/bold red]\n" + "[yellow]Nothing was changed. To drop those commits and keep your files " + "as untracked changes, run:[/yellow]\n\n" + f" git reset --mixed {upstream}\n\n" + "[yellow]then run CommitRocket again (the commits stay recoverable via " + "'git reflog').[/yellow]" + ) + + +def print_final_summary( + console, repo, batch_count, started_at, max_mib, pending_files=None, pushed=True +): + """Report files still deferred after all eligible batches finish.""" + elapsed = time.monotonic() - started_at + pending = pending_files() if pending_files else get_pending_files(repo) + + deferred = [] + for path in pending: + size = get_file_size(repo, path) + if size > max_mib * MIB: + deferred.append((path, size)) + + table = Table(title="Execution Summary", show_lines=True) + table.add_column("Metric", style="cyan") + table.add_column("Value", justify="right") + + label = "Batches pushed" if pushed else "Batches committed" + table.add_row(label, str(batch_count)) + table.add_row("Elapsed time", f"{elapsed:.1f} seconds") + table.add_row("Deferred files", str(len(deferred))) + + console.print() + console.print(Panel(table, border_style="green" if not deferred else "yellow")) + + if not deferred: + console.print("[bold green]All eligible files processed.[/bold green]") + return 0 + + details = Table(title="Deferred Files — Final Report", show_lines=True) + details.add_column("File", overflow="fold") + details.add_column("Size (MiB)", justify="right") + details.add_column("Reason", overflow="fold") + + for path, size in sorted(deferred): + details.add_row( + path, + f"{size / MIB:.2f}", + f"Exceeds --max-mib ({max_mib} MiB)", + ) + + console.print(details) + console.print( + "\n[yellow]Increase --max-mib to include these files. " + "Deferred files were not committed by this run.[/yellow]" + ) + + return 2 diff --git a/src/commitrocket/verify.py b/src/commitrocket/verify.py new file mode 100644 index 0000000..0a72bb5 --- /dev/null +++ b/src/commitrocket/verify.py @@ -0,0 +1,129 @@ +import logging +import subprocess +import time +from pathlib import PurePosixPath + +from commitrocket.errors import CommitRocketError, GitError +from commitrocket.git import get_remote_head, run_git +from commitrocket.lfs import LFS_EXCLUDED + +logger = logging.getLogger(__name__) + +POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1" +MAX_POINTER_BYTES = 1024 + + +def verify_remote_head(repo, remote, ref, retries=0, sleep=time.sleep): + """Raise unless the remote's branch points at the local HEAD.""" + local = run_git(repo, "rev-parse", "HEAD").stdout.strip() + + attempt = 0 + while True: + try: + remote_head = get_remote_head(repo, remote, ref) + break + except GitError: + if attempt >= retries: + raise + attempt += 1 + sleep(2**attempt) + + if remote_head != local: + raise CommitRocketError( + f"Push verification failed: {remote} {ref} is at " + f"{(remote_head or 'nothing')[:10]} but local HEAD is {local[:10]}." + ) + logger.info("Verified %s %s is at %s", remote, ref, local[:10]) + + +def expected_lfs_paths(selected, lfs_paths): + """Files in a batch that should have been stored as LFS pointers.""" + wanted = set(lfs_paths) + return [ + path + for path in selected + if path in wanted and PurePosixPath(path).name not in LFS_EXCLUDED + ] + + +def _git_cat_file(repo, mode, specs): + result = subprocess.run( + ["git", "-C", str(repo), "cat-file", mode], + input="".join(f"{spec}\n" for spec in specs).encode("utf-8"), + capture_output=True, + ) + if result.returncode != 0: + error = result.stderr.decode(errors="replace") + raise GitError(f"git cat-file failed:\n{error}") + return result.stdout + + +def _is_pointer(content): + return content.startswith(POINTER_PREFIX) + + +def unpushed_has_lfs_pointers(repo, revs): + """True if commits not yet pushed contain LFS pointer files. + + Pushing those without the LFS hooks installed would publish pointers whose + data was never uploaded. + """ + objects = run_git(repo, "rev-list", "--objects", *revs).stdout + if not objects.strip(): + return False + + info = _git_cat_file( + repo, + "--batch-check=%(objecttype) %(objectname) %(objectsize) %(rest)", + objects.splitlines(), + ) + small = [] + for line in info.decode("utf-8", errors="replace").splitlines(): + kind, name, size = (line.split(" ", 3) + ["", "", ""])[:3] + if kind == "blob" and size.isdigit() and int(size) <= MAX_POINTER_BYTES: + small.append(name) + + if not small: + return False + + data = _git_cat_file(repo, "--batch", small) + position = 0 + for _ in small: + newline = data.index(b"\n", position) + size = int(data[position:newline].split()[2]) + content = data[newline + 1 : newline + 1 + size] + position = newline + 1 + size + 1 + if _is_pointer(content): + return True + return False + + +def find_non_pointers(repo, paths): + """Paths whose committed content in HEAD is not an LFS pointer.""" + if not paths: + return [] + + specs = [f"HEAD:{path}" for path in paths] + sizes = _git_cat_file(repo, "--batch-check=%(objectsize)", specs) + sizes = sizes.decode("utf-8", errors="replace").splitlines() + + bad = [] + small = [] + for path, size in zip(paths, sizes, strict=True): + if size.endswith("missing") or int(size) > MAX_POINTER_BYTES: + bad.append(path) + else: + small.append(path) + + if small: + data = _git_cat_file(repo, "--batch", [f"HEAD:{path}" for path in small]) + position = 0 + for path in small: + newline = data.index(b"\n", position) + size = int(data[position:newline].split()[2]) + content = data[newline + 1 : newline + 1 + size] + position = newline + 1 + size + 1 + if not content.startswith(POINTER_PREFIX): + bad.append(path) + + return bad diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0452601 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +import subprocess + +import pytest + + +@pytest.fixture +def git_repo(tmp_path): + """An empty Git repository with a configured identity. + + It lives in a "repo" subfolder, so tmp_path itself is free for logs, reports + and other files that must stay outside the repository. + """ + repo = tmp_path / "repo" + repo.mkdir() + + def git(*args): + subprocess.run(["git", "-C", str(repo), *args], check=True) + + git("init", "-q") + git("config", "user.email", "test@example.com") + git("config", "user.name", "Test") + return repo diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py new file mode 100644 index 0000000..eeb3ea3 --- /dev/null +++ b/tests/integration/test_e2e.py @@ -0,0 +1,166 @@ +"""End-to-end runs against a local bare repository acting as the remote.""" + +import json +import subprocess + +import pytest + +from commitrocket.cli import main +from commitrocket.git import run_git + +pytestmark = pytest.mark.integration + +KIB = 1024 + + +@pytest.fixture +def project(git_repo, tmp_path_factory): + """A repo with one pushed commit and an upstream, plus a bare 'origin'.""" + remote = tmp_path_factory.mktemp("origin") + subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True) + run_git(git_repo, "remote", "add", "origin", str(remote)) + + (git_repo / "README.md").write_text("start") + run_git(git_repo, "add", "-A") + run_git(git_repo, "commit", "-m", "initial") + branch = run_git(git_repo, "branch", "--show-current").stdout.strip() + run_git(git_repo, "push", "-u", "origin", branch) + return git_repo, remote, branch + + +def add_files(repo, count, size=600 * KIB): + for index in range(count): + (repo / f"file{index}.bin").write_bytes(b"x" * size) + + +def run_cli(repo, tmp_path, *extra): + argv = [ + "--repo", + str(repo), + "--message", + "Data", + "--log-file", + str(tmp_path / "run.log"), + *extra, + ] + return main(argv) + + +def test_full_run_pushes_every_batch_and_writes_a_report(project, tmp_path): + repo, remote, branch = project + add_files(repo, 3) + report_path = tmp_path / "report.json" + + code = run_cli( + repo, tmp_path, "--force", "--limit-mib", "1", "--report", str(report_path) + ) + + assert code == 0 + report = json.loads(report_path.read_text(encoding="utf-8")) + assert report["status"] == "success" + assert [b["message"] for b in report["batches"]] == [ + "Data #1", + "Data #2", + "Data #3", + ] + assert all(b["pushed"] and b["verified"] for b in report["batches"]) + + local = run_git(repo, "rev-parse", "HEAD").stdout.strip() + remote_head = run_git(remote, "rev-parse", f"refs/heads/{branch}").stdout.strip() + assert local == remote_head + assert run_git(repo, "status", "--porcelain").stdout.strip() == "" + + +def test_dry_run_changes_nothing(project, tmp_path): + repo, _, _ = project + add_files(repo, 2) + head = run_git(repo, "rev-parse", "HEAD").stdout.strip() + report_path = tmp_path / "dry.json" + + code = run_cli( + repo, tmp_path, "--dry-run", "--limit-mib", "1", "--report", str(report_path) + ) + + assert code == 0 + assert run_git(repo, "rev-parse", "HEAD").stdout.strip() == head + assert get_staged(repo) == [] + report = json.loads(report_path.read_text(encoding="utf-8")) + assert report["status"] == "dry_run" + assert report["totals"]["batches"] == 2 + assert not any(b["pushed"] for b in report["batches"]) + + +def test_no_push_commits_locally_and_a_later_run_pushes(project, tmp_path): + repo, remote, branch = project + add_files(repo, 2) + + assert run_cli(repo, tmp_path, "--force", "--limit-mib", "1", "--no-push") == 0 + local = run_git(repo, "rev-parse", "HEAD").stdout.strip() + behind = run_git(remote, "rev-parse", f"refs/heads/{branch}").stdout.strip() + assert local != behind + + # Nothing is pending now; the run only pushes the saved commits. + assert run_cli(repo, tmp_path, "--force") == 0 + pushed = run_git(remote, "rev-parse", f"refs/heads/{branch}").stdout.strip() + assert pushed == local + + +def test_log_and_report_inside_the_repo_are_never_committed(project, tmp_path): + """Writing them used to create a new batch forever.""" + repo, _, _ = project + add_files(repo, 2) + log = repo / "run.log" + report = repo / "out" / "report.json" + + code = main( + [ + "--repo", + str(repo), + "--message", + "Data", + "--force", + "--limit-mib", + "1", + "--log-file", + str(log), + "--report", + str(report), + ] + ) + + assert code == 0 + assert log.exists() and report.exists() + tracked = run_git(repo, "ls-files").stdout.split() + assert "run.log" not in tracked + assert "out/report.json" not in tracked + assert sorted(t for t in tracked if t.endswith(".bin")) == [ + "file0.bin", + "file1.bin", + ] + assert json.loads(report.read_text(encoding="utf-8"))["totals"]["batches"] == 2 + + +def test_include_and_exclude_filters(project, tmp_path): + repo, _, _ = project + (repo / "a.mp4").write_bytes(b"x" * KIB) + (repo / "b.txt").write_bytes(b"x" * KIB) + (repo / "c.mp4").write_bytes(b"x" * KIB) + + code = run_cli( + repo, tmp_path, "--force", "--include", "*.mp4", "--exclude", "c.mp4" + ) + + assert code == 0 + committed = run_git(repo, "show", "--name-only", "--format=", "HEAD").stdout + assert committed.split() == ["a.mp4"] + assert sorted(untracked(repo)) == ["b.txt", "c.mp4"] + + +def get_staged(repo): + out = run_git(repo, "diff", "--cached", "--name-only").stdout + return out.split() + + +def untracked(repo): + out = run_git(repo, "ls-files", "--others", "--exclude-standard").stdout + return out.split() diff --git a/tests/test_batching.py b/tests/test_batching.py new file mode 100644 index 0000000..f4317df --- /dev/null +++ b/tests/test_batching.py @@ -0,0 +1,112 @@ +from commitrocket.batching import ( + MIB, + estimate_batch_count, + plan_batches, + select_batch, +) + + +def make_files(root, sizes): + for name, size in sizes.items(): + (root / name).write_bytes(b"x" * size) + return list(sizes) + + +def test_packs_small_files_up_to_limit(tmp_path): + files = make_files(tmp_path, {"a": MIB, "b": MIB, "c": MIB}) + selected, deferred, total = select_batch(tmp_path, files, 2 * MIB, 6 * MIB) + assert selected == ["a", "b"] + assert deferred == [] + assert total == 2 * MIB + + +def test_large_file_goes_alone(tmp_path): + files = make_files(tmp_path, {"small": MIB, "big": 3 * MIB}) + selected, _, total = select_batch(tmp_path, files, 2 * MIB, 6 * MIB) + assert selected == ["big"] + assert total == 3 * MIB + + +def test_oversized_file_is_deferred(tmp_path): + files = make_files(tmp_path, {"huge": 7 * MIB, "small": MIB}) + selected, deferred, _ = select_batch(tmp_path, files, 2 * MIB, 6 * MIB) + assert selected == ["small"] + assert deferred == [("huge", 7 * MIB)] + + +def test_lfs_files_are_grouped_up_to_lfs_limit(tmp_path): + files = make_files(tmp_path, {"a": 3 * MIB, "b": 3 * MIB, "c": 3 * MIB}) + selected, _, total = select_batch( + tmp_path, files, 2 * MIB, 9 * MIB, frozenset(files), 7 * MIB + ) + assert selected == ["a", "b"] + assert total == 6 * MIB + + +def test_lfs_group_always_takes_one_file(tmp_path): + files = make_files(tmp_path, {"a": 3 * MIB}) + selected, _, _ = select_batch( + tmp_path, files, 2 * MIB, 9 * MIB, frozenset(files), MIB + ) + assert selected == ["a"] + + +# The batch-type tests count in KiB so they don't write hundreds of MiB to disk. +K = 1024 + + +def batches(tmp_path, sizes, batch_type, limit=100, max_=300): + files = make_files(tmp_path, sizes) + remaining = list(files) + result = [] + while remaining: + selected, _, _ = select_batch( + tmp_path, remaining, limit * K, max_ * K, batch_type=batch_type + ) + if not selected: + break + result.append(selected) + remaining = [path for path in remaining if path not in selected] + return result + + +SIZES = {"A": 60 * K, "B": 50 * K, "C": 30 * K, "D": 20 * K} + + +def test_deterministic_skips_files_that_do_not_fit(tmp_path): + assert batches(tmp_path, SIZES, "deterministic") == [["A", "C"], ["B", "D"]] + + +def test_sequential_stops_at_first_file_that_does_not_fit(tmp_path): + assert batches(tmp_path, SIZES, "sequential") == [["A"], ["B", "C", "D"]] + + +def test_soft_may_cross_the_limit(tmp_path): + assert batches(tmp_path, SIZES, "soft") == [["A", "B"], ["C", "D"]] + + +def test_soft_respects_max_total(tmp_path): + sizes = {"A": 60 * K, "B": 50 * K, "C": 30 * K} + result = batches(tmp_path, sizes, "soft", limit=100, max_=100) + assert result == [["A"], ["B", "C"]] + + +def test_in_order_types_keep_large_file_alone(tmp_path): + sizes = {"big": 150 * K, "A": 10 * K} + assert batches(tmp_path, sizes, "sequential") == [["big"], ["A"]] + assert batches(tmp_path, sizes, "soft") == [["big"], ["A"]] + + +def test_plan_batches_matches_execution_and_reports_deferred(tmp_path): + sizes = {**SIZES, "huge": 400 * K} + files = make_files(tmp_path, sizes) + plan, deferred = plan_batches(tmp_path, files, 100 * K, 300 * K) + + assert [paths for paths, _ in plan] == [["A", "C"], ["B", "D"]] + assert [total for _, total in plan] == [90 * K, 70 * K] + assert deferred == [("huge", 400 * K)] + + +def test_estimate_matches_selection(tmp_path): + files = make_files(tmp_path, {"a": MIB, "b": MIB, "c": MIB, "big": 3 * MIB}) + assert estimate_batch_count(tmp_path, files, 2 * MIB, 6 * MIB) == 3 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..39545a1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,43 @@ +import pytest + +from commitrocket.cli import main +from commitrocket.options import RunOptions +from commitrocket.runner import own_output_files + + +def test_own_output_files_are_found_only_inside_the_repo(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + options = RunOptions( + log_file=str(repo / "logs" / "run.log"), + report=str(tmp_path / "elsewhere" / "report.json"), + ) + assert own_output_files(repo.resolve(), options) == { + "logs/run.log", + "logs/run.log.tmp", + } + + options = RunOptions(report=str(repo / "report.json")) + assert own_output_files(repo.resolve(), options) == { + "report.json", + "report.json.tmp", + } + assert own_output_files(repo.resolve(), RunOptions()) == set() + + +@pytest.mark.parametrize( + "argv", + [ + ["--message", "m", "--no-push", "--use-lfs"], + ["--message", "m", "--lfs-commit-rules"], + ["--message", "m", "--message-template", "{nope}"], + ["--message", "m", "--lfs-threshold-mib", "150"], + ["--message", "m", "--retries", "-1"], + [], + ], +) +def test_invalid_combinations_exit_with_usage_error(argv, tmp_path, capsys): + with pytest.raises(SystemExit) as exit_info: + main(["--repo", str(tmp_path), *argv]) + assert exit_info.value.code == 2 + assert "error" in capsys.readouterr().err diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..762d8fc --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,78 @@ +import pytest + +from commitrocket.cli import build_parser +from commitrocket.config import ConfigError, apply_config, find_config + + +def parse(tmp_path, toml, argv=(), name="commitrocket.toml"): + (tmp_path / name).write_text(toml) + parser = build_parser() + path, values = find_config(tmp_path) + apply_config(parser, values, path) + return parser.parse_args(list(argv)) + + +def test_no_config_found(tmp_path): + assert find_config(tmp_path) == (None, {}) + + +def test_values_become_defaults(tmp_path): + args = parse( + tmp_path, + 'message = "Videos"\nlimit-mib = 50\nuse_lfs = true\n' + 'include = ["*.mp4"]\nbatch-type = "soft"\n', + ) + assert args.message == "Videos" + assert args.limit_mib == 50 + assert args.use_lfs is True + assert args.include == ["*.mp4"] + assert args.batch_type == "soft" + + +def test_command_line_wins_and_lists_combine(tmp_path): + args = parse( + tmp_path, + 'message = "Videos"\nlimit-mib = 50\ninclude = ["*.mp4"]\n', + ["--limit-mib", "20", "--include", "*.zip"], + ) + assert args.limit_mib == 20 + assert args.include == ["*.mp4", "*.zip"] + + +def test_pyproject_table_is_used(tmp_path): + toml = '[project]\nname = "x"\n\n[tool.commitrocket]\nretries = 7\n' + args = parse(tmp_path, toml, name="pyproject.toml") + assert args.retries == 7 + + +def test_commitrocket_toml_beats_pyproject(tmp_path): + (tmp_path / "pyproject.toml").write_text("[tool.commitrocket]\nretries = 7\n") + args = parse(tmp_path, "retries = 2\n") + assert args.retries == 2 + + +def test_pyproject_without_table_is_ignored(tmp_path): + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + assert find_config(tmp_path) == (None, {}) + + +@pytest.mark.parametrize( + "toml", + [ + "unknown-option = 1\n", + 'limit-mib = "lots"\n', + 'force = "yes"\n', + 'batch-type = "random"\n', + "include = 5\n", + 'repo = "elsewhere"\n', + "this is not toml\n", + ], +) +def test_bad_config_is_rejected(tmp_path, toml): + with pytest.raises(ConfigError): + parse(tmp_path, toml) + + +def test_explicit_config_must_exist(tmp_path): + with pytest.raises(ConfigError): + find_config(tmp_path, tmp_path / "missing.toml") diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..6f91c55 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,34 @@ +from rich.console import Console + +from commitrocket.doctor import FAIL, PASS, collect_checks, run_doctor + + +def statuses(results): + return {name: status for status, name, _ in results} + + +def test_repository_without_upstream_fails_the_push_target_check(git_repo): + (git_repo / "a.txt").write_text("x") + results = collect_checks(git_repo, offline=True) + + found = statuses(results) + assert found["Git"] == PASS + assert found["Repository"] == PASS + assert found["Push target"] == FAIL + + +def test_explicit_remote_is_enough_for_the_push_target(git_repo): + results = collect_checks(git_repo, remote="origin", offline=True) + assert statuses(results)["Push target"] == PASS + assert statuses(results)["Remote access"] == "skip" + + +def test_not_a_repository(tmp_path): + found = statuses(collect_checks(tmp_path, offline=True)) + assert found["Repository"] == FAIL + + +def test_run_doctor_exit_code(git_repo): + console = Console(quiet=True) + assert run_doctor(console, git_repo, offline=True) == 1 + assert run_doctor(console, git_repo, remote="origin", offline=True) == 0 diff --git a/tests/test_eta.py b/tests/test_eta.py new file mode 100644 index 0000000..bfff491 --- /dev/null +++ b/tests/test_eta.py @@ -0,0 +1,44 @@ +from commitrocket.eta import EtaEstimator, format_duration + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + +def test_no_estimate_before_start_or_progress(): + clock = FakeClock() + eta = EtaEstimator(clock) + assert eta.estimate(10, 100) is None + + eta.start() + clock.now = 60 + assert eta.estimate(0, 100) is None + + +def test_no_estimate_until_enough_time_has_passed(): + clock = FakeClock() + eta = EtaEstimator(clock) + eta.start() + clock.now = 2 + assert eta.estimate(10, 100) is None + + +def test_estimate_uses_average_speed_of_whole_run(): + clock = FakeClock() + eta = EtaEstimator(clock) + eta.start() + clock.now = 10 + # 25 bytes in 10 s -> 2.5 B/s, 75 left -> 30 s + assert eta.estimate(25, 100) == 30 + clock.now = 40 + # 50 bytes in 40 s -> 1.25 B/s, 50 left -> 40 s + assert eta.estimate(50, 100) == 40 + + +def test_format_duration(): + assert format_duration(3725) == "1:02:05" + assert format_duration(-3) == "0:00:00" diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..d4a98b7 --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,51 @@ +import pytest + +from commitrocket.errors import CommitRocketError +from commitrocket.filters import FileFilter, read_patterns + +FILES = ["a.mp4", "notes.txt", "raw/b.mp4", "raw/c.zip", "docs/readme.md"] + + +def test_no_patterns_keeps_everything(): + file_filter = FileFilter() + assert file_filter.active is False + assert file_filter.apply(FILES) == FILES + + +def test_include_by_extension_matches_at_any_depth(): + assert FileFilter(include=["*.mp4"]).apply(FILES) == ["a.mp4", "raw/b.mp4"] + + +def test_exclude_folder_and_anchored_pattern(): + kept = FileFilter(exclude=["raw/", "/notes.txt"]).apply(FILES) + assert kept == ["a.mp4", "docs/readme.md"] + + +def test_exclude_wins_over_include(): + kept = FileFilter(include=["*.mp4"], exclude=["raw/"]).apply(FILES) + assert kept == ["a.mp4"] + + +def test_double_star_pattern(): + assert FileFilter(include=["raw/**"]).apply(FILES) == ["raw/b.mp4", "raw/c.zip"] + + +def test_read_patterns_skips_blanks_and_comments(tmp_path): + file = tmp_path / "patterns.txt" + file.write_text("# videos\n*.mp4\n\n *.zip \n") + assert read_patterns(file) == ["*.mp4", "*.zip"] + + +def test_missing_pattern_file_is_an_error(tmp_path): + with pytest.raises(CommitRocketError): + read_patterns(tmp_path / "missing.txt") + + +def test_from_options_reads_files_and_ignore_file(tmp_path): + (tmp_path / "inc.txt").write_text("*.mp4\n*.zip\n") + (tmp_path / ".commitrocketignore").write_text("raw/\n") + + file_filter = FileFilter.from_options( + tmp_path, [], [], [str(tmp_path / "inc.txt")], [] + ) + assert file_filter.apply(FILES) == ["a.mp4"] diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..1afe3c0 --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,69 @@ +import pytest + +from commitrocket.errors import CommitRocketError, GitError +from commitrocket.git import ( + LocalConfig, + get_pending_files, + get_staged_files, + resolve_target, + run_git, +) + + +def test_pending_includes_untracked_and_modified(git_repo): + (git_repo / "tracked.txt").write_text("one") + run_git(git_repo, "add", "tracked.txt") + run_git(git_repo, "commit", "-m", "init") + + (git_repo / "tracked.txt").write_text("two") + (git_repo / "new.txt").write_text("new") + + assert sorted(get_pending_files(git_repo)) == ["new.txt", "tracked.txt"] + assert get_staged_files(git_repo) == [] + + +def test_local_config_sets_and_restores(git_repo): + run_git(git_repo, "config", "--local", "pack.threads", "2") + + config = LocalConfig(git_repo) + config.set("pack.threads", 8) + config.set("lfs.concurrenttransfers", 4) + assert run_git(git_repo, "config", "--local", "pack.threads").stdout.strip() == "8" + + config.restore() + assert run_git(git_repo, "config", "--local", "pack.threads").stdout.strip() == "2" + unset = run_git( + git_repo, "config", "--local", "lfs.concurrenttransfers", check=False + ) + assert unset.returncode != 0 + + +def test_resolve_target_needs_a_remote(git_repo): + with pytest.raises(CommitRocketError, match="No upstream"): + resolve_target(git_repo, "main") + + +def test_resolve_target_uses_explicit_values_and_local_branch(git_repo): + target = resolve_target(git_repo, "feature", remote="backup") + assert (target.remote, target.branch) == ("backup", "feature") + assert target.ref == "refs/heads/feature" + assert target.refspec == "HEAD:refs/heads/feature" + assert target.label == "backup/feature" + # Never pushed there, so everything the remote lacks counts as unpushed. + assert target.revs == ["HEAD", "--not", "--remotes=backup"] + + +def test_resolve_target_reads_the_upstream_and_prefers_overrides(git_repo): + run_git(git_repo, "config", "branch.main.remote", "origin") + run_git(git_repo, "config", "branch.main.merge", "refs/heads/release") + + target = resolve_target(git_repo, "main") + assert target.label == "origin/release" + + target = resolve_target(git_repo, "main", branch="other") + assert target.label == "origin/other" + + +def test_run_git_raises_git_error(git_repo): + with pytest.raises(GitError): + run_git(git_repo, "no-such-command") diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 0000000..5566466 --- /dev/null +++ b/tests/test_journal.py @@ -0,0 +1,17 @@ +from commitrocket.journal import Journal + + +def test_journal_round_trip(git_repo): + journal = Journal(git_repo) + assert journal.read() is None + + journal.write(["a.txt", "b c.txt"], "Videos #22", 22) + assert journal.read() == { + "files": ["a.txt", "b c.txt"], + "message": "Videos #22", + "number": 22, + } + + journal.clear() + assert journal.read() is None + journal.clear() # clearing twice is fine diff --git a/tests/test_lfs.py b/tests/test_lfs.py new file mode 100644 index 0000000..dc5f699 --- /dev/null +++ b/tests/test_lfs.py @@ -0,0 +1,86 @@ +import pytest + +from commitrocket.batching import MIB +from commitrocket.errors import CommitRocketError +from commitrocket.git import run_git +from commitrocket.lfs import ( + LfsSession, + all_files_rules, + attribute_line, + committed_rule_lines, + find_lfs_candidates, +) + + +def test_attribute_line_quotes_spaces_and_escapes_globs(): + assert attribute_line("a b [1].mp4") == ( + '"/a b \\\\[1\\\\].mp4" filter=lfs diff=lfs merge=lfs -text' + ) + + +def test_committed_rules_use_extensions_and_exact_paths(): + paths = ["a.mp4", "dir/b.mp4", "c.ZIP", "Makefile", "x y/.gitignore", "d e.mp4"] + assert committed_rule_lines(paths) == [ + '"/Makefile" filter=lfs diff=lfs merge=lfs -text', + "*.ZIP filter=lfs diff=lfs merge=lfs -text", + "*.mp4 filter=lfs diff=lfs merge=lfs -text", + ] + + +def test_write_committed_rules_appends_once(git_repo): + (git_repo / ".gitattributes").write_text("*.txt text") + run_git(git_repo, "add", ".gitattributes") + run_git(git_repo, "commit", "-m", "attrs") + + session = LfsSession(git_repo) + assert session.write_committed_rules(["a.mp4", "b.mp4"]) is True + text = (git_repo / ".gitattributes").read_text() + assert text == "*.txt text\n*.mp4 filter=lfs diff=lfs merge=lfs -text\n" + + run_git(git_repo, "add", ".gitattributes") + run_git(git_repo, "commit", "-m", "rules") + assert session.write_committed_rules(["c.mp4"]) is False + + +def test_write_committed_rules_refuses_dirty_gitattributes(git_repo): + (git_repo / ".gitattributes").write_text("*.txt text") + with pytest.raises(CommitRocketError): + LfsSession(git_repo).write_committed_rules(["a.mp4"]) + + +def test_zero_threshold_selects_every_non_empty_file(tmp_path): + (tmp_path / "empty").write_bytes(b"") + (tmp_path / "tiny").write_bytes(b"x") + (tmp_path / "big").write_bytes(b"x" * MIB) + + files = ["empty", "tiny", "big"] + assert find_lfs_candidates(tmp_path, files, 0, 5 * MIB) == ["tiny", "big"] + + +def test_all_files_rules_exclude_git_config_files(): + rules = all_files_rules() + assert rules[0] == "* filter=lfs diff=lfs merge=lfs -text" + assert ".gitattributes !filter !diff !merge !text" in rules + + +def test_session_restores_attributes_file(git_repo): + (git_repo / ".git" / "info").mkdir(exist_ok=True) + attributes = git_repo / ".git" / "info" / "attributes" + attributes.write_text("*.txt text") + + session = LfsSession(git_repo) + session.attributes_path = attributes + session.original = attributes.read_bytes() + attributes.write_text("*.txt text\n" + attribute_line("big.mp4") + "\n") + + session.close() + assert attributes.read_text() == "*.txt text" + + +def test_candidates_are_above_threshold_and_within_max(tmp_path): + sizes = {"small": MIB, "edge": 2 * MIB, "big": 3 * MIB, "huge": 9 * MIB} + for name, size in sizes.items(): + (tmp_path / name).write_bytes(b"x" * size) + + files = ["small", "edge", "big", "huge"] + assert find_lfs_candidates(tmp_path, files, 2 * MIB, 5 * MIB) == ["big"] diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 0000000..552948e --- /dev/null +++ b/tests/test_logs.py @@ -0,0 +1,44 @@ +import logging +from pathlib import Path + +from commitrocket import logs + + +def test_default_dir_windows(monkeypatch, tmp_path): + monkeypatch.setattr(logs.sys, "platform", "win32") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + assert logs.default_log_dir() == tmp_path / "CommitRocket" / "Logs" + + +def test_default_dir_macos(monkeypatch): + monkeypatch.setattr(logs.sys, "platform", "darwin") + expected = Path.home() / "Library" / "Logs" / "CommitRocket" + assert logs.default_log_dir() == expected + + +def test_default_dir_linux_uses_xdg_state(monkeypatch, tmp_path): + monkeypatch.setattr(logs.sys, "platform", "linux") + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) + assert logs.default_log_dir() == tmp_path / "commitrocket" + + +def test_setup_logging_writes_file_at_level(tmp_path): + target = tmp_path / "sub" / "run.log" + path = logs.setup_logging("INFO", target) + try: + logger = logging.getLogger("commitrocket.test") + logger.debug("hidden") + logger.info("shown") + finally: + logs.close_logging() + + text = path.read_text(encoding="utf-8") + assert "shown" in text + assert "hidden" not in text + + +def test_setup_logging_returns_none_when_unwritable(tmp_path): + blocker = tmp_path / "file" + blocker.write_text("x") + assert logs.setup_logging("INFO", blocker / "run.log") is None + logs.close_logging() diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..b130581 --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,118 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest +from rich.console import Console + +from commitrocket import notify +from commitrocket.options import RunOptions +from commitrocket.report import FAILED, SUCCESS, RunReport + + +def make_report(status=SUCCESS, error=None, **settings): + report = RunReport(RunOptions(message="m", **settings)) + report.target = "origin/main" + report.add_batch(1, "m #1", ["a", "b"], 5 * 1024 * 1024) + report.status = status + report.error = error + report.finish(0 if status == SUCCESS else 1) + return report + + +def test_message_for_success(): + title, body = notify.build_message(make_report()) + assert title == "CommitRocket finished" + assert "1 batch(es) pushed, 2 file(s), 5.0 MiB" in body + assert "to origin/main" in body + + +def test_message_for_failure_includes_the_first_error_line(): + report = make_report(FAILED, error="git push failed\nmore detail") + title, body = notify.build_message(report) + assert title == "CommitRocket failed" + assert "git push failed" in body + assert "more detail" not in body + + +def test_message_says_committed_for_no_push(): + _, body = notify.build_message(make_report(no_push=True)) + assert "committed" in body + + +@pytest.mark.parametrize( + ("url", "valid"), + [ + ("https://hooks.example.com/abc", True), + ("http://localhost:8080/x", True), + ("file:///etc/passwd", False), + ("ftp://example.com/x", False), + ("not a url", False), + ], +) +def test_webhook_url_validation(url, valid): + assert notify.is_valid_webhook(url) is valid + + +def test_desktop_falls_back_to_the_bell(monkeypatch, capsys): + monkeypatch.setattr(notify.sys, "platform", "linux") + monkeypatch.setattr(notify.shutil, "which", lambda name: None) + + assert notify.notify_desktop("t", "b") == "bell" + assert "\a" in capsys.readouterr().out + + +def test_desktop_uses_notify_send_on_linux(monkeypatch): + calls = [] + monkeypatch.setattr(notify.sys, "platform", "linux") + monkeypatch.setattr(notify.shutil, "which", lambda name: "/usr/bin/" + name) + + def fake_run(command, env): + calls.append((command, env)) + return True + + monkeypatch.setattr(notify, "_run_quietly", fake_run) + + assert notify.notify_desktop("Title", "Body") == "notify-send" + command, env = calls[0] + assert command[0] == "notify-send" and command[-2:] == ["Title", "Body"] + assert env == {"CR_TITLE": "Title", "CR_BODY": "Body"} + + +def test_webhook_receives_a_json_summary(): + received = {} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers["Content-Length"]) + received["body"] = json.loads(self.rfile.read(length)) + received["title"] = self.headers["Title"] + self.send_response(200) + self.end_headers() + + def log_message(self, *args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}/hook" + notify.send_notifications(Console(quiet=True), make_report(), webhook=url) + finally: + server.shutdown() + server.server_close() + + body = received["body"] + assert received["title"] == "CommitRocket finished" + assert body["status"] == SUCCESS + assert body["batches"] == 1 + assert body["files"] == 2 + assert body["text"].startswith("CommitRocket finished") + + +def test_unreachable_webhook_does_not_raise(): + # Nothing listens on port 9, so the request fails; the run must not. + notify.send_notifications( + Console(quiet=True), make_report(), webhook="http://127.0.0.1:9/hook" + ) diff --git a/tests/test_push_parsing.py b/tests/test_push_parsing.py new file mode 100644 index 0000000..9ef6ced --- /dev/null +++ b/tests/test_push_parsing.py @@ -0,0 +1,31 @@ +import pytest + +from commitrocket.push import parse_lfs_bytes, parse_progress_line + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("Uploading LFS objects: 50% (1/2), 52 MB | 3.2 MB/s", 52_000_000), + ("Uploading LFS objects: 100% (1/1), 1.5 GB | 12 MB/s", 1_500_000_000), + ("Uploading LFS objects: 0% (0/1), 0 B | 0 B/s", 0), + ("Writing objects: 100% (6/6), 235.76 MiB | 846.00 KiB/s, done.", None), + ], +) +def test_parse_lfs_bytes(line, expected): + assert parse_lfs_bytes(line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("Enumerating objects: 5, done.", ("Enumerating objects", None)), + ("Counting objects: 40% (2/5)", ("Counting objects", 40)), + ("Compressing objects: 100% (3/3)", ("Compressing objects", 100)), + ("Writing objects: 50% (1/2)", ("Uploading objects", 50)), + ("Resolving deltas: 100% (1/1)", ("Resolving deltas", 100)), + ("To github.com:user/repo.git", ("Git push", None)), + ], +) +def test_parse_progress_line(line, expected): + assert parse_progress_line(line) == expected diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..3e34aa7 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,58 @@ +import json + +from commitrocket.options import RunOptions +from commitrocket.report import DEFERRED, SUCCESS, RunReport + + +def make_report(**settings): + report = RunReport(RunOptions(message="Videos", **settings)) + report.repo = "/repo" + report.branch = "main" + report.target = "origin/main" + return report + + +def test_report_structure_and_totals(): + report = make_report() + entry = report.add_batch(1, "Videos #1", ["a.mp4", "b.mp4"], 3000, lfs_files=1) + entry["pushed"] = True + report.add_batch(2, "Videos #2", ["c.mp4"], 500) + report.set_deferred([("huge.mp4", 9_000_000)]) + report.status = DEFERRED + report.finish(2, "/logs/run.log") + + data = report.to_dict() + assert data["schema_version"] == 1 + assert data["status"] == DEFERRED + assert data["exit_code"] == 2 + assert data["target"] == "origin/main" + assert data["log_file"] == "/logs/run.log" + assert data["totals"] == { + "batches": 2, + "files": 3, + "size_bytes": 3500, + "deferred_files": 1, + } + assert data["batches"][0]["pushed"] is True + assert data["batches"][0]["lfs_file_count"] == 1 + assert data["batches"][1]["pushed"] is False + assert data["deferred"] == [{"path": "huge.mp4", "size_bytes": 9_000_000}] + assert data["settings"]["message"] == "Videos" + + +def test_write_creates_folders_and_valid_json(tmp_path): + report = make_report(dry_run=True) + report.status = SUCCESS + report.finish(0) + + path = report.write(tmp_path / "out" / "report.json") + + assert json.loads(path.read_text(encoding="utf-8"))["dry_run"] is True + assert not list(path.parent.glob("*.tmp")) + + +def test_write_replaces_an_existing_file(tmp_path): + target = tmp_path / "report.json" + target.write_text("old") + make_report().write(target) + assert json.loads(target.read_text(encoding="utf-8"))["tool"] == "commitrocket" diff --git a/tests/test_retries.py b/tests/test_retries.py new file mode 100644 index 0000000..b6dfd54 --- /dev/null +++ b/tests/test_retries.py @@ -0,0 +1,70 @@ +import pytest +from rich.console import Console + +from commitrocket import push +from commitrocket.errors import PushError + + +@pytest.mark.parametrize( + "output", + [ + "remote: error: GH001: Large files detected.", + "! [remote rejected] main -> main (pre-receive hook declined)", + "! [rejected] main -> main (fetch first)", + "fatal: Authentication failed for 'https://example.com/r.git'", + ], +) +def test_rejections_are_not_retryable(output): + assert push.is_retryable(output) is False + + +@pytest.mark.parametrize( + "output", + [ + "error: RPC failed; HTTP 500 curl 22 The requested URL returned error: 500", + "fatal: the remote end hung up unexpectedly", + "LFS: Put https://example.com/object: connection reset by peer", + ], +) +def test_transient_failures_are_retryable(output): + assert push.is_retryable(output) is True + + +def fake_push(failures): + calls = [] + + def _push(*args, **kwargs): + calls.append(1) + if len(calls) <= len(failures): + raise failures[len(calls) - 1] + + return _push, calls + + +def run_retries(monkeypatch, failures, retries): + fake, calls = fake_push(failures) + monkeypatch.setattr(push, "push_with_progress", fake) + waits = [] + push.push_with_retries( + ".", None, None, Console(quiet=True), retries=retries, sleep=waits.append + ) + return calls, waits + + +def test_retries_transient_failures_with_backoff(monkeypatch): + failures = [PushError("RPC failed"), PushError("RPC failed")] + calls, waits = run_retries(monkeypatch, failures, retries=3) + assert len(calls) == 3 + assert waits == [5, 15] + + +def test_gives_up_after_retries(monkeypatch): + failures = [PushError("RPC failed")] * 3 + with pytest.raises(PushError): + run_retries(monkeypatch, failures, retries=2) + + +def test_does_not_retry_rejections(monkeypatch): + failures = [PushError("rejected", retryable=False)] + with pytest.raises(PushError): + run_retries(monkeypatch, failures, retries=3) diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 0000000..e0a713b --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,39 @@ +import pytest + +from commitrocket.template import DEFAULT_TEMPLATE, render_message, validate_template + + +def render(template, **overrides): + values = { + "message": "Videos ", + "n": 22, + "batch": 3, + "files": 4, + "size_mib": 123.456, + "first": "a.mp4", + } + values.update(overrides) + return render_message(template, **values) + + +def test_default_template_matches_the_original_format(): + assert render(DEFAULT_TEMPLATE) == "Videos #22" + + +def test_all_placeholders(): + text = render("{message}: {files} files, {size:.1f} MiB, {first} ({batch})") + assert text == "Videos: 4 files, 123.5 MiB, a.mp4 (3)" + + +def test_date_placeholder_is_iso_date(): + assert len(render("{date}")) == 10 + + +@pytest.mark.parametrize("template", ["{nope}", "{0}", "{n", "{n!z}"]) +def test_invalid_templates_are_rejected(template): + assert validate_template(template) is not None + + +def test_valid_and_empty_templates(): + assert validate_template(DEFAULT_TEMPLATE) is None + assert validate_template(" ") is not None diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..222b223 --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,84 @@ +import subprocess + +import pytest + +from commitrocket.errors import CommitRocketError +from commitrocket.git import run_git +from commitrocket.verify import ( + expected_lfs_paths, + find_non_pointers, + unpushed_has_lfs_pointers, + verify_remote_head, +) + +POINTER = ( + "version https://git-lfs.github.com/spec/v1\n" + "oid sha256:" + "0" * 64 + "\nsize 123\n" +) + + +def commit_all(repo, message): + run_git(repo, "add", "-A") + run_git(repo, "commit", "-m", message) + + +@pytest.fixture +def repo_with_remote(git_repo, tmp_path_factory): + remote = tmp_path_factory.mktemp("remote") + subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True) + run_git(git_repo, "remote", "add", "origin", str(remote)) + (git_repo / "a.txt").write_text("one") + commit_all(git_repo, "first") + branch = run_git(git_repo, "branch", "--show-current").stdout.strip() + return git_repo, branch + + +def test_verify_remote_head_passes_after_push(repo_with_remote): + repo, branch = repo_with_remote + run_git(repo, "push", "origin", branch) + verify_remote_head(repo, "origin", f"refs/heads/{branch}") + + +def test_verify_remote_head_fails_when_remote_is_behind(repo_with_remote): + repo, branch = repo_with_remote + run_git(repo, "push", "origin", branch) + (repo / "a.txt").write_text("two") + commit_all(repo, "second") + + with pytest.raises(CommitRocketError, match="Push verification failed"): + verify_remote_head(repo, "origin", f"refs/heads/{branch}") + + +def test_unpushed_has_lfs_pointers(repo_with_remote): + repo, branch = repo_with_remote + run_git(repo, "push", "origin", branch) + revs = [f"origin/{branch}..HEAD"] + assert unpushed_has_lfs_pointers(repo, revs) is False + + (repo / "plain.txt").write_text("not a pointer") + commit_all(repo, "plain") + assert unpushed_has_lfs_pointers(repo, revs) is False + + (repo / "video.bin").write_text(POINTER) + commit_all(repo, "pointer") + assert unpushed_has_lfs_pointers(repo, revs) is True + + +def test_find_non_pointers(git_repo): + (git_repo / "pointer.bin").write_text(POINTER) + (git_repo / "plain.bin").write_text("just a normal file") + (git_repo / "big.bin").write_bytes(b"x" * 5000) + commit_all(git_repo, "files") + + paths = ["pointer.bin", "plain.bin", "big.bin", "missing.bin"] + assert sorted(find_non_pointers(git_repo, paths)) == [ + "big.bin", + "missing.bin", + "plain.bin", + ] + assert find_non_pointers(git_repo, []) == [] + + +def test_expected_lfs_paths_skips_git_config_files(): + selected = ["a.mp4", "dir/.gitignore", "b.txt"] + assert expected_lfs_paths(selected, ["a.mp4", "dir/.gitignore"]) == ["a.mp4"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b56bd91 --- /dev/null +++ b/uv.lock @@ -0,0 +1,338 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[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 = "commitrocket" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pathspec" }, + { name = "rich" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-xdist" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pathspec", specifier = ">=0.12" }, + { name = "rich", specifier = ">=13" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=3" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-xdist", specifier = ">=3" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "distlib" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz", hash = "sha256:ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d", size = 621304, upload-time = "2026-10-16T13:56:34.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl", hash = "sha256:cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638", size = 472099, upload-time = "2026-10-16T13:56:32.053Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "filelock" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" }, +] + +[[package]] +name = "identify" +version = "2.6.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681, upload-time = "2026-09-26T20:29:07.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[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 = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[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 = "platformdirs" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/e6/d0abbb256b44e6a05748c6942511ff67db8dbb068de0c35b24bf1b395b09/platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0", size = 62362, upload-time = "2026-10-19T01:02:11.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/c3/6965c2690ddce885ec75121572d065a5b94c5a5d8d3e657927ecda9fd715/pre_commit-4.7.0.tar.gz", hash = "sha256:1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86", size = 198120, upload-time = "2026-10-12T20:45:40.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl", hash = "sha256:2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877", size = 226261, upload-time = "2026-10-12T20:45:38.858Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[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 = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/ee/cf608372c88ce2c136ee13c69ab6f667dbc0d0c1176af188b4ddc8853e66/python_discovery-1.6.4.tar.gz", hash = "sha256:bc70d31f0ec5de11eda13d4d64c16e24b3506a65984684f2b6c98cb83b2ea9df", size = 89883, upload-time = "2026-10-19T01:45:14.855Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/91/1aa70d3dfcab0dbcf88a2cbc3bce7f7c69562287a90216e617eaa4937e25/python_discovery-1.6.4-py3-none-any.whl", hash = "sha256:530179cc51a1816d512b15635b3dc931295ae12efb183783ad31190a5d425d2b", size = 39385, upload-time = "2026-10-19T01:45:13.668Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[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.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" }, + { url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" }, + { url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" }, + { url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.14.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/aa/bff50daee0488e16d611258a9b6c5a1d0c9e128f8ca424e9f4e1c0444ef2/virtualenv-21.14.13.tar.gz", hash = "sha256:de5307ba4b9a0001d9c8ea02832d38f63ca432880f416c1bface61c4323fd905", size = 5570301, upload-time = "2026-10-22T20:10:16.952Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/a9/c10d8a44a29fb94a18b43fdf11f38caca53ae7c9406f11a72b1cb8f624f1/virtualenv-21.14.13-py3-none-any.whl", hash = "sha256:5fbb04138a4861580b78ba9110d529a26bc48fcc93c8ec36bc0202eeed96a788", size = 5557094, upload-time = "2026-10-22T20:10:15.096Z" }, +]