From c2c425871dcf3213286b8b527797d0812c9adf6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= Date: Thu, 17 Sep 2026 19:43:15 +0200 Subject: [PATCH 01/10] fix(git): observe delayed PR head propagation Refs #171. Retry only read-only observations while the remote remains\nat the intended commit; retain exact PR identity and bounded failure. --- docs/specs/git-workflow.md | 9 ++++++ .../darrow-git/.claude-plugin/plugin.json | 2 +- .../darrow-git/.codex-plugin/plugin.json | 2 +- .../skills/create-pr/scripts/publication.sh | 28 ++++++++++++++++--- .../create-pr/scripts/publication.test.sh | 25 +++++++++++++++++ 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/specs/git-workflow.md b/docs/specs/git-workflow.md index 96c47904..0e102a16 100644 --- a/docs/specs/git-workflow.md +++ b/docs/specs/git-workflow.md @@ -266,6 +266,15 @@ agree after the operation. contract as reuse. A URL or successful command exit alone is not completion. If creation or push has occurred but observation fails, report the known effects and uncertainty without creating another PR. +- **GW-P11 — Bounded propagation observation.** When the remote branch already + equals the intended commit but the forge reports another valid full commit, + allow at most five observations, one second apart. Pin the first observed PR + number and URL and recheck the local branch and commit before each observation. + A changed identity, shape, unavailable observation, malformed commit, or remote + movement refuses immediately. The observation loop never pushes or creates a + PR; timeout preserves the known mutation effects and reports incomplete + verification. Initial reuse preflight still requires remote/forge agreement + before deciding whether a push is necessary. ### Non-goals diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index eca1b0bb..f06fb2a5 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.5.0", + "version": "0.5.1", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 581fc210..3981d358 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.5.0", + "version": "0.5.1", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh b/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh index 37f8f52f..e38d8935 100644 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh +++ b/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh @@ -42,8 +42,28 @@ publication_observe() { publication_fail 'ambiguous remote branch evidence' [[ "$publication_remote_commit" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || publication_fail 'malformed remote commit evidence' - [[ "$publication_pr_commit" == "$publication_remote_commit" ]] || - publication_fail 'forge and remote branch commits do not agree' + [[ "$publication_pr_commit" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || + publication_fail 'malformed forge commit evidence' +} + +publication_observe_converged() { + local attempt=1 + publication_observe + local pinned_url=$publication_url pinned_number=$publication_number + while [[ "$publication_pr_commit" != "$publication_remote_commit" ]]; do + [[ "$publication_remote_commit" == "$publication_expected" ]] || + publication_fail 'forge and remote branch commits do not agree' + [[ "$attempt" -lt 5 ]] || publication_fail 'forge and remote branch commits do not agree after bounded propagation observation' + sleep 1 + [[ "$(git rev-parse HEAD)" == "$publication_expected" && "$(git symbolic-ref -q HEAD)" == "refs/heads/$publication_branch" ]] || + publication_fail 'local publication target changed' + publication_observe + [[ "$publication_url" == "$pinned_url" && "$publication_number" == "$pinned_number" ]] || + publication_fail 'PR identity changed during publication' + [[ "$publication_remote_commit" == "$publication_expected" ]] || + publication_fail 'remote branch moved during publication observation' + attempt=$((attempt + 1)) + done } publication_push=${publication_initial_push:-none} @@ -86,7 +106,7 @@ command -v gh >/dev/null || publication_fail 'gh is required' publication_repository=$(gh repo view "$publication_origin" --json nameWithOwner --jq '.nameWithOwner') || publication_fail 'cannot identify the origin repository' [[ "$publication_repository" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || publication_fail 'malformed origin repository identity' -publication_observe +publication_observe_converged if [[ "$publication_create" == completed && -n "${publication_initial_url:-}" && "$publication_url" != "$publication_initial_url" ]]; then publication_fail 'created PR URL differs from the observed canonical PR' fi @@ -102,7 +122,7 @@ if [[ "$publication_command" == publish-existing && "$publication_remote_commit" publication_fail 'non-force push failed; observe the remote before retrying' fi publication_push=completed - publication_observe + publication_observe_converged [[ "$publication_url" == "$publication_original_url" && "$publication_number" == "$publication_original_number" ]] || publication_fail 'PR identity changed during publication' fi diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.test.sh b/plugins/capability/darrow-git/skills/create-pr/scripts/publication.test.sh index 996d8401..d604c40d 100644 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.test.sh +++ b/plugins/capability/darrow-git/skills/create-pr/scripts/publication.test.sh @@ -23,6 +23,16 @@ case "$1 $2" in case "${FORGE_MODE:-}" in stale) tip=$(git rev-parse main) ;; stale-after-push) tip=$(cat "$dir/old-tip") ;; + propagating) + calls=$(awk '/^pr list / {n++} END {print n+0}' "$dir/forge-calls") + if [[ "$calls" -lt 3 ]]; then tip=$(cat "$dir/old-tip"); fi + ;; + changed-identity) + if [[ "$tip" != "$(cat "$dir/old-tip")" ]]; then + printf '43\thttps://github.com/fixture/repo/pull/43\tOPEN\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$tip" "$base" "$draft" "$cross" + exit 0 + fi + ;; wrong-base) base=develop ;; wrong-head) branch=fix/other ;; draft) draft=true ;; @@ -128,6 +138,21 @@ export FORGE_MODE='' bash "$SCRIPT" verify --expected-head "$intended" >"$work/output" grep -Fx 'push: none' "$work/output" +fresh +export FORGE_MODE=propagating +bash "$SCRIPT" publish-existing --expected-head "$intended" >"$work/output" +grep -Fx 'publication: verified' "$work/output" +test "$(remote_tip)" = "$intended" +test "$(awk '/^pr list / {n++} END {print n+0}' .git/forge-calls)" = 3 + +fresh +export FORGE_MODE=changed-identity +if bash "$SCRIPT" publish-existing --expected-head "$intended" >"$work/output" 2>&1; then + echo 'FAIL: changed PR identity must not be retried into success' >&2; exit 1 +fi +test "$(remote_tip)" = "$intended" +grep -F 'PR identity changed during publication' "$work/output" + fresh git switch -qc remote-side "$old" printf 'remote\n' >other.txt From 326f6bb4990838ed0c16b0199688d6c19fd97883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= Date: Thu, 17 Sep 2026 19:45:31 +0200 Subject: [PATCH 02/10] fix(git): keep verified PR success reports concise Refs #174. Preserve complete machine publication evidence while reporting the PR link, base, draft state, and verified commit once. --- docs/specs/git-workflow.md | 7 +++++++ .../darrow-git/.claude-plugin/plugin.json | 2 +- .../darrow-git/.codex-plugin/plugin.json | 2 +- .../darrow-git/skills/create-pr/SKILL.md | 19 ++++++++----------- .../create-pr/evals/title-conventional.yaml | 6 ++++-- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/specs/git-workflow.md b/docs/specs/git-workflow.md index 0e102a16..41c8acd9 100644 --- a/docs/specs/git-workflow.md +++ b/docs/specs/git-workflow.md @@ -217,6 +217,13 @@ draft state, intended commit, remote branch commit, forge PR-head commit and whether a push occurred. Success requires all three full commit identities to agree after the operation. +The complete publication record is a machine-facing verification artifact. +The user-facing success report is concise: link the PR, state its base and +draft state, name the verified commit once, and mention excluded local work or +material notes. Expand repository, head and mutation details only when requested +or needed to explain uncertainty. Never summarize an incomplete observation as +verified publication. + ### Invariants - **GW-P1 — Conventional title.** The PR title follows the Conventional diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index f06fb2a5..6bfc9008 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.5.1", + "version": "0.5.2", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 3981d358..345620e7 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.5.1", + "version": "0.5.2", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/skills/create-pr/SKILL.md b/plugins/capability/darrow-git/skills/create-pr/SKILL.md index 1e549aa0..133b62f5 100644 --- a/plugins/capability/darrow-git/skills/create-pr/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-pr/SKILL.md @@ -116,15 +116,12 @@ stashes, or duplicate PRs. ### 4. Report the result -Report the emitted URL, repository, head, base, draft state, intended/remote/ -forge commits, and push/create effects. Name every uncommitted file -that inspection excluded from the PR, and include any degraded-context notes. -Preserve every publication field explicitly in the response; do not collapse -the intended, remote and forge commits into one "published commit" or omit -repository and effect fields merely because their values agree. -Use these distinct labels exactly: `publication`, `url`, `repository`, `head`, -`base`, `draft`, `intended-commit`, `remote-commit`, `pr-commit`, `push`, and -`pr-create`. +For verified success, link the PR, state its base and draft state, and name the +verified commit once. Keep the complete emitted record available as verification +evidence; do not paste duplicate commit IDs, routine effect fields, or the whole +record into the user-facing response unless requested. Name excluded local work +and include every material `## note:`. For incomplete publication, explain the +known effects and failed observation explicitly; brevity must not imply success. Leave reviewers, labels, milestones, merging and existing PR metadata updates outside this workflow. An existing-PR observation proves identity, not current publication of local commits. Return the result and refusal to the enclosing @@ -161,8 +158,8 @@ to observe an ambiguous prior publication, run the read-only operation: bash /scripts/pr.sh verify --expected-head [--base ] [--draft] ``` -Report the URL, repository, head/base, draft state, intended/remote/PR commit -IDs, push effect and excluded working-tree files. Only `publication: verified` +Use the same concise success report as §4, retaining the complete publication +record as evidence. Only `publication: verified` proves publication of that commit. If a push completed but verification failed, return both facts without claiming completion. The enclosing owner decides when changed evidence supports another observation; never retry unchanged diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/title-conventional.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/title-conventional.yaml index 79486f09..04f76794 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/title-conventional.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/title-conventional.yaml @@ -86,5 +86,7 @@ checks: run: cat .git/fixture-gh/title not_regex: "\\.$" output_checks: - - name: successful creation returns verified publication identity - expect_regex: 'publication: verified[\s\S]*url: https://github.com/fixture/repo/pull/1[\s\S]*repository: fixture/repo[\s\S]*intended-commit: ([0-9a-f]{40}|[0-9a-f]{64})[\s\S]*remote-commit: ([0-9a-f]{40}|[0-9a-f]{64})[\s\S]*pr-commit: ([0-9a-f]{40}|[0-9a-f]{64})[\s\S]*push: completed[\s\S]*pr-create: completed' + - name: successful creation links the canonical PR + expect_regex: "https://github.com/fixture/repo/pull/1" + - name: success does not paste the machine publication record + not_regex: 'intended-commit:[\s\S]*remote-commit:[\s\S]*pr-commit:' From 284cb2f89d83698f8245c890bcad6b8566f17955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= Date: Thu, 17 Sep 2026 20:18:16 +0200 Subject: [PATCH 03/10] refactor(git): migrate workflow mechanics to Python Keep the five workflows in one contained, frozen UV package with native filesystem handling and literal Git/GitHub process arguments. Preserve public records, staging safeguards, and exact publication binding. Add fresh-artifact platform checks and adapt eval mounts for the backend. Refs #181 --- .github/workflows/python-quality.yml | 62 ++ docs/specs/git-workflow.md | 23 + evals/runner/environment.test.ts | 2 + evals/runner/environment.ts | 4 + evals/runner/fixture.test.ts | 46 ++ evals/runner/fixture.ts | 11 + .../darrow-git/.claude-plugin/plugin.json | 2 +- .../darrow-git/.codex-plugin/plugin.json | 2 +- plugins/capability/darrow-git/README.md | 34 +- .../darrow-git/backend/pyproject.toml | 55 ++ .../backend/src/darrow_git/__init__.py | 1 + .../backend/src/darrow_git/branch.py | 175 +++++ .../backend/src/darrow_git/branch_options.py | 61 ++ .../darrow-git/backend/src/darrow_git/cli.py | 53 ++ .../backend/src/darrow_git/commit.py | 166 +++++ .../backend/src/darrow_git/commit_options.py | 79 +++ .../backend/src/darrow_git/evidence.py | 221 ++++++ .../backend/src/darrow_git/evidence_files.py | 134 ++++ .../src/darrow_git/evidence_identity.py | 110 +++ .../src/darrow_git/evidence_options.py | 72 ++ .../src/darrow_git/evidence_preflight.py | 147 ++++ .../backend/src/darrow_git/media.py | 80 +++ .../backend/src/darrow_git/messages.py | 29 + .../backend/src/darrow_git/names.py | 102 +++ .../darrow-git/backend/src/darrow_git/pr.py | 166 +++++ .../backend/src/darrow_git/pr_inspection.py | 123 ++++ .../backend/src/darrow_git/pr_options.py | 59 ++ .../backend/src/darrow_git/pr_repository.py | 42 ++ .../backend/src/darrow_git/process.py | 71 ++ .../backend/src/darrow_git/publication.py | 311 +++++++++ .../backend/src/darrow_git/py.typed | 0 .../backend/src/darrow_git/remediation.py | 72 ++ .../backend/src/darrow_git/repository.py | 89 +++ .../backend/src/darrow_git/templates.py | 148 +++++ .../backend/src/darrow_git/worktree.py | 227 +++++++ .../darrow-git/backend/tests/conftest.py | 55 ++ .../darrow-git/backend/tests/forge.py | 102 +++ .../darrow-git/backend/tests/fresh_install.py | 124 ++++ .../darrow-git/backend/tests/fresh_probe.py | 61 ++ .../backend/tests/test_argument_contracts.py | 157 +++++ .../backend/tests/test_branch_contract.py | 44 ++ .../backend/tests/test_branch_safety.py | 133 ++++ .../backend/tests/test_cli_media.py | 83 +++ .../backend/tests/test_commit_recovery.py | 146 ++++ .../backend/tests/test_evidence_safety.py | 216 ++++++ .../backend/tests/test_native_workflows.py | 144 ++++ .../backend/tests/test_pr_inspection.py | 70 ++ .../backend/tests/test_publication_safety.py | 133 ++++ .../tests/test_templates_properties.py | 92 +++ plugins/capability/darrow-git/backend/uv.lock | 462 +++++++++++++ .../darrow-git/skills/create-branch/SKILL.md | 12 +- .../skills/create-branch/scripts/branch.sh | 344 +--------- .../darrow-git/skills/create-commit/SKILL.md | 24 +- .../skills/create-commit/scripts/commit.sh | 354 +--------- .../darrow-git/skills/create-pr/SKILL.md | 16 +- .../create-pr/evals/base-user-named.yaml | 1 + .../create-pr/evals/body-context-ticket.yaml | 1 + .../evals/default-branch-refuse.yaml | 1 + .../create-pr/evals/dirty-committed-only.yaml | 1 + .../skills/create-pr/evals/draft-request.yaml | 1 + .../skills/create-pr/evals/dup-stop.yaml | 1 + .../evals/template-choice-selected.yaml | 1 + .../skills/create-pr/evals/template-fill.yaml | 1 + .../create-pr/evals/title-conventional.yaml | 1 + .../darrow-git/skills/create-pr/scripts/pr.sh | 627 +----------------- .../skills/create-pr/scripts/pr.test.sh | 5 +- .../skills/create-pr/scripts/publication.sh | 145 +--- .../skills/prepare-task-branch/SKILL.md | 12 +- .../prepare-task-branch/scripts/branch.sh | 448 +------------ .../skills/publish-pr-evidence/SKILL.md | 10 +- .../publish-pr-evidence/scripts/publish.sh | 307 +-------- .../darrow-git/tests/fresh-install.test.ps1 | 4 + .../darrow-git/tests/fresh-install.test.sh | 5 + python-packages.txt | 1 + 74 files changed, 5084 insertions(+), 2240 deletions(-) create mode 100644 plugins/capability/darrow-git/backend/pyproject.toml create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/__init__.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/branch.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/cli.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/commit.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/commit_options.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/evidence.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/evidence_files.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/evidence_identity.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/evidence_options.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/evidence_preflight.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/media.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/messages.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/names.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/pr.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/pr_inspection.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/pr_options.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/pr_repository.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/process.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/publication.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/py.typed create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/remediation.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/repository.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/templates.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/worktree.py create mode 100644 plugins/capability/darrow-git/backend/tests/conftest.py create mode 100644 plugins/capability/darrow-git/backend/tests/forge.py create mode 100644 plugins/capability/darrow-git/backend/tests/fresh_install.py create mode 100644 plugins/capability/darrow-git/backend/tests/fresh_probe.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_argument_contracts.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_branch_contract.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_branch_safety.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_cli_media.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_commit_recovery.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_evidence_safety.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_native_workflows.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_pr_inspection.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_publication_safety.py create mode 100644 plugins/capability/darrow-git/backend/tests/test_templates_properties.py create mode 100644 plugins/capability/darrow-git/backend/uv.lock create mode 100644 plugins/capability/darrow-git/tests/fresh-install.test.ps1 create mode 100644 plugins/capability/darrow-git/tests/fresh-install.test.sh diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index df177a7b..3a04294e 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -145,6 +145,59 @@ jobs: enable-cache: true - run: scripts/check-python --package plugins/capability/darrow-verification/skills/verify-change/backend + git-windows: + name: Git Python ${{ matrix.python-version }} on windows-latest + needs: changes + if: contains(needs.changes.outputs.packages, 'plugins/capability/darrow-git/backend') + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + runs-on: windows-latest + defaults: + run: + shell: bash + env: + UV_PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: scripts/check-python --package plugins/capability/darrow-git/backend + + git-fresh-install: + name: Fresh install git on ${{ matrix.os }} + needs: changes + if: contains(needs.changes.outputs.packages, 'plugins/capability/darrow-git/backend') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@v6 + - if: runner.os != 'Windows' + run: bash plugins/capability/darrow-git/tests/fresh-install.test.sh + - name: Verify POSIX compatibility launchers + if: runner.os != 'Windows' + shell: bash + run: | + /bin/bash --version + for test in plugins/capability/darrow-git/skills/*/scripts/*.test.sh; do + /bin/bash "$test" + done + - if: runner.os == 'Windows' + shell: pwsh + run: "& plugins/capability/darrow-git/tests/fresh-install.test.ps1" + inventory: name: Python inventory guard runs-on: ubuntu-latest @@ -278,6 +331,8 @@ jobs: - skill-authoring-windows - discovery-windows - verification-windows + - git-windows + - git-fresh-install - inventory - skill-authoring-fresh-install - observability-fresh-install @@ -305,6 +360,9 @@ jobs: DISCOVERY_INSTALL_RESULT: ${{ needs.discovery-fresh-install.result }} VERIFICATION_INSTALL_RESULT: ${{ needs.verification-fresh-install.result }} PERFORMANCE_RESULT: ${{ needs.performance.result }} + GIT_SELECTED: ${{ contains(needs.changes.outputs.packages, 'plugins/capability/darrow-git/backend') }} + GIT_WINDOWS_RESULT: ${{ needs.git-windows.result }} + GIT_INSTALL_RESULT: ${{ needs.git-fresh-install.result }} run: | scripts/verify-python-quality-results \ "$CHANGES_RESULT" "$INVENTORY_RESULT" \ @@ -316,3 +374,7 @@ jobs: "$DISCOVERY_INSTALL_RESULT" \ "$VERIFICATION_CHANGED" "$VERIFICATION_WINDOWS_RESULT" \ "$VERIFICATION_INSTALL_RESULT" + expected=skipped + if [ "$GIT_SELECTED" = true ]; then expected=success; fi + test "$GIT_WINDOWS_RESULT" = "$expected" + test "$GIT_INSTALL_RESULT" = "$expected" diff --git a/docs/specs/git-workflow.md b/docs/specs/git-workflow.md index 41c8acd9..84add036 100644 --- a/docs/specs/git-workflow.md +++ b/docs/specs/git-workflow.md @@ -10,6 +10,29 @@ Plugin: `darrow-git`. Skills: `create-commit` (M0), `create-branch`, GW-B6 applies only when the caller explicitly asks the `create-branch` capability to allocate an additional linked worktree. +## Executable mechanics + +- **GW-M1 — Contained portable implementation.** The five capabilities share + one Python package contained in `darrow-git/backend`, with frozen UV runtime + entrypoints and no runtime dependency on another plugin. Git and GitHub CLI + remain the provider boundaries. Existing script paths, command arguments, + stable records, refusal statuses, and mutation safeguards remain compatible. + POSIX scripts are dispatch-only compatibility launchers; native Windows uses + the same Python entrypoints without requiring a Unix shell. +- **GW-M2 — Literal process and filesystem boundaries.** Provider commands use + argument vectors, never interpolated shell programs. Paths and temporary + artifacts use native filesystem APIs. Hook remediation retains its diagnostic + and index-snapshot authorization boundary; the literal authorized diagnostic + command uses the native host command interpreter (POSIX sh or Windows cmd) + with its native command-string quoting. This compatibility exception never applies to Git/GitHub + provider arguments. +- **GW-M3 — Migration evidence.** The package meets the repository Python + quality standard, including separate 95% statement and branch coverage, + deterministic real-Git integration fixtures, mocked GitHub publication, + meaningful property tests, and fresh copied-plugin tests on Linux, macOS, + and native Windows. The propagation and presentation fixes tracked in #171 + and #174 remain separately identifiable from this migration. + ## Why Agents left to improvise git usage produce inconsistent messages, stage diff --git a/evals/runner/environment.test.ts b/evals/runner/environment.test.ts index 0c26a92d..82536df4 100644 --- a/evals/runner/environment.test.ts +++ b/evals/runner/environment.test.ts @@ -54,6 +54,8 @@ describe("isolated harness environment", () => { expect(env.CLAUDE_CONFIG_DIR).toStartWith(home); expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("claude-test-token"); expect(env.DARROW_ADAPTIVE_DELIVERY_EXTERNAL_SANDBOX).toBe("1"); + expect(env.UV_PROJECT_ENVIRONMENT).toBe(join(env.TMPDIR!, "uv-project")); + expect(env.UV_CACHE_DIR).toBe(join(env.TMPDIR!, "uv-cache")); expect(env.UNRELATED_EVAL_SECRET).toBeUndefined(); expect(await readFile(join(codexHome, "auth.json"), "utf8")).toBe( '{"token":"test"}', diff --git a/evals/runner/environment.ts b/evals/runner/environment.ts index 3d09af89..2a31243c 100644 --- a/evals/runner/environment.ts +++ b/evals/runner/environment.ts @@ -153,6 +153,10 @@ export async function isolatedHarnessEnvironment( ); env.HOME = stateRoot; env.TMPDIR = tempRoot; + // Installed plugin sources live in the protected host config tree. Runtime + // environments belong in writable, per-trial scratch, outside that tree. + env.UV_PROJECT_ENVIRONMENT = join(tempRoot, "uv-project"); + env.UV_CACHE_DIR = join(tempRoot, "uv-cache"); env.ZDOTDIR = shellRoot; // The runner wraps the evaluated agent in sandboxedAgentCommand. Nested // adaptive-delivery-preflight mechanics must reuse that boundary instead of attempting an diff --git a/evals/runner/fixture.test.ts b/evals/runner/fixture.test.ts index 23935818..a78a6253 100644 --- a/evals/runner/fixture.test.ts +++ b/evals/runner/fixture.test.ts @@ -228,6 +228,52 @@ describe("eval fixture skill mounts", () => { cleanup.splice(cleanup.indexOf(fixture), 1); }); + test.each(["project", "claude", "codex"])( + "mounts a plugin-level Python backend for %s without generated state", + async (host) => { + const root = await mkdtemp(join(tmpdir(), "darrow-fixture-backend-")); + cleanup.push(root); + const plugin = join(root, "plugin"); + const skill = join(plugin, "skills", "primary"); + const backend = join(plugin, "backend"); + await mkdir(skill, { recursive: true }); + await mkdir(join(backend, ".venv"), { recursive: true }); + await mkdir(join(backend, "evals"), { recursive: true }); + await mkdir(join(plugin, ".claude-plugin"), { recursive: true }); + await mkdir(join(plugin, ".codex-plugin"), { recursive: true }); + await writeFile( + join(skill, "SKILL.md"), + "---\nname: primary\ndescription: Primary\n---\n", + ); + await writeFile(join(backend, "pyproject.toml"), "[project]\n"); + await writeFile(join(backend, ".venv", "generated"), "private\n"); + await writeFile(join(backend, "evals", "secret.yaml"), "hidden\n"); + for (const manifest of [".claude-plugin", ".codex-plugin"]) { + await writeFile( + join(plugin, manifest, "plugin.json"), + JSON.stringify({ name: "fixture-backend", version: "1.0.0" }), + ); + } + const fixture = await buildFixture({ + fixture: {}, + skillDir: skill, + skillMounts: host === "project" ? [".agents/skills"] : [], + sourceClaudePlugin: host === "claude", + sourceCodexPlugin: host === "codex", + }); + cleanup.push(fixture); + const destinations: Record = { + project: join(fixture, ".agents", "backend"), + claude: join(fixture, ".git", "eval-plugin", "backend"), + codex: join(fixture, ".git", "eval-marketplace", "plugin", "backend"), + }; + const destination = destinations[host]!; + expect(existsSync(join(destination, "pyproject.toml"))).toBe(true); + expect(existsSync(join(destination, ".venv"))).toBe(false); + expect(existsSync(join(destination, "evals"))).toBe(false); + }, + ); + test("optionally mounts every plugin skill without exposing colocated evals", async () => { const root = await mkdtemp(join(tmpdir(), "darrow-fixture-plugin-")); cleanup.push(root); diff --git a/evals/runner/fixture.ts b/evals/runner/fixture.ts index e966323b..2c2719c8 100644 --- a/evals/runner/fixture.ts +++ b/evals/runner/fixture.ts @@ -240,6 +240,7 @@ async function copySkillWithoutEvals( } interface PluginMountPaths { + backend: string; manifest: string; codexManifest: string; agents: string; @@ -253,6 +254,11 @@ async function mountPluginMechanics( mount: string, paths: PluginMountPaths, ): Promise { + if (existsSync(paths.backend)) + await copySkillWithoutEvals( + paths.backend, + join(repoDir, mount, "..", "backend"), + ); if (existsSync(paths.bin)) await cp(paths.bin, join(repoDir, mount, "..", "bin"), { recursive: true }); if (existsSync(paths.config)) @@ -288,6 +294,8 @@ async function mountSourceClaudePlugin( await cp(paths.config, join(evalPlugin, "config"), { recursive: true }); if (existsSync(paths.hooks)) await cp(paths.hooks, join(evalPlugin, "hooks"), { recursive: true }); + if (existsSync(paths.backend)) + await copySkillWithoutEvals(paths.backend, join(evalPlugin, "backend")); } async function mountSourceCodexPlugin( @@ -311,6 +319,8 @@ async function mountSourceCodexPlugin( await cp(paths.config, join(plugin, "config"), { recursive: true }); if (existsSync(paths.hooks)) await cp(paths.hooks, join(plugin, "hooks"), { recursive: true }); + if (existsSync(paths.backend)) + await copySkillWithoutEvals(paths.backend, join(plugin, "backend")); const manifest = JSON.parse(await readFile(paths.codexManifest, "utf8")) as { name?: unknown; }; @@ -382,6 +392,7 @@ function pluginMountPaths( ): PluginMountPaths { const pluginRoot = sourcePluginRoot ?? dirname(dirname(skillDir)); return { + backend: join(pluginRoot, "backend"), bin: join(pluginRoot, "bin"), config: join(pluginRoot, "config"), agents: join(pluginRoot, "agents"), diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index 6bfc9008..378f0c70 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.5.2", + "version": "0.6.0", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 345620e7..858752f0 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.5.2", + "version": "0.6.0", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/README.md b/plugins/capability/darrow-git/README.md index d9e589e0..af886e55 100644 --- a/plugins/capability/darrow-git/README.md +++ b/plugins/capability/darrow-git/README.md @@ -80,11 +80,13 @@ Example: _“Attach these verification screenshots to the current PR.”_ ### Bundled workflow scripts -Each skill uses its own bundled Bash script under `skills//scripts/`. -The scripts inspect and validate repository state, perform only the authorized +Each skill uses a frozen UV entrypoint from the contained Python package in +`backend/`. The helpers inspect and validate repository state, perform only the authorized Git or GitHub operation, and return structured evidence for the skill to interpret. They are implementation details of the workflows rather than a -general Git wrapper. +general Git wrapper. Existing Bash script paths remain thin compatibility +launchers with the same commands, records, and exit statuses. Native Windows +calls the UV entrypoints directly. ## Design boundaries @@ -103,11 +105,35 @@ workflows to rewrite history, merge, release, deploy, or post generic comments. ## Hosts and prerequisites -Codex and Claude Code; Git, Bash, and baseline Unix tools. PR work also requires +Codex and Claude Code on Linux, macOS, and Windows; Git, UV, and Python +3.10–3.13 (UV can provision Python). Bash is needed only for POSIX compatibility +launchers. PR work also requires authenticated GitHub CLI access and a usable remote. Attachment publication requires a GitHub host and a `gh pr comment` implementation that advertises `--attach`. +Provider calls use literal argument vectors and native filesystem APIs. The +existing hook-remediation `--command` interface is the sole shell exception: +it passes the exact authorized diagnostic to POSIX `sh` or native Windows `cmd`. +Its saved HEAD/index checks and staged-path limits still apply. A diagnostic's +command syntax must match its host. + +Successful PR reports link the PR and name its base, draft state, and verified +commit once. Complete structured publication evidence remains available from +the helper. PR-head propagation is observed at most five times, one second +apart, without repeating a push or PR creation. + +## Validation + +`bun run check:python` runs formatting, Ruff, strict typing, deterministic and +property tests, and separate 95% statement/branch coverage gates. The package +has no runtime dependencies. Fresh-install checks copy the whole plugin and +exercise all five workflows with real local Git repositories and mocked GitHub +boundaries, using `tests/fresh-install.test.sh` on POSIX and +`tests/fresh-install.test.ps1` in native PowerShell. CI covers Python 3.10–3.13 +on Linux, macOS, and Windows. Existing shell regressions continue to exercise +the compatibility launchers. + ## Installation Install `darrow-git@darrow` using the diff --git a/plugins/capability/darrow-git/backend/pyproject.toml b/plugins/capability/darrow-git/backend/pyproject.toml new file mode 100644 index 00000000..9fc4f622 --- /dev/null +++ b/plugins/capability/darrow-git/backend/pyproject.toml @@ -0,0 +1,55 @@ +[project] +name = "darrow-git" +version = "0.6.0" +description = "Contained Git and GitHub mechanics for Darrow" +requires-python = ">=3.10,<3.14" +dependencies = [] + +[project.scripts] +darrow-create-branch = "darrow_git.cli:create_branch" +darrow-prepare-task-branch = "darrow_git.cli:prepare_branch" +darrow-create-commit = "darrow_git.cli:create_commit" +darrow-create-pr = "darrow_git.cli:create_pr" +darrow-publish-pr-evidence = "darrow_git.cli:publish_evidence" + +[dependency-groups] +dev = ["coverage[toml]>=7.10,<8", "hypothesis>=6.138,<7", "mypy>=1.18,<2", "pytest>=8.4,<9", "ruff>=0.12,<1"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +default-groups = [] + +[tool.hatch.build.targets.wheel] +packages = ["src/darrow_git"] + +[tool.ruff] +target-version = "py310" +line-length = 88 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["B", "C4", "C90", "E4", "E7", "E9", "F", "I", "N", "RUF", "SIM", "UP"] +mccabe.max-complexity = 5 + +[tool.ruff.format] +docstring-code-format = true + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src", "tests"] + +[tool.pytest.ini_options] +addopts = ["--strict-config", "--strict-markers"] +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["darrow_git"] + +[tool.coverage.report] +show_missing = true +skip_covered = false diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/__init__.py b/plugins/capability/darrow-git/backend/src/darrow_git/__init__.py new file mode 100644 index 00000000..b3aca5db --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/__init__.py @@ -0,0 +1 @@ +"""Deterministic mechanics; authority and contextual decisions stay in skills.""" diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/branch.py b/plugins/capability/darrow-git/backend/src/darrow_git/branch.py new file mode 100644 index 00000000..d5adfaef --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/branch.py @@ -0,0 +1,175 @@ +"""Create and prepare branches through the established command contracts.""" + +from . import worktree +from .branch_options import BranchOptions, parse +from .names import discover, validate_name +from .process import RefusalError, emit, git, require, succeeds +from .repository import ( + current_branch, + current_ref, + in_progress, + local_default, + require_repository, + validate_base, + worktrees, +) + + +def inspect(*, task: bool) -> None: + if in_progress(): + verb = "switch" if task else "branch" + print( + f"## mode: conflict (merge/rebase/cherry-pick in progress — do not {verb}; inform the user)" + ) + print("## unmerged files") + emit(git("diff", "--name-only", "--diff-filter=U"), 50) + return + print("## mode: ready") + print(f"## current branch: {current_ref()}") + print(f"## default branch: {local_default()}") + description = ( + "compatible uncommitted changes travel unchanged" + if task + else "uncommitted changes travel to the new branch" + ) + print(f"## working tree ({description})") + emit(git("status", "--porcelain") or "clean", 50) + inspect_branches(task) + + +def inspect_branches(task: bool) -> None: + if task: + print("## local branches") + emit( + git( + "for-each-ref", + "--sort=refname", + "--format=%(refname:lstrip=2)", + "refs/heads", + ), + 50, + ) + return + print("## recent branches (match their naming style)") + emit( + git( + "for-each-ref", + "--count=10", + "--sort=-committerdate", + "--format=%(refname:short)", + "refs/heads", + ) + ) + top = git("rev-parse", "--show-toplevel") + others = [ + f"{tree.path} [{tree.branch}]" for tree in worktrees() if str(tree.path) != top + ] + if others: + print("## other worktrees (their branches are checked out elsewhere)") + emit("\n".join(others), 50) + + +def report_matches(matches: list[str]) -> str: + return "\n".join([f"## matches: {len(matches)}", *matches]) + + +def collisions(options: BranchOptions, task: bool) -> bool: + names = git( + "for-each-ref", "refs/heads", "--format=%(refname:lstrip=2)" + ).splitlines() + existing = options.name in names + matches = "\n".join(name for name in names if name.lower() == options.name.lower()) + if not task: + require( + not matches, + f"branch already exists: {matches} — will not reuse or reset it", + 9, + ) + else: + require( + existing or not matches, + f"branch name differs only by case from existing branch: {matches}", + 9, + ) + guard_discovery(options, existing) + return existing + + +def guard_discovery(options: BranchOptions, existing: bool) -> None: + if existing: + return + matches = discover(options.token) + if matches: + raise RefusalError( + "error: correlated local branches exist; bind one exact existing branch before preparation\n" + + report_matches(matches), + 9, + ) + + +def prepare(options: BranchOptions, *, task: bool) -> None: + verb = "switch" if task else "branch" + require( + not in_progress(), + f"merge/rebase/cherry-pick in progress — resolve it first; do not {verb}", + 8, + ) + require( + succeeds("rev-parse", "-q", "--verify", "HEAD"), + "repository has no commits yet — make the first commit before branching", + 3, + ) + validate_name(options.name, options.token, task=task) + existing = collisions(options, task) + if options.worktree: + if task and worktree.reuse(options): + return + worktree.add(options, task=task, existing=existing) + else: + switch(options, task=task, existing=existing) + + +def switch(options: BranchOptions, *, task: bool, existing: bool) -> None: + if existing: + mode = "current" if current_branch() == options.name else "reused" + if mode == "reused": + git("switch", "--no-guess", options.name) + print(f"## mode: {mode}") + print(f"{options.name} (at {git('rev-parse', f'refs/heads/{options.name}')})") + return + validate_base(options.base) + source = options.base or current_ref() + git("switch", "-c", options.name, *([options.base] if options.base else [])) + if task: + print("## mode: created") + print(f"{options.name} (from {source})") + + +def run(args: list[str], *, task: bool = False) -> None: + require_repository() + command = args[0] if args else "" + if command == "inspect": + inspect(task=task) + elif command == ("prepare" if task else "create"): + prepare(parse(args[1:], task=task), task=task) + elif command == "discover" and task: + require( + len(args) == 3 and args[1] == "--ticket-token" and args[2], + "discover requires --ticket-token ", + 2, + ) + matches = discover(args[2]) + print( + f"## mode: discovered\n## ticket token: {args[2]}\n{report_matches(matches)}" + ) + else: + raise RefusalError( + "usage: branch.sh " + + ( + "discover --ticket-token | inspect | prepare --ticket-token " + if task + else "inspect | create [--ticket-token ]" + ) + + " [--from ] [--worktree [--at ]]", + 64, + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py new file mode 100644 index 00000000..12453992 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py @@ -0,0 +1,61 @@ +"""Parse the compatibility branch command arguments without changing refusals.""" + +from dataclasses import dataclass + +from .process import RefusalError, require + + +@dataclass +class BranchOptions: + name: str = "" + base: str = "" + token: str = "" + worktree: bool = False + at: str | None = None + + +def option_value(args: list[str], index: int, task: bool) -> str: + flag = args[index] + messages = { + "--from": "--from needs a value", + "--ticket-token": "--ticket-token needs a non-empty opaque value", + "--at": "--at needs a non-empty path" if task else "--at needs a value", + } + require(index + 1 < len(args), messages[flag], 2) + value = args[index + 1] + if flag == "--ticket-token" or (flag == "--at" and task): + require(value, messages[flag], 2) + return value + + +def parse(args: list[str], *, task: bool) -> BranchOptions: + options = BranchOptions() + index = 0 + while index < len(args): + index += consume(options, args, index, task) + require(options.name, "no branch name given", 2) + if task: + require( + options.token, + "prepare requires the active provider's opaque ticket token", + 2, + ) + require(options.at is None or options.worktree, "--at requires --worktree", 2) + require(options.at != "", "--at needs a non-empty path", 2) + return options + + +def consume(options: BranchOptions, args: list[str], index: int, task: bool) -> int: + flag = args[index] + fields = {"--from": "base", "--ticket-token": "token", "--at": "at"} + if flag in fields: + setattr(options, fields[flag], option_value(args, index, task)) + return 2 + if flag == "--worktree": + options.worktree = True + return 1 + if flag.startswith("-"): + raise RefusalError(f"error: unknown flag: {flag}", 2) + require(not options.name, "exactly one branch name allowed", 2) + options.name = flag + return 1 diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/cli.py b/plugins/capability/darrow-git/backend/src/darrow_git/cli.py new file mode 100644 index 00000000..68d6fc97 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/cli.py @@ -0,0 +1,53 @@ +"""Console entrypoints with stable process exit contracts.""" + +import io +import sys +from collections.abc import Callable + +from .process import RefusalError + + +def execute(operation: Callable[[list[str]], None]) -> None: + for stream in (sys.stdout, sys.stderr): + if isinstance(stream, io.TextIOWrapper): + stream.reconfigure(encoding="utf-8", errors="surrogateescape", newline="\n") + try: + operation(sys.argv[1:]) + except RefusalError as error: + print(str(error), file=sys.stderr) + raise SystemExit(error.code) from error + except OSError as error: + # Explicit CLI filesystem boundary: refuse rather than leak a traceback + # or suggest completion after an unreadable artifact/provider failure. + print(f"error: {error}", file=sys.stderr) + raise SystemExit(4) from error + + +def create_branch() -> None: + from .branch import run + + execute(run) + + +def prepare_branch() -> None: + from .branch import run + + execute(lambda args: run(args, task=True)) + + +def create_commit() -> None: + from .commit import run + + execute(run) + + +def create_pr() -> None: + from .pr import run + + execute(run) + + +def publish_evidence() -> None: + from .evidence import run + + execute(run) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/commit.py b/plugins/capability/darrow-git/backend/src/darrow_git/commit.py new file mode 100644 index 00000000..170e621f --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/commit.py @@ -0,0 +1,166 @@ +"""Exact staged-set commits with normal hooks and guarded retries.""" + +import os +from pathlib import Path + +from .commit_options import CommitOptions, parse +from .messages import validate_attribution, validate_subject +from .process import RefusalError, decode, emit, git, invoke, probe, require, succeeds + + +def require_no_conflicts() -> None: + require( + not git("ls-files", "-u"), + "merge/rebase in progress — resolve conflicts first; do not commit", + 8, + ) + + +def validate_paths(paths: list[str]) -> None: + for path in paths: + invalid = ( + path in {".", ".."} + or path.startswith(("-", ":")) + or any(char in path for char in "*?[") + ) + require(not invalid, f"only explicit file paths allowed, got: {path}", 7) + + +def validate_refresh(paths: list[str]) -> None: + validate_paths(paths) + for path in paths: + require( + git("diff", "--cached", "--name-only", "--", path) == path, + f"retry path is not in the existing staged set: {path}", + 7, + ) + + +def head() -> str: + return probe("rev-parse", "--verify", "HEAD") or "unborn" + + +def failure_path() -> Path: + return ( + Path(git("rev-parse", "--git-dir")).absolute() + / "darrow-create-commit-hook-failure" + ) + + +def save_failure(output: str, previous_head: str, tree: str) -> None: + path = failure_path() + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen( + descriptor, "w", encoding="utf-8", errors="surrogateescape" + ) as stream: + stream.write( + f"format\tdarrow-create-commit-hook-failure-v1\nhead\t{previous_head}\nindex_tree\t{tree}\noutput\n{output}\n" + ) + + +def commit(options: CommitOptions) -> None: + require_no_conflicts() + require( + succeeds("diff", "--cached", "--quiet") or not options.files, + "a staged set exists; pass no paths (commit exactly the staged set)", + 7, + ) + validate_paths(options.files) + validate_subject(options.messages[0].split("\n", 1)[0], "subject") + validate_attribution("\n\n".join(options.messages), "commit messages") + if options.files: + git("add", "--", *options.files) + require(not succeeds("diff", "--cached", "--quiet"), "nothing staged", 3) + previous_head, tree = head(), git("write-tree") + args = [item for message in options.messages for item in ("-m", message)] + result = invoke(["git", "commit", "-q", *args]) + if result.returncode: + if head() == previous_head: + git("read-tree", tree) + output = decode(result.stdout + result.stderr) + save_failure(output, previous_head, tree) + raise RefusalError(output, 4) + emit(git("log", "-1", "--format=%h %s")) + + +def retry(options: CommitOptions) -> None: + require_no_conflicts() + require( + not succeeds("diff", "--cached", "--quiet"), + "retry needs an existing staged set", + 7, + ) + validate_refresh(options.files) + git("add", "--", *options.files) + commit(CommitOptions(messages=options.messages)) + + +def inspect() -> None: + if git("ls-files", "-u"): + print( + "## mode: conflict (merge/rebase in progress — do not commit; inform the user)\n## unmerged files" + ) + emit(git("diff", "--name-only", "--diff-filter=U")) + return + staged = not succeeds("diff", "--cached", "--quiet") + if staged: + print( + "## mode: staged (commit exactly this set; pass no paths)\n## staged files" + ) + emit(git("diff", "--cached", "--name-status")) + print("## not included (unstaged/untracked)") + emit(git("diff", "--name-only")) + else: + print( + "## mode: unstaged (select only files belonging to the change)\n## unstaged files" + ) + emit(git("diff", "--name-status")) + print("## untracked files") + emit(git("ls-files", "--others", "--exclude-standard")) + print("## recent subjects") + emit(probe("log", "-5", "--format=%s")) + print(f"## {'staged' if staged else 'unstaged'} diff (truncated at 300 lines)") + emit(git("diff", *(["--cached"] if staged else []), "--unified=2"), 300) + + +def diff(paths: list[str]) -> None: + require(paths, "diff needs at least one path", 64) + for path in paths: + if succeeds("ls-files", "--error-unmatch", "--", path): + emit(git("diff", "--unified=2", "--", path), 300) + else: + require(Path(path).is_file(), f"no such file: {path}", 64) + emit( + decode( + invoke( + [ + "git", + "diff", + "--no-index", + "--unified=2", + "--", + os.devnull, + path, + ] + ).stdout + ), + 300, + ) + + +def run(args: list[str]) -> None: + from .remediation import remediate + + operations = {"commit": commit, "retry": retry, "remediate": remediate} + operation = args[0] if args else "" + if operation == "inspect": + inspect() + elif operation == "diff": + diff(args[1:]) + elif operation in operations: + operations[operation](parse(args[1:], operation)) + else: + raise RefusalError( + "usage: commit.sh inspect | diff ... | commit [-m ]... []... | retry --after-hook-failure --refresh-staged ... -m | remediate --after-hook-failure --command --refresh-staged ... -m ", + 64, + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/commit_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/commit_options.py new file mode 100644 index 00000000..8d774762 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/commit_options.py @@ -0,0 +1,79 @@ +"""Commit, retry, and remediation argument contracts.""" + +from dataclasses import dataclass, field + +from .process import RefusalError, require + + +@dataclass +class CommitOptions: + messages: list[str] = field(default_factory=list) + files: list[str] = field(default_factory=list) + after_failure: bool = False + command: str = "" + + +def parse(args: list[str], operation: str) -> CommitOptions: + options = CommitOptions() + index = 0 + while index < len(args): + index = consume(options, args, index, operation) + if operation == "commit": + require(options.messages, "no -m message given", 2) + else: + validate_retry(options, operation) + return options + + +def validate_retry(options: CommitOptions, operation: str) -> None: + command_text = ", --command" if operation == "remediate" else "" + require( + options.after_failure and options.files and options.messages, + f"{operation} needs --after-hook-failure{command_text}, --refresh-staged ..., and -m", + 2, + ) + if operation == "remediate": + require( + options.command, + "remediate needs --after-hook-failure, --command, --refresh-staged ..., and -m", + 2, + ) + + +def consume(options: CommitOptions, args: list[str], index: int, operation: str) -> int: + flag = args[index] + if flag == "-m" or (flag == "--command" and operation == "remediate"): + require(index + 1 < len(args), f"{flag} needs a value", 2) + if flag == "-m": + options.messages.append(args[index + 1]) + else: + options.command = args[index + 1] + return index + 2 + if operation == "commit": + options.files.append(flag) + return index + 1 + return consume_retry(options, args, index, operation) + + +def consume_retry( + options: CommitOptions, args: list[str], index: int, operation: str +) -> int: + flag = args[index] + if flag == "--after-hook-failure": + options.after_failure = True + return index + 1 + if flag == "--refresh-staged": + index += 1 + stops = {"-m", "--after-hook-failure", "--refresh-staged"} + if operation == "remediate": + stops.add("--command") + while index < len(args) and args[index] not in stops: + options.files.append(args[index]) + index += 1 + return index + allowed = ( + "--after-hook-failure, " + + ("--command, " if operation == "remediate" else "") + + "--refresh-staged, and -m" + ) + raise RefusalError(f"error: {operation} accepts only {allowed}", 2) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence.py new file mode 100644 index 00000000..f092d4c5 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence.py @@ -0,0 +1,221 @@ +"""One candidate-bound evidence publication, with no mutation retry.""" + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import NoReturn + +from . import evidence_preflight as preflight +from .evidence_files import Snapshot, prepared_file, snapshot +from .evidence_identity import Identity, Reconciliation, build, classify +from .evidence_options import EvidenceOptions, parse +from .process import RefusalError, git, invoke, probe, require +from .repository import current_branch + + +@dataclass +class Evidence: + options: EvidenceOptions + candidate: preflight.Candidate + pr: preflight.PullRequest + files: Snapshot + identity: Identity + prepared: Path + prepared_hash: str + head_snapshot: str + index_snapshot: str + stage: str = "reconciliation" + observation: Reconciliation = field(default_factory=Reconciliation) + observed_head: str = "" + + +def report(state: Evidence, outcome: str, effects: str, uncertainty: str) -> None: + fields = [ + ("outcome", outcome), + ("stage", state.stage), + ("repository", state.candidate.repository), + ("pr-url", state.pr.url), + ("expected-head", state.options.expected), + ("observed-head", state.observed_head), + ("identity", state.identity.value), + ("comment-url", state.observation.url or "unknown"), + ("attachment-count", str(len(state.files.attachments))), + ] + fields.extend( + (f"attachment-{index}", item.identity) + for index, item in enumerate(state.files.attachments, 1) + ) + fields.extend( + (f"observed-attachment-{index}", asset) + for index, asset in enumerate(state.observation.assets, 1) + ) + fields.extend( + [ + ("prepared-body", str(state.prepared)), + ("attachment-snapshot", str(state.files.directory)), + ("effects", effects), + ("uncertainty", uncertainty), + ( + "head-preserved", + str(git("rev-parse", "HEAD") == state.head_snapshot).lower(), + ), + ("index-preserved", str(git("write-tree") == state.index_snapshot).lower()), + ( + "files-preserved", + str( + all(item.original.is_file() for item in state.files.attachments) + ).lower(), + ), + ] + ) + print("\n".join(f"{name}: {value}" for name, value in fields)) + + +def finish( + state: Evidence, outcome: str, effects: str, uncertainty: str, code: int +) -> NoReturn: + report(state, outcome, effects, uncertainty) + raise SystemExit(code) + + +def refuse(stage: str, error: Exception) -> NoReturn: + print( + f"outcome: refused\nstage: {stage}\neffects: none\nuncertainty: none\nerror: {str(error).removeprefix('error: ')}" + ) + raise SystemExit(4) from error + + +def prepare(args: list[str]) -> Evidence: + stage = "arguments" + try: + options = parse(args) + candidate = preflight.candidate(options.expected) + stage = "pr-preflight" + pr = preflight.observe(candidate) + require( + pr.head == options.expected, "current PR head differs from expected head" + ) + stage = "cli-preflight" + preflight.cli_support() + stage = "attachment-preflight" + files = snapshot(options, candidate.top) + identity = build(candidate.repository, pr.number, options.expected, files) + prepared = prepared_file(identity.prepared, candidate.top) + return Evidence( + options, + candidate, + pr, + files, + identity, + prepared, + git("hash-object", str(prepared)), + git("rev-parse", "HEAD"), + git("write-tree"), + observed_head=pr.head, + ) + except (RefusalError, OSError) as error: + refuse(stage, error) + + +def reconcile(state: Evidence) -> None: + try: + rows = preflight.comments(state.candidate, state.pr) + except RefusalError as error: + refuse(state.stage, error) + state.observation = classify(rows, state.identity, len(state.files.attachments)) + outcomes = { + "complete": ("existing", "none", 0), + "partial": ("partial", "one incomplete candidate-bound comment exists", 5), + "ambiguous": ("ambiguous", "conflicting candidate-bound comments exist", 5), + } + if state.observation.outcome in outcomes: + outcome, uncertainty, code = outcomes[state.observation.outcome] + finish(state, outcome, "none", uncertainty, code) + + +def recheck_files(state: Evidence) -> None: + state.stage = "publication" + state.observed_head = git("rev-parse", "HEAD") + if state.observed_head != state.options.expected: + finish(state, "refused", "none", "local head changed before publication", 4) + if probe("hash-object", str(state.prepared)) != state.prepared_hash: + finish(state, "refused", "none", "prepared body changed before publication", 4) + if any( + probe("hash-object", str(item.snapshot)) != item.content + for item in state.files.attachments + ): + finish( + state, + "refused", + "none", + "attachment snapshot changed before publication", + 4, + ) + + +def publish(state: Evidence) -> int: + args = ["gh", "pr", "comment", state.pr.url, "--body-file", str(state.prepared)] + for item in state.files.attachments: + value = item.snapshot.name + (f"#{item.text}" if item.kind == "image" else "") + args.extend(["--attach", value]) + env = dict(os.environ, DARROW_EVIDENCE_REPOSITORY=str(state.candidate.top)) + result = invoke(args, cwd=state.files.directory, env=env) + (state.files.directory / "comment-command.stdout").write_bytes(result.stdout) + (state.files.directory / "comment-command.stderr").write_bytes(result.stderr) + return result.returncode + + +def post_observation(state: Evidence) -> bool: + same_candidate = False + try: + observed = preflight.observe(state.candidate) + state.observed_head = observed.head + same_candidate = observed == state.pr + except RefusalError: + state.observed_head = "" + try: + rows = preflight.comments(state.candidate, state.pr) + except RefusalError: + rows = "" + state.observation = classify(rows, state.identity, len(state.files.attachments)) + return ( + same_candidate + and current_branch() == state.candidate.branch + and probe("rev-parse", "HEAD") == state.options.expected + ) + + +def complete(state: Evidence, returncode: int) -> None: + state.stage = "post-publication" + attempted = "one gh pr comment invocation attempted" + if not post_observation(state): + finish(state, "partial", attempted, "candidate head or PR identity changed", 5) + if state.observation.outcome == "complete": + finish(state, "published", "one gh pr comment invocation completed", "none", 0) + if state.observation.outcome == "partial": + finish( + state, + "partial", + attempted, + "incomplete candidate-bound comment observed", + 5, + ) + finish( + state, + "ambiguous", + attempted, + f"publication result unclassifiable (command exit {returncode})", + 5, + ) + + +def run(args: list[str]) -> None: + if not args or args[0] != "publish": + raise RefusalError( + "usage: publish.sh publish --expected-head --body-file [--image --alt | --video --explanation ]...", + 64, + ) + state = prepare(args[1:]) + reconcile(state) + recheck_files(state) + complete(state, publish(state)) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_files.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_files.py new file mode 100644 index 00000000..9eafa41e --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_files.py @@ -0,0 +1,134 @@ +"""Native file snapshots and path-independent attachment identities.""" + +import os +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from . import media +from .evidence_options import AttachmentInput, EvidenceOptions +from .process import RefusalError, decode, git, invoke, readable, require + + +@dataclass(frozen=True) +class Attachment: + original: Path + snapshot: Path + kind: str + text: str + content: str + identity: str + + +@dataclass(frozen=True) +class Snapshot: + directory: Path + body: bytes + attachments: list[Attachment] + + +def hash_bytes(value: bytes) -> str: + result = invoke(["git", "hash-object", "--stdin"], input_bytes=value) + require(result.returncode == 0, "cannot hash evidence content") + return decode(result.stdout) + + +def canonical_file(path: Path) -> Path: + # Resolve the parent, as in the compatibility interface; preserve filename. + return path.parent.resolve(strict=True) / path.name + + +def checked_file(path: Path, description: str) -> Path: + try: + canonical = canonical_file(path) + require(readable(canonical) and canonical.stat().st_size, description) + return canonical + except OSError as error: + raise RefusalError(f"error: {description}") from error + + +def checked_media(item: AttachmentInput, top: Path) -> tuple[Path, str, int]: + path = checked_file( + item.path, + f"attachment must be a readable non-empty regular file: {item.path.absolute()}", + ) + require( + not path.is_relative_to(top), + f"evidence attachment must remain outside the repository: {path}", + ) + extension = path.suffix[1:].lower() + require( + extension in {*media.SIGNATURES, "svg"}, f"unsupported attachment type: {path}" + ) + kind = "video" if extension in {"mp4", "mov", "webm"} else "image" + require(item.kind == kind, f"attachment kind does not match file type: {path}") + size = path.stat().st_size + require( + size <= (104857600 if kind == "video" else 10485760), + f"attachment exceeds supported size: {path}", + ) + require( + media.matches(path, extension, size), + f"attachment content does not match its supported type: {path}", + ) + return path, extension, size + + +def snapshot_attachment( + item: AttachmentInput, top: Path, directory: Path, number: int, seen: set[str] +) -> Attachment: + path, extension, size = checked_media(item, top) + content = git("hash-object", str(path)) + require(content not in seen, f"duplicate attachment content: {path}") + seen.add(content) + presentation_hash = hash_bytes(item.text.encode("utf-8")) + identity = f"{number}:{item.kind}:{content}:{size}:{presentation_hash}" + snapshot = directory / f"darrow-evidence-attachment-{number}.{extension}" + shutil.copyfile(path, snapshot) + require( + git("hash-object", str(snapshot)) == content, + f"attachment changed while snapshotting: {path}", + ) + return Attachment(path, snapshot, item.kind, item.text, content, identity) + + +def snapshot(options: EvidenceOptions, top: Path) -> Snapshot: + body = checked_file(Path(options.body), "body file must be readable and non-empty") + original = body.read_bytes() + require( + b"darrow-evidence-attachment-" not in original, + "body file contains reserved attachment reference text", + ) + require( + b"github.com/user-attachments/assets/" not in original, + "body file contains an attachment URL that prevents deterministic reconciliation", + ) + directory = Path(tempfile.mkdtemp(prefix="darrow-pr-evidence-files.")).resolve() + require( + not directory.is_relative_to(top), + "attachment snapshot location is inside the repository", + ) + seen: set[str] = set() + attachments = [ + snapshot_attachment(item, top, directory, index, seen) + for index, item in enumerate(options.attachments, 1) + ] + target = directory / "body" + shutil.copyfile(body, target) + require( + git("hash-object", str(target)) == git("hash-object", str(body)), + "body file changed while snapshotting", + ) + return Snapshot(directory, target.read_bytes(), attachments) + + +def prepared_file(body: bytes, top: Path) -> Path: + descriptor, name = tempfile.mkstemp(prefix="darrow-pr-evidence.") + with os.fdopen(descriptor, "wb") as stream: + stream.write(body) + path = Path(name).resolve() + require( + not path.is_relative_to(top), "temporary body location is inside the repository" + ) + return path diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_identity.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_identity.py new file mode 100644 index 00000000..08fd5f4c --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_identity.py @@ -0,0 +1,110 @@ +"""Stable v1 evidence identity, prepared Markdown, and comment reconciliation.""" + +import re +from dataclasses import dataclass, field + +from .evidence_files import Snapshot, hash_bytes +from .process import git + +ASSET = re.compile(r"https://github\.com/user-attachments/assets/[A-Za-z0-9._?=&%/\-]+") +PLACEHOLDER = re.compile( + r"darrow-evidence-attachment-[0-9]+\.(png|jpg|jpeg|gif|webp|svg|mp4|mov|webm)" +) + + +@dataclass(frozen=True) +class Identity: + value: str + marker: str + candidate_marker: str + prepared: bytes + normalized: str + + +@dataclass +class Reconciliation: + outcome: str = "none" + url: str = "" + assets: list[str] = field(default_factory=list) + + +def escape_tsv(text: str) -> str: + return ( + text.replace("\\", "\\\\") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("\n", "\\n") + ) + + +def build(repository: str, number: str, expected: str, snapshot: Snapshot) -> Identity: + manifest = "".join( + f"\nattachment-{index}={item.identity}" + for index, item in enumerate(snapshot.attachments, 1) + ) + body_hash = git("hash-object", str(snapshot.directory / "body")) + identity_input = f"repository={repository}\npr={number}\nhead={expected}\nbody={body_hash}\nattachment-count={len(snapshot.attachments)}{manifest}" + value = hash_bytes(identity_input.encode()) + candidate = f"{repository}#{number}@{expected}" + marker = f"" + prepared = ( + marker.encode() + + b"\n\n" + + snapshot.body + + presentation(snapshot).encode() + + f"\n".encode() + ) + normalized = PLACEHOLDER.sub( + "__DARROW_ATTACHMENT_URL__", + escape_tsv(prepared.decode("utf-8", errors="surrogateescape")), + ) + return Identity( + value, + marker, + f"darrow-pr-evidence:v1 candidate={candidate}", + prepared, + normalized, + ) + + +def presentation(snapshot: Snapshot) -> str: + if not snapshot.attachments: + return "" + lines = ["\n\n### Attached evidence\n"] + for index, item in enumerate(snapshot.attachments, 1): + if item.kind == "image": + lines.append( + f"- Image {index} alt text: {item.text}\n![{item.text}; content identity {item.content}]({item.snapshot.name})\n" + ) + else: + lines.append( + f"- Video {index} explanation: {item.text}; content identity {item.content}\n![]({item.snapshot.name})\n" + ) + return "".join(lines) + + +def classify(data: str, identity: Identity, count: int) -> Reconciliation: + related = [ + row.split("\t", 2) + for row in data.splitlines() + if identity.candidate_marker in row + ] + if not related: + return Reconciliation() + if len(related) != 1 or len(related[0]) != 3: + return Reconciliation( + "ambiguous", related[-1][1] if len(related[-1]) > 1 else "" + ) + _, url, body = related[0] + if identity.marker not in body: + return Reconciliation("ambiguous", url) + assets = ASSET.findall(body) + normalized = ASSET.sub("__DARROW_ATTACHMENT_URL__", body) + complete = ( + normalized == identity.normalized + and len(assets) == count + and len(set(assets)) == count + ) + return Reconciliation( + "complete" if complete else "partial", url, assets if complete else [] + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_options.py new file mode 100644 index 00000000..a2ac689b --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_options.py @@ -0,0 +1,72 @@ +"""Evidence input order and presentation metadata.""" + +from dataclasses import dataclass, field +from pathlib import Path + +from .process import RefusalError, require +from .publication import FULL_COMMIT + + +@dataclass(frozen=True) +class AttachmentInput: + path: Path + kind: str + text: str + + +@dataclass +class EvidenceOptions: + expected: str = "" + body: str = "" + attachments: list[AttachmentInput] = field(default_factory=list) + + +def attachment(args: list[str], index: int) -> AttachmentInput: + flag = args[index] + kind = flag.removeprefix("--") + require( + len(args) >= index + 4 and args[index + 1], + f"{flag} needs a path and presentation text", + ) + presentation_flag = "--alt" if kind == "image" else "--explanation" + require( + args[index + 2] == presentation_flag, + f"{flag} must be followed by {presentation_flag}", + ) + text = args[index + 3] + require(text.strip(), f"{kind} presentation text must be meaningful") + require( + not any(char in text for char in "\n\r\t"), + f"{kind} presentation text must be one line", + ) + return AttachmentInput(Path(args[index + 1]), kind, text) + + +def consume(options: EvidenceOptions, args: list[str], index: int) -> int: + flag = args[index] + if flag in {"--image", "--video"}: + options.attachments.append(attachment(args, index)) + return index + 4 + if flag in {"--expected-head", "--body-file"}: + require(len(args) > index + 1 and args[index + 1], f"{flag} needs a value") + setattr( + options, + "expected" if flag == "--expected-head" else "body", + args[index + 1], + ) + return index + 2 + raise RefusalError(f"error: unknown argument: {flag}") + + +def parse(args: list[str]) -> EvidenceOptions: + options = EvidenceOptions() + index = 0 + while index < len(args): + index = consume(options, args, index) + require( + FULL_COMMIT.fullmatch(options.expected), + "--expected-head requires a full commit ID", + ) + require(options.body, "--body-file is required") + require(len(options.attachments) <= 50, "at most 50 attachments are supported") + return options diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_preflight.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_preflight.py new file mode 100644 index 00000000..df3aa41a --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_preflight.py @@ -0,0 +1,147 @@ +"""Read-only GitHub identity and attachment support checks.""" + +import re +from dataclasses import dataclass +from pathlib import Path + +from .process import RefusalError, command, git, require +from .publication import REPOSITORY +from .repository import current_branch, require_repository + +FIELDS = "number,url,headRefOid,isCrossRepository" +QUERY = ".[] | [.number,.url,.headRefOid,.isCrossRepository] | @tsv" + + +@dataclass(frozen=True) +class Candidate: + top: Path + repository: str + branch: str + + +@dataclass(frozen=True) +class PullRequest: + number: str + url: str + head: str + + +def checked(message: str, *args: str) -> str: + try: + return command(*args) + except RefusalError as error: + raise RefusalError(f"error: {message}") from error + + +def candidate(expected: str) -> Candidate: + require_repository() + top = Path(git("rev-parse", "--show-toplevel")) + require( + git("rev-parse", "HEAD") == expected, "local HEAD differs from expected head" + ) + branch = current_branch() + require(branch, "a feature branch is required") + row = checked( + "cannot identify the repository", + "gh", + "repo", + "view", + "--json", + "nameWithOwner,url", + "--jq", + "[.nameWithOwner,.url] | @tsv", + ) + fields = row.split("\t") + require( + len(fields) == 2 + and REPOSITORY.fullmatch(fields[0]) + and fields[1].startswith("https://github.com/"), + "active repository is not on a supported GitHub attachment host", + ) + return Candidate(top, fields[0], branch) + + +def observe(candidate: Candidate) -> PullRequest: + rows = checked( + "cannot observe open pull requests", + "gh", + "pr", + "list", + "--repo", + candidate.repository, + "--head", + candidate.branch, + "--state", + "open", + "--limit", + "2", + "--json", + FIELDS, + "--jq", + QUERY, + ) + lines = [line for line in rows.splitlines() if line.strip()] + require(len(lines) == 1, "exactly one open PR is required") + fields = lines[0].split("\t") + require(len(fields) == 4, "malformed or cross-repository PR identity") + number, url, head, cross = fields + require( + number.isdigit() and url.startswith("https://") and cross == "false", + "malformed or cross-repository PR identity", + ) + return PullRequest(number, url, head) + + +def advertised_limit(help_text: str) -> int: + active = False + limits = [50] + for line in help_text.lower().splitlines(): + active = attachment_help_active(line, active) + if active: + limits.extend( + int(value) + for value in re.findall(r"(?:maximum|max|up to)[^0-9]*([0-9]+)", line) + ) + if not line.strip(): + active = False + return min(limits) + + +def attachment_help_active(line: str, previous: bool) -> bool: + if "--attach" in line: + return True + if re.match(r"^\s+--[a-z0-9-]+", line): + return False + return previous + + +def cli_support() -> None: + help_text = checked( + "cannot inspect gh pr comment support", "gh", "pr", "comment", "--help" + ) + require( + "--attach" in help_text, + "installed gh lacks pr comment --attach; install a supported GitHub CLI before retrying", + ) + limit = advertised_limit(help_text) + require( + limit >= 50, f"installed gh advertises a stricter attachment limit: {limit}" + ) + checked( + "active GitHub host does not confirm attachment publication support", + "gh", + "api", + "meta", + ) + + +def comments(candidate: Candidate, pr: PullRequest) -> str: + return checked( + "cannot reconcile top-level PR comments", + "gh", + "api", + f"repos/{candidate.repository}/issues/{pr.number}/comments", + "--paginate", + "--jq", + ".[] | [.id,.html_url,.body] | @tsv", + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/media.py b/plugins/capability/darrow-git/backend/src/darrow_git/media.py new file mode 100644 index 00000000..c4ce965f --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/media.py @@ -0,0 +1,80 @@ +"""Bounded media signature checks for the supported attachment types.""" + +import re +from collections.abc import Callable +from pathlib import Path + + +def png(data: bytes, tail: bytes, size: int) -> bool: + return ( + size >= 45 + and data.startswith(bytes.fromhex("89504e470d0a1a0a0000000d49484452")) + and data[16:20] != b"\0" * 4 + and data[20:24] != b"\0" * 4 + and tail.endswith(bytes.fromhex("0000000049454e44ae426082")) + ) + + +def jpeg(data: bytes, tail: bytes, size: int) -> bool: + return ( + size >= 12 + and data.startswith(b"\xff\xd8\xff") + and any(marker in data for marker in (b"\xff\xc0", b"\xff\xc1", b"\xff\xc2")) + and tail.endswith(b"\xff\xd9") + ) + + +def gif(data: bytes, tail: bytes, size: int) -> bool: + return ( + size >= 14 + and data.startswith((b"GIF87a", b"GIF89a")) + and data[6:10] != b"\0" * 4 + and tail.endswith(b";") + ) + + +def webp(data: bytes, tail: bytes, size: int) -> bool: + return ( + size >= 20 + and data.startswith(b"RIFF") + and data[8:16] in (b"WEBPVP8 ", b"WEBPVP8L", b"WEBPVP8X") + ) + + +def video(data: bytes, tail: bytes, size: int) -> bool: + return size >= 20 and data[4:8] == b"ftyp" and (b"moov" in data or b"mdat" in data) + + +def webm(data: bytes, tail: bytes, size: int) -> bool: + return ( + size >= 8 + and data.startswith(bytes.fromhex("1a45dfa3")) + and bytes.fromhex("18538067") in data[:4096] + ) + + +SIGNATURES: dict[str, Callable[[bytes, bytes, int], bool]] = { + "png": png, + "jpg": jpeg, + "jpeg": jpeg, + "gif": gif, + "webp": webp, + "mp4": video, + "mov": video, + "webm": webm, +} + + +def matches(path: Path, extension: str, size: int) -> bool: + with path.open("rb") as stream: + prefix = stream.read(65536) + stream.seek(max(0, size - 12)) + tail = stream.read(12) + if extension == "svg": + return ( + size >= 12 + and bool(re.search(rb"]", prefix[:4096])) + and bool(re.search(rb"", path.read_bytes())) + ) + check = SIGNATURES.get(extension) + return check(prefix, tail, size) if check else False diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/messages.py b/plugins/capability/darrow-git/backend/src/darrow_git/messages.py new file mode 100644 index 00000000..4bfc873d --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/messages.py @@ -0,0 +1,29 @@ +"""Conventional subjects and attribution guards shared by commits and PRs.""" + +import re + +from .names import TYPES +from .process import require + +TOOLS = r"claude|gpt|chatgpt|codex|copilot|cursor|gemini" +ATTRIBUTION = re.compile( + rf"co-authored-by:.*\b({TOOLS}|ai)\b|co[- ]?authored[- ]by +({TOOLS})\b|" + rf"(generated|built|written|created|made|assisted)[- ](with|by|using) +\[?({TOOLS}|an? ai\b|ai\b)|🤖", + re.IGNORECASE, +) + + +def validate_subject(subject: str, label: str) -> None: + require( + re.match(rf"^({TYPES})(\([^)]+\))?!?: \S", subject), + f"{label} not Conventional Commits format: {subject}", + 5, + ) + require(len(subject) <= 72, f"{label} exceeds 72 chars ({len(subject)})", 5) + require(not subject.endswith("."), f"{label} has trailing period", 5) + + +def validate_attribution(text: str, label: str) -> None: + require( + not ATTRIBUTION.search(text), f"AI attribution is not allowed in {label}", 6 + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/names.py b/plugins/capability/darrow-git/backend/src/darrow_git/names.py new file mode 100644 index 00000000..32945f35 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/names.py @@ -0,0 +1,102 @@ +"""Conventional names and opaque ticket-token contracts.""" + +import re + +from .process import RefusalError, git, require, succeeds + +TYPES = "feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert" +KEBAB = r"[a-z0-9]+(-[a-z0-9]+)*" + + +def validate_token(token: str, *, task: bool = False) -> None: + require( + re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]*", token), + f"ticket token contains unsupported branch characters: {token}", + 5, + ) + if task: + require( + len(token) <= 55 + and succeeds("check-ref-format", "--branch", f"ci/{token}-a"), + f"ticket token cannot form a valid task branch: {token}", + 5, + ) + + +def validate_suffix(name: str, token: str) -> None: + slug = name.split("/", 1)[1] + require( + slug.startswith(token + "-"), + f"branch must lead with the supplied ticket token exactly: {name}", + 5, + ) + require( + slug.count(token) == 1, + f"supplied ticket token must occur exactly once: {name}", + 5, + ) + require( + re.fullmatch(KEBAB, slug[len(token) + 1 :]), + f"branch suffix must be lowercase kebab-case: {name}", + 5, + ) + + +def validate_unbound_slug(name: str) -> None: + parts = name.split("/", 1)[1].split("-") + for index, part in enumerate(parts): + ordinary = re.fullmatch(r"[a-z0-9]+", part) + ticket = ( + re.fullmatch(r"[A-Z]+", part) + and index + 1 < len(parts) + and parts[index + 1].isdigit() + ) + require( + ordinary or ticket, + f"slug segments must be lowercase (ticket ids like DAR-123 may be caps): {name}", + 5, + ) + + +def validate_name(name: str, token: str = "", *, task: bool = False) -> None: + if token: + validate_token(token, task=task) + shape = "-" if task else "" + require( + re.fullmatch(f"({TYPES})/.+", name) + and succeeds("check-ref-format", "--branch", name), + f"branch name must be /{shape}: {name}", + 5, + ) + if token: + validate_suffix(name, token) + else: + validate_unbound_slug(name) + require(len(name) <= 60, f"branch name exceeds 60 chars ({len(name)})", 5) + + +def correlated(name: str, token: str) -> bool: + try: + validate_name(name, token, task=True) + except RefusalError: + return False + return True + + +def discover(token: str) -> list[str]: + validate_token(token, task=True) + try: + rows = git( + "for-each-ref", + "--sort=refname", + "--format=%(refname:lstrip=2)%09%(objectname)", + "refs/heads", + ) + except RefusalError as error: + raise RefusalError("error: cannot enumerate local task branches", 3) from error + matches = [] + for row in rows.splitlines(): + name, tip = row.split("\t") + if correlated(name, token): + matches.append(f"{name} (at {tip})") + return matches diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/pr.py b/plugins/capability/darrow-git/backend/src/darrow_git/pr.py new file mode 100644 index 00000000..0ea7a66c --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/pr.py @@ -0,0 +1,166 @@ +"""One non-force branch publication followed by exact forge verification.""" + +import shutil +import sys +import tempfile +from pathlib import Path + +from . import pr_inspection, publication, templates +from .messages import validate_attribution, validate_subject +from .pr_options import PrOptions, parse +from .pr_repository import compare_ref, default_branch, validate_base +from .process import RefusalError, decode, git, invoke, probe, require, succeeds +from .repository import current_branch, in_progress, require_repository + + +def creation_context(options: PrOptions) -> tuple[str, str]: + require( + not in_progress(), + "merge/rebase/cherry-pick in progress — resolve it first; do not open a PR", + 8, + ) + require( + succeeds("rev-parse", "-q", "--verify", "HEAD"), + "repo has no commits yet — nothing to propose", + 3, + ) + branch = current_branch() + require(branch, "detached HEAD — a PR needs a branch (see create-branch)", 3) + require( + succeeds("remote", "get-url", "origin"), + "no 'origin' remote — cannot create a PR", + 3, + ) + require(shutil.which("gh"), "gh CLI not found — cannot create a PR", 3) + default, _ = default_branch() + require( + branch != default, + f"on the default branch ({branch}) — a PR needs a feature branch (see create-branch)", + 9, + ) + base = options.base or default + require(base != "(none)", "cannot determine the default branch — pass --base", 3) + require(base != branch, f"base equals the current branch: {branch}", 2) + if options.base: + validate_base(base, branch) + return branch, base + + +def validate_proposal(options: PrOptions, base: str) -> str: + validate_subject(options.title, "title") + body = "\n\n".join(options.bodies) + template = templates.select(options.template) + validate_attribution(options.title + "\n" + body, "PR titles or bodies") + templates.validate(template, body) + comparison = compare_ref(base) + result = invoke(["git", "rev-list", "--count", f"{comparison}..HEAD"]) + require( + result.returncode == 0, + f"cannot compare against {comparison} — fetch origin first", + 3, + ) + require( + decode(result.stdout) != "0", + f"no commits ahead of {comparison} — nothing to propose", + 3, + ) + return body + + +def refuse_duplicate(branch: str) -> None: + result = invoke(["gh", "pr", "list", "--head", branch, "--state", "open"]) + require( + result.returncode == 0, + "could not check for an existing open PR — fix gh before retrying:\n" + + "\n".join(decode(result.stderr).splitlines()[:50]), + 4, + ) + existing = decode(result.stdout) + require( + not existing, + f"an open PR for {branch} already exists — report it; do not create another:\n" + + "\n".join(existing.splitlines()[:50]), + 9, + ) + + +def create_remote_pr( + options: PrOptions, branch: str, base: str, body: str +) -> tuple[str, str]: + # Native temporary-file lifetime works on Windows too: close before gh reads. + with tempfile.TemporaryDirectory(prefix="darrow-pr-") as directory: + path = Path(directory) / "body.md" + path.write_text(body, encoding="utf-8") + args = [ + "gh", + "pr", + "create", + "--title", + options.title, + "--body-file", + str(path), + "--base", + base, + "--head", + branch, + ] + if options.draft: + args.append("--draft") + result = invoke(args) + output = decode(result.stdout + result.stderr) + if result.returncode: + print(output, file=sys.stderr) + return "uncertain", "" + last = next((line for line in reversed(output.splitlines()) if line.strip()), "") + return ("completed", last) if last.startswith("https://") else ("uncertain", "") + + +def create(options: PrOptions) -> None: + branch, base = creation_context(options) + body = validate_proposal(options, base) + refuse_duplicate(branch) + upstream = probe("rev-parse", "-q", "--verify", "--abbrev-ref", "@{u}") + expected = git("rev-parse", "HEAD") + git( + "-c", + "push.followTags=false", + "-c", + "remote.origin.mirror=false", + "push", + *([] if upstream else ["-u"]), + "origin", + f"refs/heads/{branch}:refs/heads/{branch}", + ) + effect, url = create_remote_pr(options, branch, base, body) + publication.verify( + publication.Publication( + expected=expected, + base=base, + draft=str(options.draft).lower(), + push="completed", + create=effect, + initial_url=url, + ) + ) + if upstream and upstream != f"origin/{branch}": + print( + f"note: upstream is {upstream}; pushed and opened the PR from origin/{branch}" + ) + + +def run(args: list[str]) -> None: + require_repository() + operation = args[0] if args else "" + if operation in {"verify", "publish-existing"}: + publication.verify( + publication.parse(args[1:]), publish=operation == "publish-existing" + ) + elif operation == "inspect": + pr_inspection.inspect(parse(args[1:], creating=False)) + elif operation == "create": + create(parse(args[1:], creating=True)) + else: + raise RefusalError( + "usage: pr.sh inspect [--base ] [--template ] | create --title -b ... [--template ] [--base ] [--draft] | publish-existing|verify --expected-head [--base ] [--draft]", + 64, + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/pr_inspection.py b/plugins/capability/darrow-git/backend/src/darrow_git/pr_inspection.py new file mode 100644 index 00000000..93974897 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/pr_inspection.py @@ -0,0 +1,123 @@ +"""Read-only PR readiness and template context.""" + +import shutil + +from . import templates +from .pr_options import PrOptions +from .pr_repository import compare_ref, default_branch, validate_base +from .process import decode, emit, git, invoke, probe, succeeds +from .repository import current_ref, in_progress + + +def stopped() -> bool: + if in_progress(): + print( + "## mode: conflict (merge/rebase/cherry-pick in progress — do not open a PR; inform the user)\n## unmerged files" + ) + emit(git("diff", "--name-only", "--diff-filter=U"), 50) + elif not succeeds("rev-parse", "-q", "--verify", "HEAD"): + print("## mode: empty (no commits yet — nothing to propose)") + elif not succeeds("remote", "get-url", "origin"): + print( + "## mode: no-remote (no 'origin' remote — cannot create a PR; inform the user)" + ) + else: + return False + return True + + +def wrong_branch(branch: str, default: str) -> bool: + if branch.startswith("(detached"): + print( + f"## mode: wrong-branch (detached HEAD — a PR needs a branch; suggest create-branch)\n## cur: {branch}" + ) + elif branch == default: + print( + f"## mode: wrong-branch (on the default branch — a PR needs a feature branch; suggest create-branch)\n## cur branch: {branch}" + ) + else: + return False + return True + + +def existing_pr(branch: str) -> tuple[str, str]: + if not shutil.which("gh"): + return "", "## note: gh CLI not found — create will fail until it is installed" + result = invoke(["gh", "pr", "list", "--head", branch, "--state", "open"]) + if result.returncode: + return ( + "", + "## note: could not check for an existing open PR (gh error) — create re-checks", + ) + return decode(result.stdout), "" + + +def inspect(options: PrOptions) -> None: + if stopped(): + return + branch = current_ref() + default, source = default_branch() + if wrong_branch(branch, default): + return + if options.base: + validate_base(options.base, branch) + base = options.base or default + base_source = "selected" if options.base else "default" + existing, note = existing_pr(branch) + comparison = compare_ref(base) if base != "(none)" else "" + ahead = probe("rev-list", "--count", f"{comparison}..HEAD") if comparison else "" + report_readiness( + options, branch, base, base_source, source, existing, note, comparison, ahead + ) + + +def report_readiness( + options: PrOptions, + branch: str, + base: str, + base_source: str, + source: str, + existing: str, + note: str, + comparison: str, + ahead: str, +) -> None: + if existing: + print( + f"## mode: exists (open PR for this branch — report it; do not create another)\n## intended commit: {git('rev-parse', 'HEAD')}" + ) + emit(existing, 50) + elif ahead == "0": + print( + f"## mode: no-commits (no commits ahead of {comparison} — nothing to propose; report and stop)\n## cur branch: {branch}\n## base branch ({base_source}): {base}" + ) + else: + print( + f"## mode: ready\n## intended commit: {git('rev-parse', 'HEAD')}\n## cur branch: {branch}\n## base branch ({base_source}): {base}" + ) + if source == "guess": + print( + "## note: default branch guessed from local branches (origin/HEAD unset, remote unreachable)" + ) + emit(note) + context(comparison, ahead, options.template) + + +def context(comparison: str, ahead: str, template: str) -> None: + upstream = probe("rev-parse", "-q", "--verify", "--abbrev-ref", "@{u}") + if upstream: + print( + f"## upstream: {upstream} (ahead {git('rev-list', '--count', '@{u}..HEAD')}, behind {git('rev-list', '--count', 'HEAD..@{u}')})" + ) + else: + print("## upstream: none (create will push with -u)") + if comparison and ahead: + print(f"## commits to include ({comparison}..HEAD)") + emit(probe("log", "--format=%h %s", f"{comparison}..HEAD"), 50) + print("## diffstat") + emit(probe("diff", "--stat", f"{comparison}...HEAD"), 50) + selected = templates.select(template, inspecting=True) + if selected: + templates.show(selected) + print("## working tree (uncommitted changes will NOT be in the PR)") + emit(git("status", "--porcelain") or "clean", 50) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/pr_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/pr_options.py new file mode 100644 index 00000000..4e42f402 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/pr_options.py @@ -0,0 +1,59 @@ +"""Public PR arguments shared by inspection and creation.""" + +from dataclasses import dataclass, field + +from .process import RefusalError, require +from .templates import validate_choice + + +@dataclass +class PrOptions: + title: str = "" + bodies: list[str] = field(default_factory=list) + base: str = "" + template: str = "" + draft: bool = False + + +def parse(args: list[str], *, creating: bool) -> PrOptions: + options = PrOptions() + index = 0 + while index < len(args): + index += consume(options, args, index, creating) + if creating: + require(options.title, "--title required", 2) + require(options.bodies, "at least one -b body section required", 2) + validate_choice(options.template) + return options + + +def consume(options: PrOptions, args: list[str], index: int, creating: bool) -> int: + flag = args[index] + if creating and flag == "--draft": + options.draft = True + return 1 + flags = {"--base": "base", "--template": "template"} + if creating: + flags.update({"--title": "title", "-b": "bodies"}) + if flag not in flags: + raise RefusalError(f"error: unknown argument: {flag}", 2) + value = read_value(args, index) + if flag == "-b": + options.bodies.append(value) + else: + setattr(options, flags[flag], value) + return 2 + + +def read_value(args: list[str], index: int) -> str: + flag = args[index] + description = {"--base": "a non-empty value", "--template": "a filename"}.get( + flag, "a value" + ) + require(index + 1 < len(args), f"{flag} needs {description}", 2) + value = args[index + 1] + if flag == "--base": + require(value, "--base needs a non-empty value", 2) + if flag == "-b": + require(value.strip(), "-b section is empty", 2) + return value diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/pr_repository.py b/plugins/capability/darrow-git/backend/src/darrow_git/pr_repository.py new file mode 100644 index 00000000..c0fe0e1d --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/pr_repository.py @@ -0,0 +1,42 @@ +"""PR base selection and committed comparison context.""" + +from .process import probe, require, succeeds + + +def default_branch() -> tuple[str, str]: + tracked = probe("symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD") + if tracked: + return tracked.removeprefix("origin/"), "tracked" + rows = probe("ls-remote", "--symref", "origin", "HEAD") + for line in rows.splitlines(): + if line.startswith("ref: "): + return line.split()[1].removeprefix("refs/heads/"), "remote" + return guessed_default(), "guess" + + +def guessed_default() -> str: + return next( + ( + name + for name in ("main", "master") + if succeeds("show-ref", "-q", "--verify", f"refs/heads/{name}") + ), + "(none)", + ) + + +def compare_ref(base: str) -> str: + return ( + f"origin/{base}" + if succeeds("rev-parse", "-q", "--verify", f"refs/remotes/origin/{base}") + else base + ) + + +def validate_base(base: str, branch: str) -> None: + require(base != branch, f"base equals the current branch: {branch}", 2) + require( + succeeds("rev-parse", "-q", "--verify", f"refs/remotes/origin/{base}"), + f"base not found on origin: {base} — fetch or push it first", + 2, + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/process.py b/plugins/capability/darrow-git/backend/src/darrow_git/process.py new file mode 100644 index 00000000..89f4e7f8 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/process.py @@ -0,0 +1,71 @@ +"""Literal provider execution and stable refusal boundaries.""" + +import os +import subprocess +from collections.abc import Sequence +from pathlib import Path + + +class RefusalError(Exception): + def __init__(self, message: str, code: int = 4) -> None: + super().__init__(message) + self.code = code + + +def require(condition: object, message: str, code: int = 4) -> None: + if not condition: + raise RefusalError(f"error: {message}", code) + + +def invoke( + args: Sequence[str], + *, + cwd: Path | None = None, + input_bytes: bytes | None = None, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[bytes]: + """Capture provider streams without allowing shell expansion or pipe deadlocks.""" + try: + return subprocess.run( + args, + input=input_bytes, + capture_output=True, + cwd=cwd, + env=env, + check=False, + ) + except OSError as error: + raise RefusalError(f"error: cannot execute {args[0]}: {error}") from error + + +def decode(value: bytes) -> str: + return value.decode("utf-8", errors="surrogateescape").rstrip("\n") + + +def command(*args: str, cwd: Path | None = None, code: int = 4) -> str: + result = invoke(args, cwd=cwd) + if result.returncode: + raise RefusalError(decode(result.stdout + result.stderr), code) + return decode(result.stdout) + + +def git(*args: str, cwd: Path | None = None) -> str: + return command("git", *args, cwd=cwd) + + +def probe(*args: str, cwd: Path | None = None) -> str: + result = invoke(["git", *args], cwd=cwd) + return decode(result.stdout) if result.returncode == 0 else "" + + +def succeeds(*args: str, cwd: Path | None = None) -> bool: + return invoke(["git", *args], cwd=cwd).returncode == 0 + + +def readable(path: Path) -> bool: + return path.is_file() and os.access(path, os.R_OK) + + +def emit(text: str, limit: int | None = None) -> None: + if text: + print("\n".join(text.splitlines()[:limit])) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/publication.py b/plugins/capability/darrow-git/backend/src/darrow_git/publication.py new file mode 100644 index 00000000..f13d2e02 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/publication.py @@ -0,0 +1,311 @@ +"""Verified publication bound to one full commit and one same-repository PR.""" + +import re +import shutil +import time +from dataclasses import dataclass + +from .process import RefusalError, command, emit, git, require +from .repository import current_branch, in_progress + +FULL_COMMIT = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") +REPOSITORY = re.compile(r"[^/\s]+/[^/\s]+\Z") +PR_FIELDS = ( + "number,url,state,headRefName,headRefOid,baseRefName,isDraft,isCrossRepository" +) +PR_QUERY = ".[] | [.number,.url,.state,.headRefName,.headRefOid,.baseRefName,.isDraft,.isCrossRepository] | @tsv" + + +@dataclass +class Publication: + expected: str = "" + base: str = "" + draft: str = "false" + push: str = "none" + create: str = "none" + initial_url: str = "" + url: str = "" + number: str = "" + origin: str = "" + branch: str = "" + repository: str = "" + remote_commit: str = "" + pr_commit: str = "" + + def fail(self, message: str) -> RefusalError: + lines = [] + if self.push == "completed": + lines.append("note: push completed; publication verification is incomplete") + if self.create == "completed": + lines.append("note: PR creation completed; do not create another PR") + elif self.create == "uncertain": + lines.append( + "note: PR creation command had an uncertain effect; do not create another PR without fresh observation" + ) + lines.extend( + [ + f"push: {self.push}", + f"pr-create: {self.create}", + f"initial-url: {self.initial_url or 'unknown'}", + f"observed-url: {self.url or 'unknown'}", + f"error: {message}", + ] + ) + return RefusalError("\n".join(lines), 4) + + +def parse(args: list[str]) -> Publication: + state = Publication() + index = 0 + while index < len(args): + flag = args[index] + if flag == "--draft": + state.draft = "true" + index += 1 + else: + index = parse_value(args, index, state) + return state + + +def parse_value(args: list[str], index: int, state: Publication) -> int: + flag = args[index] + if flag not in {"--expected-head", "--base"}: + raise state.fail(f"unknown argument: {flag}") + if index + 1 >= len(args) or not args[index + 1]: + raise state.fail(f"{flag} needs a value") + setattr(state, "expected" if flag == "--expected-head" else "base", args[index + 1]) + return index + 2 + + +def checked_command(message: str, *args: str) -> str: + try: + return command(*args) + except RefusalError as error: + raise RefusalError(message) from error + + +def preflight(state: Publication) -> None: + require( + FULL_COMMIT.fullmatch(state.expected), + "--expected-head requires the intended full commit ID", + ) + require(not in_progress(), "Git operation in progress") + state.branch = current_branch() + require(state.branch, "a feature branch is required") + require( + git("rev-parse", "HEAD") == state.expected, + "local HEAD differs from the intended commit", + ) + state.origin = checked_command( + "origin is required", "git", "remote", "get-url", "origin" + ) + require( + git("remote", "get-url", "--all", "origin") == state.origin + and git("remote", "get-url", "--push", "--all", "origin") == state.origin, + "origin must have one identical fetch and push endpoint", + ) + bind_base(state) + require(shutil.which("gh"), "gh is required") + state.repository = checked_command( + "cannot identify the origin repository", + "gh", + "repo", + "view", + state.origin, + "--json", + "nameWithOwner", + "--jq", + ".nameWithOwner", + ) + require( + REPOSITORY.fullmatch(state.repository), "malformed origin repository identity" + ) + + +def bind_base(state: Publication) -> None: + listing = checked_command( + "repository default branch is unverified", + "git", + "ls-remote", + "--symref", + "origin", + "HEAD", + ) + refs = [ + line.split()[1] + for line in listing.splitlines() + if line.startswith("ref: ") and line.endswith("\tHEAD") + ] + require( + len(refs) == 1 and refs[0].startswith("refs/heads/"), + "repository default branch is unverified", + ) + default = refs[0].removeprefix("refs/heads/") + require(state.branch != default, "the default branch cannot be published as a PR") + state.base = state.base or default + require(state.base != state.branch, "base equals the current branch") + + +def observe(state: Publication) -> None: + rows = checked_command( + "cannot observe the open PR", + "gh", + "pr", + "list", + "--repo", + state.origin, + "--head", + state.branch, + "--state", + "open", + "--limit", + "2", + "--json", + PR_FIELDS, + "--jq", + PR_QUERY, + ) + lines = [line for line in rows.splitlines() if line.strip()] + require(len(lines) == 1, "exactly one open PR is required") + fields = lines[0].split("\t") + require(len(fields) == 8, "malformed PR identity evidence") + state.number, state.url, status, head, state.pr_commit, base, draft, cross = fields + require( + state.number.isdigit() and state.url.startswith("https://"), + "malformed PR identity evidence", + ) + require( + status == "OPEN" and cross == "false" and head == state.branch, + "PR is not open on the expected same-repository branch", + ) + require( + base == state.base and draft == state.draft, + "PR base or draft state does not match the requested shape", + ) + observe_remote(state) + + +def observe_remote(state: Publication) -> None: + row = checked_command( + "cannot observe the remote branch", + "git", + "ls-remote", + "--exit-code", + "origin", + f"refs/heads/{state.branch}", + ) + fields = row.split("\t") + require( + len(fields) == 2 + and "\n" not in row + and fields[1] == f"refs/heads/{state.branch}", + "ambiguous remote branch evidence", + ) + state.remote_commit = fields[0] + require( + FULL_COMMIT.fullmatch(state.remote_commit), "malformed remote commit evidence" + ) + require(FULL_COMMIT.fullmatch(state.pr_commit), "malformed forge commit evidence") + + +def observe_converged(state: Publication) -> None: + observe(state) + pinned = (state.url, state.number) + for attempt in range(5): + if state.pr_commit == state.remote_commit: + return + require( + state.remote_commit == state.expected, + "forge and remote branch commits do not agree", + ) + require( + attempt < 4, + "forge and remote branch commits do not agree after bounded propagation observation", + ) + time.sleep(1) + check_local(state) + observe(state) + require( + (state.url, state.number) == pinned, + "PR identity changed during publication", + ) + require( + state.remote_commit == state.expected, + "remote branch moved during publication observation", + ) + + +def check_local(state: Publication) -> None: + require( + git("rev-parse", "HEAD") == state.expected and current_branch() == state.branch, + "local publication target changed", + ) + + +def publish_existing(state: Publication) -> None: + if state.remote_commit == state.expected: + return + check_local(state) + original = (state.url, state.number) + checked_command( + "non-force push failed; observe the remote before retrying", + "git", + "-c", + "push.followTags=false", + "-c", + "remote.origin.mirror=false", + "push", + "origin", + f"{state.expected}:refs/heads/{state.branch}", + ) + state.push = "completed" + observe_converged(state) + require( + (state.url, state.number) == original, "PR identity changed during publication" + ) + + +def verify(state: Publication, *, publish: bool = False) -> None: + try: + preflight(state) + observe_converged(state) + require( + state.create != "completed" + or not state.initial_url + or state.url == state.initial_url, + "created PR URL differs from the observed canonical PR", + ) + if publish: + publish_existing(state) + require( + state.remote_commit == state.expected and state.pr_commit == state.expected, + "the intended commit is not the published PR head", + ) + check_local(state) + except RefusalError as error: + raise state.fail(str(error).removeprefix("error: ")) from error + report(state) + + +def report(state: Publication) -> None: + if state.create == "uncertain": + state.create = "observed-after-uncertain-command" + print( + "\n".join( + [ + "publication: verified", + f"url: {state.url}", + f"repository: {state.repository}", + f"head: {state.branch}", + f"base: {state.base}", + f"draft: {state.draft}", + f"intended-commit: {state.expected}", + f"remote-commit: {state.remote_commit}", + f"pr-commit: {state.pr_commit}", + f"push: {state.push}", + f"pr-create: {state.create}", + ] + ) + ) + print("## working tree (excluded from publication)") + emit(git("status", "--porcelain", "--untracked-files=all")) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/py.typed b/plugins/capability/darrow-git/backend/src/darrow_git/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/remediation.py b/plugins/capability/darrow-git/backend/src/darrow_git/remediation.py new file mode 100644 index 00000000..f6561b05 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/remediation.py @@ -0,0 +1,72 @@ +"""Execute only a saved hook diagnostic against its exact failed snapshot.""" + +import os +import subprocess + +from .commit import failure_path, head, retry, validate_refresh +from .commit_options import CommitOptions +from .process import RefusalError, decode, git, invoke, readable, require, succeeds + + +def read_state() -> tuple[str, str, str]: + path = failure_path() + require(readable(path), "no readable hook-failure state for remediation", 7) + text = path.read_text(encoding="utf-8", errors="surrogateescape") + metadata, _, output = text.partition("output\n") + fields = dict(line.split("\t", 1) for line in metadata.splitlines() if "\t" in line) + previous_head, tree = fields.get("head", ""), fields.get("index_tree", "") + require(previous_head and tree, "hook-failure state is invalid", 7) + require( + previous_head == "unborn" + or succeeds("rev-parse", "--verify", f"{previous_head}^{{commit}}"), + "hook-failure state is invalid", + 7, + ) + require( + succeeds("rev-parse", "--verify", f"{tree}^{{tree}}"), + "hook-failure state is invalid", + 7, + ) + return previous_head, tree, output + + +def run_diagnostic(command: str) -> subprocess.CompletedProcess[bytes]: + # This is an explicitly authorized *shell command*, not interpolated provider + # input. Preserve its historical syntax on POSIX; use the native command + # interpreter on Windows. Never feed Git/GitHub arguments through this path. + if os.name == "nt": + # cmd parses its /c payload itself, not with the CRT argv convention. + # A list would backslash-escape embedded quotes and corrupt quoted paths. + interpreter = subprocess.list2cmdline([os.environ.get("COMSPEC", "cmd.exe")]) + return subprocess.run( + f'{interpreter} /d /s /c "{command}"', + capture_output=True, + check=False, + ) + return invoke(["sh", "-c", command]) + + +def remediate(options: CommitOptions) -> None: + previous_head, tree, output = read_state() + require( + head() == previous_head and git("write-tree") == tree, + "failed commit state changed; do not remediate", + 7, + ) + validate_refresh(options.files) + require( + options.command in output, + "remediation command is not present in the failed hook diagnostic", + 7, + ) + result = run_diagnostic(options.command) + require(head() == previous_head, "remediation changed HEAD; do not retry", 8) + if git("write-tree") != tree: + git("read-tree", tree) + raise RefusalError( + "error: remediation changed the index; restored failed commit staging and stopped", + 7, + ) + if result.returncode: + raise RefusalError(decode(result.stdout + result.stderr), 4) + retry(options) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/repository.py b/plugins/capability/darrow-git/backend/src/darrow_git/repository.py new file mode 100644 index 00000000..bf2ab046 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/repository.py @@ -0,0 +1,89 @@ +"""Repository identity, operation guards, and worktree observations.""" + +from dataclasses import dataclass +from pathlib import Path + +from .process import git, probe, require, succeeds + +OPERATIONS = ( + "MERGE_HEAD", + "CHERRY_PICK_HEAD", + "REVERT_HEAD", + "rebase-merge", + "rebase-apply", +) + + +def require_repository() -> None: + require( + probe("rev-parse", "--is-inside-work-tree") == "true", + "not inside a git work tree", + 3, + ) + + +def git_path(name: str, cwd: Path | None = None) -> Path: + path = Path(git("rev-parse", "--git-path", name, cwd=cwd)) + return path if path.is_absolute() else (cwd or Path.cwd()) / path + + +def in_progress(cwd: Path | None = None) -> bool: + return any(git_path(name, cwd).exists() for name in OPERATIONS) or bool( + git("ls-files", "-u", "--", ":/", cwd=cwd) + ) + + +def current_branch() -> str: + return probe("symbolic-ref", "-q", "HEAD").removeprefix("refs/heads/") + + +def current_ref() -> str: + return current_branch() or f"(detached @ {git('rev-parse', '--short', 'HEAD')})" + + +def local_default() -> str: + remote = probe("symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD") + if remote: + return remote.removeprefix("origin/") + return next( + ( + name + for name in ("main", "master") + if succeeds("show-ref", "-q", "--verify", f"refs/heads/{name}") + ), + "(none)", + ) + + +@dataclass(frozen=True) +class Worktree: + path: Path + branch: str + + +def worktrees() -> list[Worktree]: + listing = git("worktree", "list", "--porcelain") + return [parse_worktree(block) for block in listing.split("\n\n") if block] + + +def parse_worktree(block: str) -> Worktree: + fields = dict(line.partition(" ")[::2] for line in block.splitlines()) + branch = fields.get("branch", "").removeprefix("refs/heads/") + if "detached" in fields: + branch = "(detached)" + if "bare" in fields: + branch = "(bare)" + return Worktree(Path(fields["worktree"]), branch) + + +def common_dir(path: Path) -> Path: + value = Path(git("rev-parse", "--git-common-dir", cwd=path)) + return (path / value).resolve() + + +def validate_base(base: str) -> None: + require( + not base or succeeds("rev-parse", "--verify", "-q", f"{base}^{{commit}}"), + f"base not found: {base}", + 2, + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/templates.py b/plugins/capability/darrow-git/backend/src/darrow_git/templates.py new file mode 100644 index 00000000..39ee499c --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/templates.py @@ -0,0 +1,148 @@ +"""PR template selection and literal Markdown section validation.""" + +import re +from pathlib import Path + +from .process import emit, git, readable, require + + +def validate_choice(choice: str) -> None: + require( + not choice + or (not choice.startswith(".") and "/" not in choice and "\\" not in choice), + "--template must be one exact visible filename from .github/PULL_REQUEST_TEMPLATE/", + 2, + ) + + +def select(choice: str, *, inspecting: bool = False) -> Path | None: + top = Path(git("rev-parse", "--show-toplevel")) + single = next( + ( + top / directory / name + for directory in (".github", "", "docs") + for name in ("PULL_REQUEST_TEMPLATE.md", "pull_request_template.md") + if (top / directory / name).is_file() + ), + None, + ) + if single is not None: + require( + not choice, + f"--template is not valid when the repository has the single template {single}", + 7, + ) + return single + directory = top / ".github" / "PULL_REQUEST_TEMPLATE" + choices = ( + sorted( + path.name + for path in directory.iterdir() + if path.is_file() + and not path.is_symlink() + and not path.name.startswith(".") + ) + if directory.is_dir() + else [] + ) + return select_multiple(directory, choices, choice, inspecting) + + +def select_multiple( + directory: Path, choices: list[str], choice: str, inspecting: bool +) -> Path | None: + if choice: + require( + choice in choices, + f"selected pr template is not available: {directory / choice}", + 7, + ) + return directory / choice + if len(choices) == 1: + return directory / choices[0] + if len(choices) > 1: + require( + inspecting, + "multiple PR templates require --template ; run inspect and ask the user which one to follow", + 7, + ) + print( + "## note: multiple PR templates in .github/PULL_REQUEST_TEMPLATE/ — ask the user which one to follow" + ) + emit("\n".join(choices), 50) + return None + + +def show(path: Path) -> None: + relative = path.relative_to(git("rev-parse", "--show-toplevel")) + if not readable(path): + print( + f"## note: pr template {relative} exists but is not readable — fix its permissions; create will refuse" + ) + return + print( + f"## pr template ({relative}) — the body must follow it: keep headings verbatim, fill every section, follow comment instructions then delete the comments" + ) + text = path.read_text(encoding="utf-8") + emit(text, 100) + count = len(text.splitlines()) + if count > 100: + print( + f"## note: template truncated at 100 lines ({count} total) — read {path} for the rest" + ) + + +def markdown_lines(text: str) -> list[tuple[str, int, bool]]: + """Preserve the facade's ATX/fence semantics, including indented headings.""" + fenced = False + lines = [] + for line in text.removeprefix("\ufeff").splitlines(): + fence = bool(re.match(r"^ {0,3}(```|~~~)", line)) + if fence: + fenced = not fenced + heading = re.match(r"^ {0,3}(#{1,6})[ \t]", line) + level = len(heading[1]) if heading and not (fenced or fence) else 0 + lines.append((line.rstrip(), level, fenced or fence)) + return lines + + +def section_state(body: list[tuple[str, int, bool]], heading: str, level: int) -> str: + start = next( + ( + index + for index, (line, _, fenced) in enumerate(body) + if line == heading and not fenced + ), + None, + ) + if start is None: + return "missing from" + for line, next_level, _ in body[start + 1 :]: + if next_level and next_level <= level: + break + if line.strip(): + return "" + return "empty in" + + +def validate(path: Path | None, body: str) -> None: + if path is None: + return + require( + readable(path), f"pr template is not readable: {path} — fix its permissions", 3 + ) + relative = path.relative_to(git("rev-parse", "--show-toplevel")) + body_lines = markdown_lines(body) + for heading, level, _ in markdown_lines(path.read_text(encoding="utf-8")): + if level: + state = section_state(body_lines, heading, level) + require( + not state, + f"template section {state} the body: {heading} ({relative})", + 7, + ) + require( + not any(") — follow their instructions, then remove them", + 7, + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/worktree.py b/plugins/capability/darrow-git/backend/src/darrow_git/worktree.py new file mode 100644 index 00000000..5c942e36 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/worktree.py @@ -0,0 +1,227 @@ +"""Additive worktree allocation, exclusion preflight, and exact reuse.""" + +import os +from dataclasses import dataclass, field +from pathlib import Path + +from .branch_options import BranchOptions +from .process import RefusalError, git, invoke, probe, require +from .repository import ( + common_dir, + current_ref, + git_path, + in_progress, + validate_base, + worktrees, +) + + +@dataclass +class Allocation: + path: Path + exclude: Path | None = None + append: bool = False + created_parents: list[Path] = field(default_factory=list) + + +def destination(options: BranchOptions) -> Path: + if options.at is not None: + path = caller_directory() / options.at + else: + path = worktrees()[0].path / ".worktrees" / options.name + gitdir = Path(git("rev-parse", "--git-dir")).resolve() + require( + not path.resolve().is_relative_to(gitdir), + f"worktree path is inside the .git directory: {path}", + 2, + ) + require( + not (path.exists() or path.is_symlink()), + f"path already exists: {path} — will not reuse it", + 9, + ) + return path + + +def caller_directory() -> Path: + logical = Path(os.environ.get("PWD", str(Path.cwd()))) + try: + return ( + logical + if logical.is_absolute() and logical.samefile(Path.cwd()) + else Path.cwd() + ) + except OSError: + return Path.cwd() + + +def create_parents(allocation: Allocation) -> None: + parent = allocation.path.parent + while not parent.exists(): + allocation.created_parents.append(parent) + parent = parent.parent + try: + allocation.path.parent.mkdir(parents=True, exist_ok=True) + except OSError as error: + raise RefusalError( + f"error: cannot create worktree parent dir: {error}", 9 + ) from error + + +def cleanup_parents(allocation: Allocation) -> None: + for parent in allocation.created_parents: + try: + parent.rmdir() + except OSError: + # Never remove a nonempty, replaced, or otherwise inaccessible parent. + return + + +def inspect_exclude(allocation: Allocation) -> None: + exclude = git_path("info/exclude") + allocation.exclude = exclude + try: + if exclude.exists() or exclude.is_symlink(): + require( + os.access(exclude, os.R_OK), + f"repository exclude file is unreadable: {exclude}", + 9, + ) + allocation.append = b"/.worktrees/" not in exclude.read_bytes().splitlines() + else: + allocation.append = True + exclude.parent.mkdir(parents=True, exist_ok=True) + except OSError as error: + raise RefusalError( + f"error: cannot inspect repository exclude file: {exclude}", 9 + ) from error + writable = exclude if exclude.exists() else exclude.parent + require( + not allocation.append or os.access(writable, os.W_OK), + f"repository exclude file is not writable: {exclude}", + 9, + ) + + +def append_exclude(allocation: Allocation, task: bool) -> None: + if not allocation.append or allocation.exclude is None: + return + try: + with allocation.exclude.open("ab") as stream: + stream.write(b"/.worktrees/\n") + except OSError as error: + action = "prepared" if task else "created" + raise RefusalError( + f"error: worktree {action} but cannot update repository exclude file: {allocation.exclude}", + 9, + ) from error + + +def report_notes(options: BranchOptions, path: Path, dirty: str) -> None: + if dirty: + print( + f"## note: uncommitted changes stay in the current worktree — they were not carried into {path}" + ) + if ( + options.at is not None + and invoke(["git", "check-ignore", "-q", "--", str(path)]).returncode == 1 + ): + print( + f"## note: {path} is inside the repository and not ignored — git status will list it; consider adding it to .git/info/exclude" + ) + + +def valid_registered(path: Path, name: str) -> bool: + if not path.is_dir(): + return False + try: + return ( + probe("rev-parse", "--is-inside-work-tree", cwd=path) == "true" + and probe("symbolic-ref", "-q", "HEAD", cwd=path) == f"refs/heads/{name}" + and common_dir(path) == common_dir(Path.cwd()) + ) + except RefusalError: + return False + + +def reuse(options: BranchOptions) -> bool: + path = next( + (tree.path for tree in worktrees() if tree.branch == options.name), None + ) + if path is None: + return False + require( + valid_registered(path, options.name), + f"registered task worktree is missing, inaccessible, or inconsistent: {path}", + 9, + ) + require( + not in_progress(path), + f"merge/rebase/cherry-pick in progress in prepared worktree {path} — resolve it first", + 8, + ) + require( + path != Path(git("rev-parse", "--show-toplevel")), + f"branch is already active in the current checkout and cannot also be attached to a linked worktree: {options.name}", + 9, + ) + require( + options.at is None or caller_directory() / options.at == path, + f"branch is already checked out in another worktree: {path}", + 9, + ) + print("## mode: worktree-current") + print( + f"{options.name} (at {git('rev-parse', f'refs/heads/{options.name}')} ) in {path}".replace( + " )", ")" + ) + ) + return True + + +def prepare_allocation(options: BranchOptions, task: bool) -> Allocation: + allocation = Allocation(destination(options)) + if options.at is None: + try: + create_parents(allocation) + inspect_exclude(allocation) + except RefusalError: + if task: + cleanup_parents(allocation) + raise + return allocation + + +def add(options: BranchOptions, *, task: bool, existing: bool) -> None: + validate_base(options.base) + source = options.base or current_ref() + dirty = git("status", "--porcelain") + allocation = prepare_allocation(options, task) + args = ["worktree", "add", str(allocation.path)] + args += ( + [options.name] + if existing + else ["-b", options.name, *([options.base] if options.base else [])] + ) + try: + git(*args) + except RefusalError: + if task: + cleanup_parents(allocation) + raise + append_exclude(allocation, task) + report_added(options, allocation.path, task, existing, source) + report_notes(options, allocation.path, dirty) + + +def report_added( + options: BranchOptions, path: Path, task: bool, existing: bool, source: str +) -> None: + if task: + print(f"## mode: worktree-{'reused' if existing else 'created'}") + evidence = ( + f"at {git('rev-parse', f'refs/heads/{options.name}')}" + if existing + else f"from {source}" + ) + print(f"{options.name} ({evidence}) {'in' if task else 'at'} {path}") diff --git a/plugins/capability/darrow-git/backend/tests/conftest.py b/plugins/capability/darrow-git/backend/tests/conftest.py new file mode 100644 index 00000000..36e8be64 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/conftest.py @@ -0,0 +1,55 @@ +"""Native Git fixtures and a deterministic, in-process GitHub boundary.""" + +import importlib +import os +from pathlib import Path + +import pytest + +from darrow_git import process +from darrow_git.process import git +from forge import Forge + + +@pytest.fixture +def repository(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + path = tmp_path / "repository with spaces" + path.mkdir() + monkeypatch.chdir(path) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + git("init", "-q", "-b", "main") + git("config", "user.name", "Fixture") + git("config", "user.email", "fixture@example.test") + git("config", "commit.gpgsign", "false") + (path / "tracked.txt").write_text("base\n", encoding="utf-8") + git("add", "tracked.txt") + git("commit", "-q", "-m", "chore: initialize") + return path + + +@pytest.fixture +def remote(repository: Path, tmp_path: Path) -> Path: + path = tmp_path / "origin.git" + git("init", "-q", "--bare", str(path)) + git("--git-dir", str(path), "symbolic-ref", "HEAD", "refs/heads/main") + git("remote", "add", "origin", str(path)) + git("push", "-qu", "origin", "main") + git("remote", "set-head", "origin", "main") + git("switch", "-qc", "fix/181-python-git") + (repository / "tracked.txt").write_text("feature\n", encoding="utf-8") + git("commit", "-qam", "fix: change feature") + return path + + +@pytest.fixture +def forge(monkeypatch: pytest.MonkeyPatch) -> Forge: + fixture = Forge() + source = Path(process.__file__).parent + for path in source.glob("*.py"): + module = importlib.import_module(f"darrow_git.{path.stem}") + if hasattr(module, "invoke"): + monkeypatch.setattr(module, "invoke", fixture.invoke) + monkeypatch.setattr("shutil.which", lambda name: f"/fixture/{name}") + monkeypatch.setattr("darrow_git.publication.time.sleep", lambda seconds: None) + return fixture diff --git a/plugins/capability/darrow-git/backend/tests/forge.py b/plugins/capability/darrow-git/backend/tests/forge.py new file mode 100644 index 00000000..b5d55d6f --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/forge.py @@ -0,0 +1,102 @@ +"""Deterministic GitHub boundary shared by native and fresh-artifact fixtures.""" + +import subprocess +from collections.abc import Sequence +from pathlib import Path + +from darrow_git import process +from darrow_git.process import git + + +class Forge: + """Mock only gh; Git operations still use actual local repositories.""" + + def __init__(self) -> None: + self.original = process.invoke + self.calls: list[list[str]] = [] + self.created = False + self.base = "main" + self.draft = "false" + self.number = "42" + self.cross = "false" + self.comments = "" + self.body = "" + self.mode = "" + self.pr_head = "" + self.observations = 0 + + def invoke( + self, + args: Sequence[str], + *, + cwd: Path | None = None, + input_bytes: bytes | None = None, + env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[bytes]: + if args[0] != "gh": + return self.original(args, cwd=cwd, input_bytes=input_bytes, env=env) + values = list(args[1:]) + self.calls.append(values) + output, code = self.dispatch(values) + return subprocess.CompletedProcess( + args, code, output.encode(), b"fixture failure" if code else b"" + ) + + def dispatch(self, args: list[str]) -> tuple[str, int]: + routes = { + ("repo", "view"): self.repo_view, + ("pr", "list"): self.pr_list, + ("pr", "create"): self.pr_create, + ("pr", "comment"): self.comment, + } + if args[0] == "api": + return ("{}" if args[1] == "meta" else self.comments), 0 + return routes[(args[0], args[1])](args) + + def repo_view(self, args: list[str]) -> tuple[str, int]: + if "nameWithOwner,url" in args: + return "fixture/repo\thttps://github.com/fixture/repo", 0 + return "fixture/repo", 0 + + def pr_list(self, args: list[str]) -> tuple[str, int]: + self.observations += 1 + if self.mode == "unavailable": + return "", 1 + if not self.created: + return "", 0 + return self.pr_row(args), 0 + + def pr_row(self, args: list[str]) -> str: + branch = git("symbolic-ref", "--short", "HEAD") + head = self.pr_head or git("rev-parse", "HEAD") + if self.mode == "propagating" and self.observations < 3: + head = git("rev-parse", "main") + url = f"https://github.com/fixture/repo/pull/{self.number}" + if "--json" not in args: + return f"{self.number}\tExisting PR\t{branch}" + if "number,url,headRefOid,isCrossRepository" in args: + return f"{self.number}\t{url}\t{head}\t{self.cross}" + return f"{self.number}\t{url}\tOPEN\t{branch}\t{head}\t{self.base}\t{self.draft}\t{self.cross}" + + def pr_create(self, args: list[str]) -> tuple[str, int]: + self.created = True + self.base = args[args.index("--base") + 1] + self.draft = str("--draft" in args).lower() + self.body = Path(args[args.index("--body-file") + 1]).read_text() + return ( + "https://github.com/fixture/repo/pull/42", + 1 if self.mode == "uncertain" else 0, + ) + + def comment(self, args: list[str]) -> tuple[str, int]: + from darrow_git.evidence_identity import PLACEHOLDER, escape_tsv + + if "--help" in args: + return "--attach file (maximum 50)", 0 + self.body = Path(args[args.index("--body-file") + 1]).read_bytes().decode() + rendered = PLACEHOLDER.sub( + lambda match: "https://github.com/user-attachments/assets/" + match[0], + self.body, + ) + self.comments = f"9\thttps://github.com/fixture/repo/pull/42#issuecomment-9\t{escape_tsv(rendered)}" + return "", 0 diff --git a/plugins/capability/darrow-git/backend/tests/fresh_install.py b/plugins/capability/darrow-git/backend/tests/fresh_install.py new file mode 100644 index 00000000..49fb4bdb --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/fresh_install.py @@ -0,0 +1,124 @@ +"""Fresh copied-artifact validation on native Linux, macOS, and Windows.""" + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def command(cwd: Path, *args: str) -> str: + result = subprocess.run(args, cwd=cwd, text=True, capture_output=True, check=False) + if result.returncode: + raise RuntimeError( + f"{args[0]} exited {result.returncode}: {result.stdout}{result.stderr}" + ) + return result.stdout + + +def runtime(backend: Path, cwd: Path, *args: str) -> str: + return command( + cwd, + "uv", + "run", + "--quiet", + "--frozen", + "--no-dev", + "--project", + str(backend), + *args, + ) + + +def repository(path: Path, remote: Path) -> None: + path.mkdir() + command(path, "git", "init", "-qb", "main") + command(path, "git", "config", "user.name", "Fresh Fixture") + command(path, "git", "config", "user.email", "fixture@example.test") + command(path, "git", "config", "commit.gpgsign", "false") + command(path, "git", "commit", "--allow-empty", "-qm", "chore: initialize") + command(path, "git", "init", "--bare", "-q", str(remote)) + command( + path, "git", "--git-dir", str(remote), "symbolic-ref", "HEAD", "refs/heads/main" + ) + command(path, "git", "remote", "add", "origin", str(remote)) + command(path, "git", "push", "-qu", "origin", "main") + command(path, "git", "remote", "set-head", "origin", "main") + + +def validate(copy: Path, fixture: Path) -> None: + backend = copy / "backend" + command(fixture, "uv", "sync", "--frozen", "--no-dev", "--project", str(backend)) + tree = command( + fixture, "uv", "tree", "--frozen", "--no-dev", "--project", str(backend) + ) + assert not any( + tool in tree for tool in ("pytest", "ruff", "mypy", "hypothesis", "coverage") + ) + repo = fixture / "repository with spaces" + repository(repo, fixture / "remote.git") + assert "mode: ready" in runtime(backend, repo, "darrow-create-branch", "inspect") + assert "from main" in runtime( + backend, + repo, + "darrow-create-branch", + "create", + "fix/181-python-git", + "--ticket-token", + "181", + ) + assert "matches: 1" in runtime( + backend, repo, "darrow-prepare-task-branch", "discover", "--ticket-token", "181" + ) + assert "mode: current" in runtime( + backend, + repo, + "darrow-prepare-task-branch", + "prepare", + "fix/181-python-git", + "--ticket-token", + "181", + ) + (repo / "tracked.txt").write_text("fresh\n", encoding="utf-8") + assert "fix: fresh candidate" in runtime( + backend, + repo, + "darrow-create-commit", + "commit", + "-m", + "fix: fresh candidate", + "tracked.txt", + ) + output = runtime(backend, repo, "python", str(backend / "tests" / "fresh_probe.py")) + assert ( + "publication: verified" in output + and "outcome: published" in output + and "outcome: existing" in output + ) + assert not command(repo, "git", "status", "--porcelain").strip() + + +def main() -> None: + os.environ["GIT_CONFIG_GLOBAL"] = os.devnull + os.environ["GIT_CONFIG_NOSYSTEM"] = "1" + plugin = Path(__file__).resolve().parents[2] + ignored = shutil.ignore_patterns( + ".venv", + "__pycache__", + ".hypothesis", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".coverage*", + "coverage.json", + ) + with tempfile.TemporaryDirectory(prefix="darrow git fresh ") as temporary: + fixture = Path(temporary).resolve() + copy = fixture / "plugin copy" + shutil.copytree(plugin, copy, ignore=ignored) + validate(copy, fixture) + print("fresh copied Git plugin: all five workflows passed") + + +if __name__ == "__main__": + main() diff --git a/plugins/capability/darrow-git/backend/tests/fresh_probe.py b/plugins/capability/darrow-git/backend/tests/fresh_probe.py new file mode 100644 index 00000000..1f4d2e42 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/fresh_probe.py @@ -0,0 +1,61 @@ +"""Run installed console functions against Git with only gh mocked, without pytest.""" + +import importlib +import sys +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import patch + +from darrow_git import cli, process +from forge import Forge + + +def main() -> None: + fixture = Forge() + with ExitStack() as stack: + for path in Path(process.__file__).parent.glob("*.py"): + module = importlib.import_module(f"darrow_git.{path.stem}") + if hasattr(module, "invoke"): + stack.enter_context(patch.object(module, "invoke", fixture.invoke)) + stack.enter_context(patch("shutil.which", lambda name: f"fixture-{name}")) + invoke_publication(fixture) + + +def invoke_publication(fixture: Forge) -> None: + sys.argv = [ + "darrow-create-pr", + "create", + "--title", + "fix: fresh installation", + "-b", + "Verified copied plugin.", + ] + cli.create_pr() + assert fixture.created and fixture.body == "Verified copied plugin." + head = process.git("rev-parse", "HEAD") + sys.argv = ["darrow-create-pr", "verify", "--expected-head", head] + cli.create_pr() + publish_evidence(head) + assert sum(call[:2] == ["pr", "create"] for call in fixture.calls) == 1 + + +def publish_evidence(head: str) -> None: + body = Path.cwd().parent / "evidence.md" + body.write_text("Fresh installation passed.\n", encoding="utf-8") + sys.argv = [ + "darrow-publish-pr-evidence", + "publish", + "--expected-head", + head, + "--body-file", + str(body), + ] + for _ in range(2): + try: + cli.publish_evidence() + except SystemExit as result: + assert result.code == 0 + + +if __name__ == "__main__": + main() diff --git a/plugins/capability/darrow-git/backend/tests/test_argument_contracts.py b/plugins/capability/darrow-git/backend/tests/test_argument_contracts.py new file mode 100644 index 00000000..d0b179bc --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_argument_contracts.py @@ -0,0 +1,157 @@ +"""Public validation refuses malformed requests before mutation.""" + +from pathlib import Path + +import pytest + +from darrow_git import branch, commit, evidence_options, pr, publication +from darrow_git.process import RefusalError, git + + +@pytest.mark.parametrize( + "task,args,code", + [ + (False, [], 64), + (True, ["other"], 64), + (False, ["create"], 2), + (True, ["prepare", "fix/181-work"], 2), + (False, ["create", "--from"], 2), + (False, ["create", "--ticket-token"], 2), + (False, ["create", "--ticket-token", ""], 2), + (False, ["create", "--at"], 2), + (True, ["prepare", "--at", ""], 2), + (False, ["create", "--unknown"], 2), + (False, ["create", "feat/a", "feat/b"], 2), + (False, ["create", "feat/a", "--at", "path"], 2), + (False, ["create", "feat/a", "--worktree", "--at", ""], 2), + (False, ["create", "feat/a", "--from", "missing"], 2), + (False, ["create", "feature/wrong"], 5), + (False, ["create", "feat/Upper"], 5), + (False, ["create", "feat/" + "a" * 56], 5), + (False, ["create", "feat/181-work", "--ticket-token", "/181"], 5), + (False, ["create", "feat/work-181", "--ticket-token", "181"], 5), + (False, ["create", "feat/181-181", "--ticket-token", "181"], 5), + (False, ["create", "feat/181-UPPER", "--ticket-token", "181"], 5), + (True, ["discover"], 2), + (True, ["discover", "--ticket-token", ""], 2), + (True, ["discover", "--ticket-token", "token..bad"], 5), + (True, ["discover", "--ticket-token", "x" * 56], 5), + ], +) +def test_branch_invalid( + repository: Path, task: bool, args: list[str], code: int +) -> None: + before = git("show-ref") + with pytest.raises(RefusalError) as failure: + branch.run(args, task=task) + assert failure.value.code == code + assert git("show-ref") == before + + +@pytest.mark.parametrize( + "args,code", + [ + ([], 64), + (["diff"], 64), + (["diff", "missing"], 64), + (["commit"], 2), + (["commit", "-m"], 2), + (["commit", "-m", "invalid"], 5), + (["commit", "-m", "feat: period."], 5), + (["commit", "-m", "feat: " + "x" * 70], 5), + (["commit", "-m", "feat: example", "-m", "Generated by Claude"], 6), + (["commit", "-m", "feat: example", "."], 7), + (["commit", "-m", "feat: example", "*.py"], 7), + (["commit", "-m", "feat: example"], 3), + (["retry"], 2), + (["retry", "--wrong"], 2), + (["retry", "-m"], 2), + (["remediate", "--command"], 2), + (["remediate", "--unknown"], 2), + ( + [ + "remediate", + "--after-hook-failure", + "--refresh-staged", + "tracked.txt", + "-m", + "fix: example", + ], + 2, + ), + ( + [ + "retry", + "--after-hook-failure", + "--refresh-staged", + "tracked.txt", + "-m", + "fix: example", + ], + 7, + ), + ], +) +def test_commit_invalid(repository: Path, args: list[str], code: int) -> None: + before = git("write-tree"), git("rev-parse", "HEAD") + with pytest.raises(RefusalError) as failure: + commit.run(args) + assert failure.value.code == code + assert (git("write-tree"), git("rev-parse", "HEAD")) == before + + +@pytest.mark.parametrize( + "args,code", + [ + ([], 64), + (["create"], 2), + (["create", "--title", "feat: x"], 2), + (["create", "--title"], 2), + (["create", "-b"], 2), + (["create", "--base", ""], 2), + (["inspect", "--base"], 2), + (["create", "--template"], 2), + (["create", "--unknown"], 2), + (["create", "-b", " \n"], 2), + (["inspect", "--template", "../file"], 2), + (["inspect", "--template", ".hidden"], 2), + ], +) +def test_pr_invalid(repository: Path, args: list[str], code: int) -> None: + with pytest.raises(RefusalError) as failure: + pr.run(args) + assert failure.value.code == code + + +@pytest.mark.parametrize("args", [["--expected-head"], ["--base", ""], ["--force"]]) +def test_publication_parse_refusal(args: list[str]) -> None: + with pytest.raises(RefusalError, match="push: none") as failure: + publication.parse(args) + assert failure.value.code == 4 + + +@pytest.mark.parametrize( + "args", + [ + [], + ["--expected-head", "HEAD"], + ["--body-file"], + ["--unknown"], + ["--expected-head", "a" * 40], + ["--image", "a.png"], + ["--image", "a.png", "--explanation", "text"], + ["--video", "a.mp4", "--alt", "text"], + ["--image", "a.png", "--alt", " "], + ["--image", "a.png", "--alt", "bad\nline"], + [ + "--expected-head", + "a" * 40, + "--body-file", + "body", + *(["--image", "x.png", "--alt", "Image"] * 51), + ], + ], +) +def test_evidence_parse_refusal(args: list[str]) -> None: + with pytest.raises(RefusalError): + evidence_options.parse(args) diff --git a/plugins/capability/darrow-git/backend/tests/test_branch_contract.py b/plugins/capability/darrow-git/backend/tests/test_branch_contract.py new file mode 100644 index 00000000..5f691655 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_branch_contract.py @@ -0,0 +1,44 @@ +"""Exercise the installed public command against real Git repositories.""" + +import subprocess +from pathlib import Path + +import pytest + +from darrow_git.branch import run +from darrow_git.process import RefusalError + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], text=True).strip() + + +@pytest.fixture +def repository(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.chdir(tmp_path) + git("init", "-q", "-b", "main") + git("config", "user.name", "Fixture") + git("config", "user.email", "fixture@example.test") + git("commit", "-q", "--allow-empty", "-m", "chore: initialize") + return tmp_path + + +def test_create_preserves_work( + repository: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (repository / "outside.txt").write_text("local work", encoding="utf-8") + run(["create", "refactor/181-python-git", "--ticket-token", "181"]) + assert capsys.readouterr().out == "refactor/181-python-git (from main)\n" + assert git("symbolic-ref", "--short", "HEAD") == "refactor/181-python-git" + assert (repository / "outside.txt").read_text() == "local work" + assert git("stash", "list") == "" + + +def test_collision_preserves_tip(repository: Path) -> None: + git("branch", "feat/existing-work") + before = git("rev-parse", "refs/heads/feat/existing-work") + with pytest.raises(RefusalError) as error: + run(["create", "feat/existing-work"]) + assert error.value.code == 9 + assert git("rev-parse", "refs/heads/feat/existing-work") == before + assert git("symbolic-ref", "--short", "HEAD") == "main" diff --git a/plugins/capability/darrow-git/backend/tests/test_branch_safety.py b/plugins/capability/darrow-git/backend/tests/test_branch_safety.py new file mode 100644 index 00000000..da314496 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_branch_safety.py @@ -0,0 +1,133 @@ +"""Real worktree identity, conflict, and failure-state contracts.""" + +from pathlib import Path + +import pytest + +from darrow_git import branch, names, worktree +from darrow_git.branch_options import BranchOptions +from darrow_git.process import RefusalError, git +from darrow_git.repository import parse_worktree + + +def test_inspection_context( + repository: Path, capsys: pytest.CaptureFixture[str] +) -> None: + branch.run(["inspect"]) + assert "## current branch: main" in capsys.readouterr().out + branch.run(["inspect"], task=True) + assert "## local branches" in capsys.readouterr().out + (repository / ".git" / "MERGE_HEAD").touch() + branch.run(["inspect"]) + assert "mode: conflict" in capsys.readouterr().out + with pytest.raises(RefusalError) as error: + branch.run(["create", "feat/no-conflict"]) + assert error.value.code == 8 + (repository / ".git" / "MERGE_HEAD").unlink() + git("checkout", "--detach", "HEAD") + branch.run(["inspect"]) + assert "detached @" in capsys.readouterr().out + + +def test_existing_worktree_and_deliberate_base( + repository: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + git("branch", "fix/181-existing") + path = tmp_path / "other checkout" + args = [ + "prepare", + "fix/181-existing", + "--ticket-token", + "181", + "--worktree", + "--at", + str(path), + ] + branch.run(args, task=True) + assert "worktree-reused" in capsys.readouterr().out + branch.run(["inspect"]) + assert str(path) in capsys.readouterr().out + branch.run(args, task=True) + assert "worktree-current" in capsys.readouterr().out + with pytest.raises(RefusalError, match="another worktree"): + branch.run([*args[:-1], str(tmp_path / "different")], task=True) + branch.run(["create", "feat/from-main", "--from", "main"]) + assert "from main" in capsys.readouterr().out + + +def test_absent_exclude_and_case_collision(repository: Path) -> None: + exclude = repository / ".git" / "info" / "exclude" + exclude.unlink() + branch.run( + ["prepare", "fix/181-new", "--ticket-token", "181", "--worktree"], task=True + ) + assert exclude.read_text() == "/.worktrees/\n" + git("branch", "fix/DAR-2-work") + with pytest.raises(RefusalError, match="only by case"): + branch.run(["prepare", "fix/dar-2-work", "--ticket-token", "dar-2"], task=True) + + +def test_failed_task_worktree_cleans_only_empty_parents( + repository: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + original = git + + def fail_add(*args: str) -> str: + if args[:2] == ("worktree", "add"): + raise RefusalError("simulated add failure") + return original(*args) + + monkeypatch.setattr(worktree, "git", fail_add) + with pytest.raises(RefusalError, match="simulated"): + branch.run( + ["prepare", "fix/181-new", "--ticket-token", "181", "--worktree"], task=True + ) + assert not (repository / ".worktrees").exists() + assert git("symbolic-ref", "--short", "HEAD") == "main" + allocation = worktree.Allocation(repository / "file", created_parents=[repository]) + worktree.cleanup_parents(allocation) + assert repository.exists() + + +def test_filesystem_preflight_refuses( + repository: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (repository / ".worktrees").write_text("not a directory") + with pytest.raises(RefusalError, match="parent dir"): + worktree.prepare_allocation( + BranchOptions(name="fix/181-work", worktree=True), True + ) + (repository / ".worktrees").unlink() + monkeypatch.setattr("os.access", lambda path, mode: False) + with pytest.raises(RefusalError, match="unreadable"): + worktree.prepare_allocation( + BranchOptions(name="fix/181-work", worktree=True), True + ) + assert not (repository / ".worktrees").exists() + + +def test_discovery_failure_is_not_zero( + repository: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def failure(*args: str) -> str: + raise RefusalError("enumeration failed") + + monkeypatch.setattr(names, "git", failure) + with pytest.raises(RefusalError, match="cannot enumerate") as error: + names.discover("181") + assert error.value.code == 3 + + +def test_worktree_porcelain_preserves_special_modes() -> None: + assert parse_worktree("worktree /somewhere\ndetached").branch == "(detached)" + assert parse_worktree("worktree /somewhere\nbare").branch == "(bare)" + + +def test_byte_valued_exclude_preserved(repository: Path) -> None: + exclude = repository / ".git" / "info" / "exclude" + original = b"# Latin-1 comment: f\xfc r\nlocal-only\n" + exclude.write_bytes(original) + branch.run( + ["prepare", "fix/181-bytes", "--ticket-token", "181", "--worktree"], task=True + ) + assert exclude.read_bytes() == original + b"/.worktrees/\n" diff --git a/plugins/capability/darrow-git/backend/tests/test_cli_media.py b/plugins/capability/darrow-git/backend/tests/test_cli_media.py new file mode 100644 index 00000000..481a08c1 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_cli_media.py @@ -0,0 +1,83 @@ +"""Console exit contracts, provider failures, and bounded media recognition.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from darrow_git import cli, media +from darrow_git.process import RefusalError, command, invoke + + +@pytest.mark.parametrize( + "entrypoint,args", + [ + (cli.create_branch, ["inspect"]), + (cli.prepare_branch, ["inspect"]), + (cli.create_commit, ["inspect"]), + (cli.create_pr, ["inspect"]), + ], +) +def test_console_inspect( + repository: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + entrypoint: Callable[[], None], + args: list[str], +) -> None: + monkeypatch.setattr("sys.argv", ["darrow-command", *args]) + entrypoint() + assert "## mode:" in capsys.readouterr().out + + +def test_cli_refusal( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("sys.argv", ["darrow-publish-pr-evidence"]) + with pytest.raises(SystemExit) as failure: + cli.publish_evidence() + assert failure.value.code == 64 + assert "usage: publish.sh" in capsys.readouterr().err + + +def test_cli_filesystem_boundary(capsys: pytest.CaptureFixture[str]) -> None: + def inaccessible(args: list[str]) -> None: + raise PermissionError("cannot read requested file") + + with pytest.raises(SystemExit) as failure: + cli.execute(inaccessible) + assert failure.value.code == 4 + assert "cannot read requested file" in capsys.readouterr().err + + +def test_provider_failures(tmp_path: Path) -> None: + with pytest.raises(RefusalError, match="cannot execute"): + invoke([str(tmp_path / "missing-program")]) + with pytest.raises(RefusalError): + command("git", "not-a-real-command") + + +@pytest.mark.parametrize( + "extension,data", + [ + ( + "png", + bytes.fromhex("89504e470d0a1a0a0000000d494844520000000100000001") + + b"padding padding" + + bytes.fromhex("0000000049454e44ae426082"), + ), + ("jpg", b"\xff\xd8\xff\xe0padding\xff\xc0\xff\xd9"), + ("gif", b"GIF89a\x01\x00\x01\x00padding;"), + ("webp", b"RIFFxxxxWEBPVP8Xpadding"), + ("mp4", b"xxxxftypmp42padding-mdat"), + ("webm", bytes.fromhex("1a45dfa318538067")), + ("svg", b"Example"), + ], +) +def test_media_valid_and_truncated(tmp_path: Path, extension: str, data: bytes) -> None: + path = tmp_path / ("media." + extension) + path.write_bytes(data) + assert media.matches(path, extension, len(data)) + path.write_bytes(data[:3]) + assert not media.matches(path, extension, 3) + assert not media.matches(path, "txt", 3) diff --git a/plugins/capability/darrow-git/backend/tests/test_commit_recovery.py b/plugins/capability/darrow-git/backend/tests/test_commit_recovery.py new file mode 100644 index 00000000..de8e664f --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_commit_recovery.py @@ -0,0 +1,146 @@ +"""Hook failures preserve exact index snapshots and bound remediation.""" + +import os +import shlex +import subprocess +import sys +from collections.abc import Sequence +from pathlib import Path + +import pytest + +from darrow_git import commit, remediation +from darrow_git.process import RefusalError, git, invoke + + +def stage(repository: Path) -> tuple[str, str]: + (repository / "tracked.txt").write_text("stale") + git("add", "tracked.txt") + (repository / "outside.txt").write_text("outside") + return git("rev-parse", "HEAD"), git("write-tree") + + +def correction(tmp_path: Path, source: str) -> str: + script = tmp_path / "corrector with spaces.py" + script.write_text(source, encoding="utf-8") + args = [sys.executable, str(script)] + return subprocess.list2cmdline(args) if os.name == "nt" else shlex.join(args) + + +def remediate_args(command: str) -> list[str]: + return [ + "remediate", + "--after-hook-failure", + "--command", + command, + "--refresh-staged", + "tracked.txt", + "-m", + "fix: correct staged file", + ] + + +@pytest.mark.parametrize( + "diagnostic", [b"hook failed\n", b"hook failed: Datei f\xfc r\n"] +) +def test_failed_hook_restores_index( + repository: Path, monkeypatch: pytest.MonkeyPatch, diagnostic: bytes +) -> None: + before_head, before_tree = stage(repository) + + def failed( + args: Sequence[str], + *, + cwd: Path | None = None, + input_bytes: bytes | None = None, + env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[bytes]: + if list(args[:2]) == ["git", "commit"]: + git("add", "outside.txt") + return subprocess.CompletedProcess(args, 1, b"", diagnostic) + return invoke(args, cwd=cwd, input_bytes=input_bytes, env=env) + + monkeypatch.setattr(commit, "invoke", failed) + with pytest.raises(RefusalError, match="hook failed") as error: + commit.run(["commit", "-m", "fix: attempt change"]) + assert error.value.code == 4 + assert git("rev-parse", "HEAD") == before_head + assert git("write-tree") == before_tree + assert remediation.read_state() == ( + before_head, + before_tree, + diagnostic.decode("utf-8", errors="surrogateescape"), + ) + + +@pytest.mark.parametrize( + "effect,code,message", + [ + ("Path('tracked.txt').write_text('corrected')", 0, ""), + ( + "subprocess.run(['git', 'add', 'outside.txt'], check=True)", + 7, + "changed the index", + ), + ( + "subprocess.run(['git', 'commit', '-qm', 'fix: concurrent'], check=True)", + 8, + "changed HEAD", + ), + ("sys.stderr.write('correction failed'); sys.exit(1)", 4, "correction failed"), + ], +) +def test_remediation_effects( + repository: Path, tmp_path: Path, effect: str, code: int, message: str +) -> None: + before_head, before_tree = stage(repository) + command = correction( + tmp_path, "from pathlib import Path\nimport subprocess, sys\n" + effect + ) + commit.save_failure("run: " + command, before_head, before_tree) + if code: + with pytest.raises(RefusalError, match=message) as error: + commit.run(remediate_args(command)) + assert error.value.code == code + else: + commit.run(remediate_args(command)) + assert git("show", "HEAD:tracked.txt") == "corrected" + assert (repository / "outside.txt").read_text() == "outside" + if code == 7: + assert git("write-tree") == before_tree + assert git("rev-parse", "HEAD") == before_head + + +def test_remediation_refusals(repository: Path) -> None: + before_head, before_tree = stage(repository) + with pytest.raises(RefusalError, match="no readable"): + commit.run(remediate_args("unknown")) + commit.failure_path().write_text("broken") + with pytest.raises(RefusalError, match="invalid"): + remediation.read_state() + commit.save_failure("run: known", "deadbeef", before_tree) + with pytest.raises(RefusalError, match="invalid"): + remediation.read_state() + commit.save_failure("run: known", before_head, "deadbeef") + with pytest.raises(RefusalError, match="invalid"): + remediation.read_state() + commit.save_failure("run: known", before_head, before_tree) + with pytest.raises(RefusalError, match="not present"): + commit.run(remediate_args("unknown")) + git("add", "outside.txt") + with pytest.raises(RefusalError, match="state changed"): + commit.run(remediate_args("known")) + + +def test_unstaged_inspect_and_diff( + repository: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (repository / "tracked.txt").write_text("different") + (repository / "new.txt").write_text("new content") + commit.run(["inspect"]) + assert "mode: unstaged" in capsys.readouterr().out + commit.run(["diff", "tracked.txt", "new.txt"]) + assert "new content" in capsys.readouterr().out + commit.run(["commit", "-m", "feat: explicit paths", "new.txt"]) + assert git("show", "HEAD:new.txt") == "new content" + assert git("diff", "--name-only") == "tracked.txt" diff --git a/plugins/capability/darrow-git/backend/tests/test_evidence_safety.py b/plugins/capability/darrow-git/backend/tests/test_evidence_safety.py new file mode 100644 index 00000000..e766d3b4 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_evidence_safety.py @@ -0,0 +1,216 @@ +"""Evidence reconciliation and preflight failures never trigger retries.""" + +from pathlib import Path + +import pytest + +from darrow_git import evidence, evidence_files, evidence_preflight +from darrow_git.evidence_identity import Identity, classify +from darrow_git.process import RefusalError, git +from forge import Forge + + +def arguments(tmp_path: Path) -> list[str]: + body = tmp_path / "body.md" + body.write_text("Evidence.\n") + return [ + "publish", + "--expected-head", + git("rev-parse", "HEAD"), + "--body-file", + str(body), + ] + + +def test_preflight_and_reconciliation_failures( + remote: Path, + forge: Forge, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + args = arguments(tmp_path) + with pytest.raises(SystemExit) as failure: + evidence.run(args) + assert failure.value.code == 4 + assert "stage: pr-preflight" in capsys.readouterr().out + forge.created = True + + def unavailable(*args: object) -> str: + raise RefusalError("cannot reconcile top-level PR comments") + + monkeypatch.setattr(evidence_preflight, "comments", unavailable) + with pytest.raises(SystemExit) as failure: + evidence.run(args) + assert failure.value.code == 4 + assert "stage: reconciliation" in capsys.readouterr().out + assert not any( + call[:2] == ["pr", "comment"] and "--help" not in call for call in forge.calls + ) + + +@pytest.mark.parametrize( + "mode,outcome", + [ + ("extra", "partial"), + ("conflicting", "ambiguous"), + ("failed", "ambiguous"), + ("head", "partial"), + ("observation-failure", "partial"), + ], +) +def test_post_publication_uncertainty( + remote: Path, + forge: Forge, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + mode: str, + outcome: str, +) -> None: + forge.created = True + original = forge.comment + + def uncertain(args: list[str]) -> tuple[str, int]: + result = original(args) + if "--help" not in args: + change_remote_observation(forge, mode) + return result + + monkeypatch.setattr(forge, "comment", uncertain) + with pytest.raises(SystemExit) as failure: + evidence.run(arguments(tmp_path)) + assert failure.value.code == 5 + output = capsys.readouterr().out + assert f"outcome: {outcome}" in output + assert "one gh pr comment invocation attempted" in output + assert ( + sum( + call[:2] == ["pr", "comment"] and "--help" not in call + for call in forge.calls + ) + == 1 + ) + + +def change_remote_observation(forge: Forge, mode: str) -> None: + comments = { + "extra": forge.comments + " extra", + "conflicting": forge.comments + "\n" + forge.comments, + "failed": "", + } + if mode in comments: + forge.comments = comments[mode] + elif mode == "head": + forge.pr_head = "f" * 40 + else: + forge.mode = "unavailable" + + +@pytest.mark.parametrize("change", ["head", "body", "attachment"]) +def test_changed_snapshot_refuses_before_comment( + remote: Path, + forge: Forge, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + change: str, +) -> None: + forge.created = True + args = arguments(tmp_path) + image = tmp_path / "picture.svg" + image.write_text("image") + args += ["--image", str(image), "--alt", "Evidence image"] + state = evidence.prepare(args[1:]) + if change == "head": + git("commit", "--allow-empty", "-qm", "fix: concurrent change") + elif change == "body": + state.prepared.write_text("changed") + else: + state.files.attachments[0].snapshot.write_text("changed") + with pytest.raises(SystemExit) as failure: + evidence.recheck_files(state) + assert failure.value.code == 4 + assert "outcome: refused" in capsys.readouterr().out + assert not any( + call[:2] == ["pr", "comment"] and "--help" not in call for call in forge.calls + ) + + +def test_comment_classification() -> None: + identity = Identity( + "identity", "full-marker", "candidate", b"", "candidate full-marker exact" + ) + assert classify("", identity, 0).outcome == "none" + assert classify("candidate", identity, 0).outcome == "ambiguous" + assert ( + classify("1\turl\tcandidate different-marker", identity, 0).outcome + == "ambiguous" + ) + assert ( + classify("1\turl\tcandidate full-marker changed", identity, 0).outcome + == "partial" + ) + assert ( + classify("1\turl\tcandidate full-marker exact", identity, 0).outcome + == "complete" + ) + + +@pytest.mark.parametrize( + "help_text,expected", + [ + ("global maximum 1\n--attach file\n Maximum: 10\n\n--other", 10), + ("--attach file (maximum 50)\n --body Maximum 1", 50), + ("--attach file\n up to 25 files", 25), + ], +) +def test_attachment_limit_scope(help_text: str, expected: int) -> None: + assert evidence_preflight.advertised_limit(help_text) == expected + + +def test_missing_snapshot_file(tmp_path: Path) -> None: + with pytest.raises(RefusalError, match="unreadable"): + evidence_files.checked_file(tmp_path / "missing" / "body", "unreadable body") + + +def test_body_identity_preserves_legacy_git_filters( + repository: Path, tmp_path: Path +) -> None: + from darrow_git.evidence_identity import build + from darrow_git.evidence_options import EvidenceOptions + + git("config", "core.autocrlf", "true") + body = tmp_path / "windows-body.md" + body.write_bytes(b"First\r\nSecond\r\n") + files = evidence_files.snapshot(EvidenceOptions(body=str(body)), repository) + head = git("rev-parse", "HEAD") + identity = build("fixture/repo", "42", head, files) + legacy_body_hash = git("hash-object", str(files.directory / "body")) + assert legacy_body_hash != evidence_files.hash_bytes(files.body) + legacy_input = f"repository=fixture/repo\npr=42\nhead={head}\nbody={legacy_body_hash}\nattachment-count=0" + assert identity.value == evidence_files.hash_bytes(legacy_input.encode()) + + +def test_video_presentation_and_partial_existing( + remote: Path, forge: Forge, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + forge.created = True + video = tmp_path / "video.mp4" + video.write_bytes(b"xxxxftypmp42padding-mdat") + args = [ + *arguments(tmp_path), + "--video", + str(video), + "--explanation", + "Recorded acceptance", + ] + with pytest.raises(SystemExit) as success: + evidence.run(args) + assert success.value.code == 0 + assert "outcome: published" in capsys.readouterr().out + assert "Video 1 explanation: Recorded acceptance" in forge.body + forge.comments += " extra" + with pytest.raises(SystemExit) as partial: + evidence.run(args) + assert partial.value.code == 5 + assert "outcome: partial" in capsys.readouterr().out diff --git a/plugins/capability/darrow-git/backend/tests/test_native_workflows.py b/plugins/capability/darrow-git/backend/tests/test_native_workflows.py new file mode 100644 index 00000000..ca4e6a46 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_native_workflows.py @@ -0,0 +1,144 @@ +"""Observable native-platform acceptance at each public workflow seam.""" + +from pathlib import Path + +import pytest + +from darrow_git import branch, commit, evidence, pr +from darrow_git.process import RefusalError, git +from forge import Forge + + +def test_task_discovery_and_exact_reuse( + repository: Path, capsys: pytest.CaptureFixture[str] +) -> None: + branch.run(["prepare", "fix/181-first", "--ticket-token", "181"], task=True) + tip = git("rev-parse", "HEAD") + git("switch", "main") + branch.run(["discover", "--ticket-token", "181"], task=True) + assert f"fix/181-first (at {tip})" in capsys.readouterr().out + with pytest.raises(RefusalError, match="correlated local branches"): + branch.run(["prepare", "fix/181-second", "--ticket-token", "181"], task=True) + branch.run(["prepare", "fix/181-first", "--ticket-token", "181"], task=True) + assert git("rev-parse", "HEAD") == tip + branch.run(["prepare", "fix/181-first", "--ticket-token", "181"], task=True) + assert "## mode: current" in capsys.readouterr().out + + +@pytest.mark.parametrize("task", [False, True]) +def test_worktree_keeps_caller_work( + repository: Path, capsys: pytest.CaptureFixture[str], task: bool +) -> None: + (repository / "tracked.txt").write_text("dirty\n") + operation = "prepare" if task else "create" + branch.run( + [operation, "fix/181-tree", "--ticket-token", "181", "--worktree"], task=task + ) + tree = repository / ".worktrees" / "fix" / "181-tree" + assert tree.is_dir() + assert git("symbolic-ref", "--short", "HEAD") == "main" + assert (repository / "tracked.txt").read_text() == "dirty\n" + assert git("check-ignore", str(tree)) + if task: + branch.run( + [operation, "fix/181-tree", "--ticket-token", "181", "--worktree"], + task=task, + ) + assert "worktree-current" in capsys.readouterr().out + + +def test_commit_scope_and_retry( + repository: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (repository / "one.txt").write_text("staged") + (repository / "two.txt").write_text("outside") + git("add", "one.txt") + commit.run(["inspect"]) + assert "mode: staged" in capsys.readouterr().out + before = git("write-tree") + with pytest.raises(RefusalError, match="a staged set exists"): + commit.run(["commit", "-m", "feat: scope", "two.txt"]) + assert git("write-tree") == before + (repository / "one.txt").write_text("corrected") + commit.run( + [ + "retry", + "--after-hook-failure", + "--refresh-staged", + "one.txt", + "-m", + "feat: scope", + ] + ) + assert git("show", "HEAD:one.txt") == "corrected" + assert git("status", "--short", "two.txt") == "?? two.txt" + + +def test_pr_creation_and_verification( + remote: Path, forge: Forge, capsys: pytest.CaptureFixture[str] +) -> None: + pr.run(["inspect"]) + assert "mode: ready" in capsys.readouterr().out + pr.run(["create", "--title", "fix: publish scoped work", "-b", "Why\n\nWhat"]) + output = capsys.readouterr().out + expected = git("rev-parse", "HEAD") + assert "publication: verified" in output + assert f"pr-commit: {expected}" in output + assert forge.body == "Why\n\nWhat" + assert ( + git("--git-dir", str(remote), "rev-parse", "refs/heads/fix/181-python-git") + == expected + ) + pr.run(["verify", "--expected-head", expected]) + assert "push: none" in capsys.readouterr().out + with pytest.raises(RefusalError, match="already exists"): + pr.run(["create", "--title", "fix: duplicate", "-b", "Why"]) + assert sum(call[:2] == ["pr", "create"] for call in forge.calls) == 1 + + +@pytest.mark.parametrize("mode", ["propagating", "uncertain"]) +def test_creation_observes_uncertainty( + remote: Path, forge: Forge, capsys: pytest.CaptureFixture[str], mode: str +) -> None: + forge.mode = mode + pr.run(["create", "--title", "fix: reconcile publication", "-b", "Why"]) + assert "publication: verified" in capsys.readouterr().out + assert sum(call[:2] == ["pr", "create"] for call in forge.calls) == 1 + + +def test_evidence_identity_and_single_publication( + remote: Path, forge: Forge, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + forge.created = True + body = tmp_path / "body.md" + body.write_text("Verified behavior.\n") + image = tmp_path / "image.svg" + image.write_text('Pass') + args = [ + "publish", + "--expected-head", + git("rev-parse", "HEAD"), + "--body-file", + str(body), + "--image", + str(image), + "--alt", + "Verified result", + ] + with pytest.raises(SystemExit) as first: + evidence.run(args) + assert first.value.code == 0 + assert "outcome: published" in capsys.readouterr().out + with pytest.raises(SystemExit) as second: + evidence.run(args) + assert second.value.code == 0 + assert "outcome: existing" in capsys.readouterr().out + assert ( + sum( + call[:2] == ["pr", "comment"] and "--help" not in call + for call in forge.calls + ) + == 1 + ) + assert image.is_file() + assert git("status", "--porcelain") == "" diff --git a/plugins/capability/darrow-git/backend/tests/test_pr_inspection.py b/plugins/capability/darrow-git/backend/tests/test_pr_inspection.py new file mode 100644 index 00000000..bae36c0d --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_pr_inspection.py @@ -0,0 +1,70 @@ +"""Readiness retains its selected base, degraded context, and refusal modes.""" + +from pathlib import Path + +import pytest + +from darrow_git import pr, pr_repository +from darrow_git.process import git +from forge import Forge + + +def test_readiness_modes( + remote: Path, + repository: Path, + forge: Forge, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + forge.created = True + pr.run(["inspect"]) + assert "mode: exists" in capsys.readouterr().out + forge.created = False + forge.mode = "unavailable" + pr.run(["inspect"]) + assert "could not check for an existing" in capsys.readouterr().out + (repository / "PULL_REQUEST_TEMPLATE.md").write_text("## Why\n") + monkeypatch.setattr("shutil.which", lambda name: None) + pr.run(["inspect"]) + assert "gh CLI not found" in capsys.readouterr().out + git("switch", "-c", "feat/empty", "main") + pr.run(["inspect"]) + assert "mode: no-commits" in capsys.readouterr().out + git("switch", "main") + pr.run(["inspect"]) + assert "on the default branch" in capsys.readouterr().out + git("checkout", "--detach", "HEAD") + pr.run(["inspect"]) + assert "detached HEAD" in capsys.readouterr().out + + +def test_conflict_and_empty( + repository: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + (repository / ".git" / "MERGE_HEAD").touch() + pr.run(["inspect"]) + assert "mode: conflict" in capsys.readouterr().out + empty = tmp_path / "empty" + empty.mkdir() + monkeypatch.chdir(empty) + git("init", "-qb", "main") + pr.run(["inspect"]) + assert "mode: empty" in capsys.readouterr().out + + +def test_default_resolution( + remote: Path, forge: Forge, capsys: pytest.CaptureFixture[str] +) -> None: + git("symbolic-ref", "--delete", "refs/remotes/origin/HEAD") + assert pr_repository.default_branch() == ("main", "remote") + git("remote", "set-url", "origin", str(remote.parent / "missing.git")) + assert pr_repository.default_branch() == ("main", "guess") + pr.run(["inspect"]) + assert "default branch guessed" in capsys.readouterr().out + git("branch", "-d", "main") + assert pr_repository.default_branch() == ("(none)", "guess") + pr.run(["inspect"]) + assert "mode: ready" in capsys.readouterr().out diff --git a/plugins/capability/darrow-git/backend/tests/test_publication_safety.py b/plugins/capability/darrow-git/backend/tests/test_publication_safety.py new file mode 100644 index 00000000..96bc2b12 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_publication_safety.py @@ -0,0 +1,133 @@ +"""Exact publication identity and refusal behavior with local remotes.""" + +from pathlib import Path + +import pytest + +from darrow_git import pr, publication +from darrow_git.process import RefusalError, git +from forge import Forge + + +def test_existing_publication_pins_commit( + remote: Path, + repository: Path, + forge: Forge, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + git("push", "-qu", "origin", "HEAD") + old = git("rev-parse", "HEAD") + (repository / "tracked.txt").write_text("new") + git("commit", "-qam", "fix: finish") + expected = git("rev-parse", "HEAD") + forge.created = True + forge.pr_head = old + original = forge.pr_row + + def follow_remote(args: list[str]) -> str: + forge.pr_head = git("--git-dir", str(remote), "rev-parse", "fix/181-python-git") + return original(args) + + monkeypatch.setattr(forge, "pr_row", follow_remote) + pr.run(["publish-existing", "--expected-head", expected]) + assert "push: completed" in capsys.readouterr().out + assert git("--git-dir", str(remote), "rev-parse", "fix/181-python-git") == expected + pr.run(["publish-existing", "--expected-head", expected]) + assert "push: none" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "mode", ["stale", "wrong-base", "draft", "fork", "unavailable", "missing"] +) +def test_verification_fails_closed(remote: Path, forge: Forge, mode: str) -> None: + git("push", "-qu", "origin", "HEAD") + expected = git("rev-parse", "HEAD") + forge.created = mode != "missing" + if mode == "stale": + forge.pr_head = git("rev-parse", "main") + forge.base = "develop" if mode == "wrong-base" else "main" + forge.draft = str(mode == "draft").lower() + forge.cross = str(mode == "fork").lower() + forge.mode = mode + with pytest.raises(RefusalError, match="push: none"): + pr.run(["verify", "--expected-head", expected]) + assert not any(call[:2] == ["pr", "create"] for call in forge.calls) + assert git("--git-dir", str(remote), "rev-parse", "fix/181-python-git") == expected + if mode == "stale": + assert forge.observations == 5 + + +def test_draft_selected_base_and_upstream( + remote: Path, forge: Forge, capsys: pytest.CaptureFixture[str] +) -> None: + git("branch", "release", "main") + git("push", "origin", "release") + git("branch", "--set-upstream-to", "origin/main") + pr.run(["inspect", "--base", "release"]) + assert "base branch (selected): release" in capsys.readouterr().out + pr.run( + [ + "create", + "--title", + "fix: selected base", + "-b", + "Explanation", + "--base", + "release", + "--draft", + ] + ) + output = capsys.readouterr().out + assert "draft: true" in output and "upstream is origin/main" in output + pr.run( + [ + "verify", + "--expected-head", + git("rev-parse", "HEAD"), + "--base", + "release", + "--draft", + ] + ) + assert "publication: verified" in capsys.readouterr().out + + +@pytest.mark.parametrize("effect", ["completed", "uncertain"]) +def test_incomplete_creation_reports_effects( + remote: Path, forge: Forge, effect: str +) -> None: + git("push", "-qu", "origin", "HEAD") + forge.created = True + state = publication.Publication( + expected=git("rev-parse", "HEAD"), + push="completed", + create=effect, + initial_url="https://github.com/fixture/repo/pull/99", + ) + forge.pr_head = git("rev-parse", "main") + with pytest.raises(RefusalError) as error: + publication.verify(state) + assert "push completed" in str(error.value) + assert "do not create another PR" in str(error.value) + + +def test_identity_change_during_propagation( + remote: Path, forge: Forge, monkeypatch: pytest.MonkeyPatch +) -> None: + git("push", "-qu", "origin", "HEAD") + forge.created = True + original = forge.pr_row + + def changed(args: list[str]) -> str: + if forge.observations == 1: + forge.pr_head = git("rev-parse", "main") + else: + forge.pr_head = "" + forge.number = "43" + return original(args) + + monkeypatch.setattr(forge, "pr_row", changed) + with pytest.raises(RefusalError, match="identity changed"): + pr.run(["verify", "--expected-head", git("rev-parse", "HEAD")]) + assert forge.observations == 2 diff --git a/plugins/capability/darrow-git/backend/tests/test_templates_properties.py b/plugins/capability/darrow-git/backend/tests/test_templates_properties.py new file mode 100644 index 00000000..844a3453 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_templates_properties.py @@ -0,0 +1,92 @@ +"""Markdown template semantics and meaningful generated validation invariants.""" + +from pathlib import Path + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from darrow_git import templates +from darrow_git.evidence_identity import escape_tsv +from darrow_git.messages import validate_attribution, validate_subject +from darrow_git.process import RefusalError + + +@settings(max_examples=50, derandomize=True) +@given( + st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789 ", min_size=1, max_size=60 + ).filter(lambda value: bool(value.strip())) +) +def test_generated_subjects_keep_length_and_attribution_guards(text: str) -> None: + subject = "fix: " + text.strip() + validate_subject(subject, "subject") + with pytest.raises(RefusalError): + validate_subject(subject + ".", "subject") + with pytest.raises(RefusalError): + validate_attribution(subject + "\nGenerated by Claude", "commit messages") + + +@settings(max_examples=50, derandomize=True) +@given(st.text(alphabet="abc\\\t\n\r😀", max_size=100)) +def test_tsv_never_contains_raw_record_delimiters(text: str) -> None: + escaped = escape_tsv(text) + assert not any(char in escaped for char in "\t\n\r") + # Decode the exact four escape forms used by gh @tsv, without interpreting + # arbitrary Python/JSON escapes or corrupting Unicode. + restored = [] + index = 0 + mapping = {"t": "\t", "r": "\r", "n": "\n", "\\": "\\"} + while index < len(escaped): + if escaped[index] == "\\": + restored.append(mapping[escaped[index + 1]]) + index += 2 + else: + restored.append(escaped[index]) + index += 1 + assert "".join(restored) == text + + +@pytest.mark.parametrize( + "body,message", + [ + ("## Different\ncontent", "missing"), + ("## Why\n\n## What\ntext", "empty"), + ("## Why\ncontent\n## What\n", "comments"), + ], +) +def test_template_invalid(repository: Path, body: str, message: str) -> None: + path = repository / "PULL_REQUEST_TEMPLATE.md" + path.write_text("## Why\n## What\n") + with pytest.raises(RefusalError, match=message): + templates.validate(path, body) + + +def test_template_fences_and_selection( + repository: Path, capsys: pytest.CaptureFixture[str] +) -> None: + directory = repository / ".github" / "PULL_REQUEST_TEMPLATE" + directory.mkdir(parents=True) + first = directory / "one.md" + first.write_text("\ufeff## Why\n```\n## Not a heading\n```\n## What\n") + assert templates.select("") == first + templates.validate( + first, "## Why\n### Child\ncontent\n## What\n```\n\n```\n" + ) + (directory / "two.md").write_text("# Second") + with pytest.raises(RefusalError, match="multiple PR templates"): + templates.select("") + assert templates.select("one.md") == first + with pytest.raises(RefusalError, match="not available"): + templates.select("missing.md") + assert templates.select("", inspecting=True) is None + assert "multiple PR templates" in capsys.readouterr().out + templates.show(first) + assert "pr template" in capsys.readouterr().out + root = repository / "PULL_REQUEST_TEMPLATE.md" + root.write_text("## Root\n" + "text\n" * 101) + assert templates.select("") == root + with pytest.raises(RefusalError, match="single template"): + templates.select("one.md") + templates.show(root) + assert "truncated at 100 lines" in capsys.readouterr().out diff --git a/plugins/capability/darrow-git/backend/uv.lock b/plugins/capability/darrow-git/backend/uv.lock new file mode 100644 index 00000000..7edb2a40 --- /dev/null +++ b/plugins/capability/darrow-git/backend/uv.lock @@ -0,0 +1,462 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.14" + +[[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 = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/62/a8b4dd53308a4b1571e6bdd77ee562c9b1c1742db9886c0aef2f69463067/coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853", size = 223178, upload-time = "2026-09-13T19:08:21.805Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/b9325b8d486a5c05fd4a9eb1cd9c9f697c361ddeda0e73567d5c2d8959f6/coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097", size = 223701, upload-time = "2026-09-13T19:08:23.391Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ee/3ab3ac6e9e7165a623695b73a83d1bfbdf5069681a68a012cb5abb096060/coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a", size = 250434, upload-time = "2026-09-13T19:08:24.948Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/31ffc231e9879876f611cf1ac7622cf092d76762c07ca64dce47b29a200b/coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881", size = 252264, upload-time = "2026-09-13T19:08:26.734Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f4/4fb4f6b16de07dbae3d3a111ae28d32ad657ce6ff4d364c49c10962daad5/coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43", size = 254127, upload-time = "2026-09-13T19:08:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/048e88eb610f44b7542dac10fa45bb2ffad5fbfcdab0f03289a843da934b/coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b", size = 256043, upload-time = "2026-09-13T19:08:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/97/59/d27b113c80b04c7fc64c69457132648e1d287b83a1de41189a504b50b52b/coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec", size = 251092, upload-time = "2026-09-13T19:08:31.57Z" }, + { url = "https://files.pythonhosted.org/packages/45/42/b7eafaceff08dc1dcbfc924fa9233031fd00c8b8287cc4a1a0dcdedcb0f9/coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9", size = 252170, upload-time = "2026-09-13T19:08:33.191Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8f/57dcc5360aa6d8d24370c12fe855f6ebf5fbf2718c154fb9fdec7161de07/coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38", size = 250173, upload-time = "2026-09-13T19:08:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/379a8c7ac0f3063769dc43fd2475e9fc9b2722cb88eabe71f4d862660c93/coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a", size = 253986, upload-time = "2026-09-13T19:08:36.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/25/70218d2a6077730843b75ee2525fa64a750283d71cc4c063dc0b75ba6979/coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594", size = 250444, upload-time = "2026-09-13T19:08:38.434Z" }, + { url = "https://files.pythonhosted.org/packages/58/ac/6c41c441baa6c8a1fed5615e4e1e7d0ca649b33b472ee28591a92d70a1ff/coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773", size = 251046, upload-time = "2026-09-13T19:08:40.133Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/01166d6f8aa42acd148b4c2c88173d60eefdd0270ea2bcbba79be35bc193/coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc", size = 225242, upload-time = "2026-09-13T19:08:41.672Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/a76060478919becf0fbea2df2053a6e1a7195f11edf8dfd51c58c1636b43/coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90", size = 225868, upload-time = "2026-09-13T19:08:43.22Z" }, + { url = "https://files.pythonhosted.org/packages/af/3a/d09495dfd5191b5593852bbe2f037afae768157443378d066d9ecea69a49/coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", size = 223307, upload-time = "2026-09-13T19:08:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/310196a80258e28ebf6e5aa814a5a6ffbb18c03cf3c0c0c04191ed893e88/coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", size = 223817, upload-time = "2026-09-13T19:08:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/73/29/ed0fda1fcd440cdfc07e72a07f3c9c3b43f65a6b3d53bff2cba9b9de50e8/coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", size = 254222, upload-time = "2026-09-13T19:08:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4e/5e2507fbd71ec1047620978f39568b0d2ed6c4468d08495a0c24f5677f65/coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", size = 256134, upload-time = "2026-09-13T19:08:49.926Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/a9f97846554bc240473656f9c3874591105f29c08eccf4282daadc6bc281/coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", size = 258240, upload-time = "2026-09-13T19:08:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/2b1ab208d06719394851cdd7dc322157870a93b178410b1bda6cac7755f9/coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", size = 260202, upload-time = "2026-09-13T19:08:53.204Z" }, + { url = "https://files.pythonhosted.org/packages/af/14/ff4db31d6e3126d5b72aab1e868d35ec94e1b71dee46f5c3b54cb1b2438e/coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", size = 254305, upload-time = "2026-09-13T19:08:54.833Z" }, + { url = "https://files.pythonhosted.org/packages/ce/27/c28faa4818f61c49ca3bfb4a77e1c191e79a40447504b89af0bd859cd6fc/coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", size = 255935, upload-time = "2026-09-13T19:08:56.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/5a/42e64e5b716048cae33e2e15f8c6fe04a927467056c533b8e88900da895e/coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", size = 253995, upload-time = "2026-09-13T19:08:58.159Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/860287acd5a1e29acd337f302b476b54148203590091b1a1555b2e914b50/coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", size = 257767, upload-time = "2026-09-13T19:08:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c5/4b68006da567b4b413352f891776c35776b3557a0479a28187ab57c21659/coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", size = 253715, upload-time = "2026-09-13T19:09:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0e/b2d0c47cfbf11770e197f1d0f11a92864eb29fd7eaca45bc9915573d9725/coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", size = 254624, upload-time = "2026-09-13T19:09:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/3bab8821b2b942155578fc34e88e21b0670d922f039ab23651f6035ba7f7/coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", size = 225404, upload-time = "2026-09-13T19:09:05.174Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ce/4f4ce667a97d1c538b46a79b89161395ad2beffc248b6c782afc9f521927/coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", size = 225879, upload-time = "2026-09-13T19:09:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/f3dce5e47351ab34522dd037fd64b111742958e24861bdd2820971bc1a26/coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", size = 225428, upload-time = "2026-09-13T19:09:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, + { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, + { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, + { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "darrow-git" +version = "0.6.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "coverage", extra = ["toml"] }, + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.10,<8" }, + { name = "hypothesis", specifier = ">=6.138,<7" }, + { name = "mypy", specifier = ">=1.18,<2" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.168.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/ce/c0946bebffb99b62426a6a7643d4272cc6c5cf777a488b3b4d0ee724e960/hypothesis-6.168.0.tar.gz", hash = "sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e", size = 510805, upload-time = "2026-09-08T18:48:36.072Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/f8/8b2cc9ae7b439538f6f2d32a92892340b6343a6b04d171c516666901dedc/hypothesis-6.168.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2", size = 791358, upload-time = "2026-09-08T18:47:37.076Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/4d7d897310cde5085779fb96feadb8529d98cb8e51ed7b24f7da9b6c6bdc/hypothesis-6.168.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099", size = 787081, upload-time = "2026-09-08T18:47:16.227Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/9d52066d363faba7f3ac20ee60a3c696a475feb2c477d235d0d649d41cc1/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4", size = 1123850, upload-time = "2026-09-08T18:48:11.504Z" }, + { url = "https://files.pythonhosted.org/packages/50/cf/aa46d76fa7df43caf2c372e394fda84ce1dc08421814f8674a6b9295e2ea/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f", size = 1147685, upload-time = "2026-09-08T18:46:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/78ed8c8d5aa37e4baae2a4b3e29687ee3d9b7f1a5e56ea5ea5ec7ec71ecb/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1", size = 1149294, upload-time = "2026-09-08T18:47:04.757Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/d57440f19e9de70e85359cf179ce786f309ee17609bd3c5a0113272875c0/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213", size = 1169729, upload-time = "2026-09-08T18:47:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/a1/86/dc74410a186990bb22c2a3eea0e77804f2eb0f300860b46d7d8948073674/hypothesis-6.168.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131", size = 1129182, upload-time = "2026-09-08T18:46:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/be048fc4f6dac831625e155bdf11e4233caf46bb54030391d6fba8e19449/hypothesis-6.168.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149", size = 1160180, upload-time = "2026-09-08T18:47:26.459Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4a/15a34498a5f08720fbbdbbe4668a8050fe4e17c16c9eeb6f56a017f2fa6b/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d", size = 1299711, upload-time = "2026-09-08T18:47:28.34Z" }, + { url = "https://files.pythonhosted.org/packages/77/09/5354e0dae302ab98c4f0046b7e8699c2186ae4349397bda5d5c852bda68b/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517", size = 1425341, upload-time = "2026-09-08T18:46:56.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/7c/3c0e1f59043ff128c70a51d298d3d6b5973525c357a1e1d0542dbc05ac90/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349", size = 1281063, upload-time = "2026-09-08T18:46:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/db884db9a7d42ae6b72a00c13c725940b638dfb263e618b22af59d5dfa2f/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b", size = 1300247, upload-time = "2026-09-08T18:47:02.949Z" }, + { url = "https://files.pythonhosted.org/packages/99/8a/4ee9769e1d48676efb6a78a130f82e0d52d3f87b2055294102272a08615c/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377", size = 1336084, upload-time = "2026-09-08T18:48:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/61/17/d4ed11bc99d205d6d2651a0f1a4874f377150f836b5a0849bd511d68a2eb/hypothesis-6.168.0-cp310-abi3-win32.whl", hash = "sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29", size = 677989, upload-time = "2026-09-08T18:47:24.722Z" }, + { url = "https://files.pythonhosted.org/packages/77/51/abf1fde7b8afab87db30afb73b3847e62440551d146472111cabeba2fe00/hypothesis-6.168.0-cp310-abi3-win_amd64.whl", hash = "sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b", size = 684692, upload-time = "2026-09-08T18:46:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/12/e2/64d79aed47a95186ce9edcef60eb4684870c72542da3b7027c2483d8ee8c/hypothesis-6.168.0-cp310-abi3-win_arm64.whl", hash = "sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc", size = 682709, upload-time = "2026-09-08T18:48:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/bfacadcc6cd4bd179d1cd2f6c85f580136019b38af7db71e3159ebb3ce33/hypothesis-6.168.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5", size = 792193, upload-time = "2026-09-08T18:47:42.392Z" }, + { url = "https://files.pythonhosted.org/packages/fc/29/4abab4114035f54438e3b07bdab3f68fd8769fe0fb84f8ffd6656eaa58fd/hypothesis-6.168.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc", size = 788121, upload-time = "2026-09-08T18:47:09.398Z" }, + { url = "https://files.pythonhosted.org/packages/85/d3/c54242439dcb59c7e3274a2952188bebbc605be397c3a6ff027fc81e1726/hypothesis-6.168.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3", size = 1124222, upload-time = "2026-09-08T18:46:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8b/1c9f54be7040fe7d94e4cd5d9343da5a36240bee2fa856f44109f9622251/hypothesis-6.168.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67", size = 1170346, upload-time = "2026-09-08T18:46:52.784Z" }, + { url = "https://files.pythonhosted.org/packages/af/d4/f6b1557bd3cf2976d56465fcbc483214bc6e7f1aa7e4db9f8c5f776d547a/hypothesis-6.168.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0", size = 1300267, upload-time = "2026-09-08T18:46:45.644Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ad/a345588acc038deb09a43610328004d31fe5a53289627e03b0199978dc45/hypothesis-6.168.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f", size = 1336724, upload-time = "2026-09-08T18:46:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/97/e9/c5e380fd29fd552ff442bcfb1090926287e2911d40212ae0602a0736347b/hypothesis-6.168.0-cp310-cp310-win_amd64.whl", hash = "sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236", size = 684644, upload-time = "2026-09-08T18:47:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7a/7a28d9afd52c9a89c4d8e3170c92fe1ffbd4a4ce3f905ca15fa7ddaaa581/hypothesis-6.168.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a", size = 792045, upload-time = "2026-09-08T18:46:29.612Z" }, + { url = "https://files.pythonhosted.org/packages/0a/40/e2d6fe12b54abbc8b802b234ec4974d0a420dde4098f57a324e9c791ac27/hypothesis-6.168.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9", size = 787932, upload-time = "2026-09-08T18:47:11.145Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/cc78e05f3248949c6d9ecb489d0e422247d11b08533ab02e177a10a01610/hypothesis-6.168.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843", size = 1123994, upload-time = "2026-09-08T18:47:12.647Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e3/7d483a79e2ed9a6c868cac8783b3c6c2bb82303f7db364c9da9a406df10d/hypothesis-6.168.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063", size = 1170271, upload-time = "2026-09-08T18:47:54.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/bffaf5b5e6b4566aa40d8a4ae25d8d84b47dfe0d9e771f454e4974cdb428/hypothesis-6.168.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04", size = 1300162, upload-time = "2026-09-08T18:46:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/25/06/6c30a00fc2f5c58546e858259cf2261c6a3ef6a060c373e00d00311267be/hypothesis-6.168.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f", size = 1336294, upload-time = "2026-09-08T18:46:37.324Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/501910e5a6a9c245f2394fda016376f6c7e209c5dca8c056eaece64ad10e/hypothesis-6.168.0-cp311-cp311-win_amd64.whl", hash = "sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2", size = 684464, upload-time = "2026-09-08T18:47:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/0035896c101f0484c364353f8ee30175eef8936b49171618677287fdd85d/hypothesis-6.168.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda", size = 793115, upload-time = "2026-09-08T18:48:21.352Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/938173e27df771cc6e92bc47f117a1b1be4a88fc6dc214f7fc65f9c7ad93/hypothesis-6.168.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79", size = 784634, upload-time = "2026-09-08T18:47:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/0b2c6ac07d519131f97712f2533750ffe9b2490eec8f518ef3a5ed2dd514/hypothesis-6.168.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7", size = 1122855, upload-time = "2026-09-08T18:46:21.967Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/dde930fe43171cab37572bd993d70c2a271f240f428f8ccd64ec4c2d661b/hypothesis-6.168.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4", size = 1168932, upload-time = "2026-09-08T18:46:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5d/92b83c3d06194ec626e92723d0b0f70221ebf42d7cb355ed36929df6d735/hypothesis-6.168.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d", size = 1298566, upload-time = "2026-09-08T18:48:19.485Z" }, + { url = "https://files.pythonhosted.org/packages/9c/67/52de8bf3446e3d2d555b812d96673b31bb213b5b9d5804a666e5d0bba76e/hypothesis-6.168.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d", size = 1335074, upload-time = "2026-09-08T18:48:01.561Z" }, + { url = "https://files.pythonhosted.org/packages/14/fd/e592773c1c0ce55e35d26ec55f75546bf1fd72ef5a5c520ed685969b40cb/hypothesis-6.168.0-cp312-cp312-win_amd64.whl", hash = "sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68", size = 682026, upload-time = "2026-09-08T18:47:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/39bb8fcccfbafd10fcc777d583c6ebad5d2148e7d743cb350562f28e974f/hypothesis-6.168.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28", size = 793050, upload-time = "2026-09-08T18:47:56.045Z" }, + { url = "https://files.pythonhosted.org/packages/f7/dd/00fd32e8ec470535e0065cb8d6e175f9fc54b4d3a6f1269f6c487f8bd79d/hypothesis-6.168.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20", size = 784558, upload-time = "2026-09-08T18:46:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4d/553c47093f68bdbac0438e16c024ce97649b5804dc6972ef86b9bd2db8a1/hypothesis-6.168.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475", size = 1122841, upload-time = "2026-09-08T18:48:09.374Z" }, + { url = "https://files.pythonhosted.org/packages/43/d6/0b5940aa75e617c8fd12200bae24d1b71347362514e8210735c581d4d3d1/hypothesis-6.168.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0", size = 1168825, upload-time = "2026-09-08T18:47:33.702Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/800de1231b2869b51409bbf85799d6f1bf49a00e0afff0aabc097aa8f1b7/hypothesis-6.168.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669", size = 1298433, upload-time = "2026-09-08T18:46:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/9907667a30c1dbabbc44a09b4c25a0f575570937fcbbada924ae3a1dbf2a/hypothesis-6.168.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c", size = 1334988, upload-time = "2026-09-08T18:48:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/7bf214e703fff532ed47cb52ab93ce4b7ea41e4e7084593fa02b740828b7/hypothesis-6.168.0-cp313-cp313-win_amd64.whl", hash = "sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d", size = 681990, upload-time = "2026-09-08T18:47:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/16/f7/5adae1bf1d4877aca2e8c8e007e57077237e9ac3765e430ffda490b17e19/hypothesis-6.168.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128", size = 791056, upload-time = "2026-09-08T18:48:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/f2/93/b1b2770b87591db5cf564b9aa0265cf21e9207d28645e0abdeb8df63225b/hypothesis-6.168.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795", size = 782980, upload-time = "2026-09-08T18:48:34.087Z" }, + { url = "https://files.pythonhosted.org/packages/de/bd/673171c1d2423379a7d4a0f9f009cca735a422d0c4a4ac6422d1d1736cab/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441", size = 1120964, upload-time = "2026-09-08T18:46:59.92Z" }, + { url = "https://files.pythonhosted.org/packages/7d/00/33a9bd941b22a4fd8a8c805b1563e0db17efc020422f5bd15bdd0fdf258f/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63", size = 1143869, upload-time = "2026-09-08T18:46:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c1/963460976f41721eff8f67f30d31059ea407cc1b41c737c8859aabf37197/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782", size = 1146453, upload-time = "2026-09-08T18:46:25.757Z" }, + { url = "https://files.pythonhosted.org/packages/ce/53/09db238098ad66f4e6d2fe883f26c270c2595b90e21c8f969d4cb21cad7a/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc", size = 1166918, upload-time = "2026-09-08T18:48:23.436Z" }, + { url = "https://files.pythonhosted.org/packages/b9/31/e1b7b452c8a6166e445ba2ad80a864f6a9eee0fe4c8cecdb9af5c1ee0aa5/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e", size = 1126637, upload-time = "2026-09-08T18:47:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/2b/4eceed248afb46fb6b2df21cf2239362de5b25d295c2dc67a82ec8657d5e/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c", size = 1155682, upload-time = "2026-09-08T18:47:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/f3414cda4f325004d5774485e8983b7d1b99e4b91013f013dd088fd778cf/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9", size = 1296464, upload-time = "2026-09-08T18:46:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/aa/40/ca79cf96545e1f172b36b8df56bfeb02b61f351f283026f9a57c4631e368/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad", size = 1421853, upload-time = "2026-09-08T18:47:23.083Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/657d5386740c1f4ac518ff05e4c5b132f1e6df2e7c865ba8fda4279adfa5/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f", size = 1278216, upload-time = "2026-09-08T18:46:30.871Z" }, + { url = "https://files.pythonhosted.org/packages/51/54/2328cdb70489a36634534478d9b238594a269d8bec4630ea0e6897048347/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09", size = 1297593, upload-time = "2026-09-08T18:46:41.395Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ca/d803fa57e3ff7f460f6e262d2b74822cf143343d378501fd3da601b12040/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887", size = 1333785, upload-time = "2026-09-08T18:48:25.434Z" }, + { url = "https://files.pythonhosted.org/packages/86/8f/b9799ae6ba6074f151db2c63f9f6f12d844512821ffaba1a3672d7e59f07/hypothesis-6.168.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780", size = 675173, upload-time = "2026-09-08T18:47:52.601Z" }, + { url = "https://files.pythonhosted.org/packages/61/54/14c3e277b451ff24128ecc2673cac59dd7e535bce1a433c466912fd682e1/hypothesis-6.168.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef", size = 681491, upload-time = "2026-09-08T18:46:51.437Z" }, + { url = "https://files.pythonhosted.org/packages/99/f3/827e4a48ffee7e40244b0bf064ba47c2171e053ab7edf1cf770105e23401/hypothesis-6.168.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a", size = 679197, upload-time = "2026-09-08T18:47:32.112Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/7f15e1b10d13e266b0f09cf3117dd2206104a1f6ac3b3baf6ec4c43b7c2e/hypothesis-6.168.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76", size = 792946, upload-time = "2026-09-08T18:47:35.438Z" }, + { url = "https://files.pythonhosted.org/packages/10/fb/32487bdcf68b3805ec5efcb7f92fb002b573ad3f90c252d2f2913c5d3124/hypothesis-6.168.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01", size = 788783, upload-time = "2026-09-08T18:46:42.862Z" }, + { url = "https://files.pythonhosted.org/packages/13/26/1aa42e0069ebc68d29d980d47420af324fa002494e01c1c0321c34f609ba/hypothesis-6.168.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab", size = 1124762, upload-time = "2026-09-08T18:48:07.512Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4e/99509045ed55aaa05d8408663051a93168d1a8a5bfc132d92ec1584ce47a/hypothesis-6.168.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7", size = 1171748, upload-time = "2026-09-08T18:46:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c9/96e57dfd89913322b5180b031b9e36a29bfe67842108b19904466476cd24/hypothesis-6.168.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c", size = 685447, upload-time = "2026-09-08T18:46:35.97Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.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 = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "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 = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/plugins/capability/darrow-git/skills/create-branch/SKILL.md b/plugins/capability/darrow-git/skills/create-branch/SKILL.md index 5ade3c9d..12185570 100644 --- a/plugins/capability/darrow-git/skills/create-branch/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-branch/SKILL.md @@ -7,18 +7,22 @@ description: Create exactly one new Git branch from a work description or an exp Create exactly one new branch for the described work. -Run every Git operation through `scripts/branch.sh` with Bash; `` +Run every Git operation through the frozen UV entrypoint below; `` contains this file. The script owns repository inspection, naming validation, branch creation, switching, and worktree safety. Use its output as the source of truth. Correct invalid input and retry; relay a safety refusal or Git failure verbatim and stop. Advice inside a Git error does not authorize another action. +The package lives at `/../../backend` inside this plugin. The existing +`scripts/branch.sh` path remains a POSIX compatibility launcher for the same +entrypoint, arguments, records, refusals, and exit statuses. + ## Workflow ### 1. Inspect ```sh -bash /scripts/branch.sh inspect +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-branch inspect ``` - `mode: ready`: continue. In-place branching carries uncommitted changes to @@ -53,14 +57,14 @@ ticket, and satisfies the script's format. Default—in the current checkout: ```sh -bash /scripts/branch.sh create / \ +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-branch create / \ [--ticket-token ] [--from ] ``` Worktree—only on explicit request: ```sh -bash /scripts/branch.sh create / --worktree \ +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-branch create / --worktree \ [--at ] [--ticket-token ] [--from ] ``` diff --git a/plugins/capability/darrow-git/skills/create-branch/scripts/branch.sh b/plugins/capability/darrow-git/skills/create-branch/scripts/branch.sh index 778f8f91..45ba9527 100644 --- a/plugins/capability/darrow-git/skills/create-branch/scripts/branch.sh +++ b/plugins/capability/darrow-git/skills/create-branch/scripts/branch.sh @@ -1,343 +1,5 @@ #!/usr/bin/env bash -# Deterministic git steps for the create-branch skill. -# stdout is read by a model: print only decision-relevant data, never raw -# intermediate git output. Checkable invariants (naming convention, no -# clobbering, in-progress guard) are enforced here, not in the prompt. +# Compatibility launcher; all mechanics live in the contained Python package. set -euo pipefail - -truncate_lines() { awk 'NR<=50'; } -# Unmerged index entries alone miss resolved-but-uncontinued merges, -# rebase stopped at edit/break, and am conflicts — and ls-files is -# cwd-scoped, so check the operation state files too. -in_progress() { - local p - for p in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply; do - if [[ -e "$(git rev-parse --git-path "$p")" ]]; then - return 0 - fi - done - [[ -n "$(git ls-files -u -- ':/')" ]] -} -current_ref() { git symbolic-ref -q --short HEAD || echo "(detached @ $(git rev-parse --short HEAD))"; } -other_worktrees() { - local current=$1 listing line path="" branch="" - listing=$(git worktree list --porcelain) - while IFS= read -r line; do - case "$line" in - "worktree "*) path=${line#worktree } ;; - "branch "*) branch=${line#branch refs/heads/} ;; - "detached") branch="(detached)" ;; - "bare") branch="(bare)" ;; - "") - if [[ -n "$path" && "$path" != "$current" ]]; then - printf '%s [%s]\n' "$path" "$branch" - fi - path="" - branch="" - ;; - esac - done <<< "$listing" - if [[ -n "$path" && "$path" != "$current" ]]; then - printf '%s [%s]\n' "$path" "$branch" - fi -} -default_branch() { - local b - b=$(git symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [[ -n "$b" ]]; then - echo "${b#origin/}" - return - fi - for b in main master; do - if git show-ref -q --verify "refs/heads/$b"; then - echo "$b" - return - fi - done - echo "(none)" -} - -if [[ "$(git rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]]; then - echo "error: not inside a git work tree" >&2 - exit 3 -fi - -cmd=${1:-} -shift || true - -case "$cmd" in - inspect) - if in_progress; then - echo "## mode: conflict (merge/rebase/cherry-pick in progress — do not branch; inform the user)" - echo "## unmerged files" - git diff --name-only --diff-filter=U | truncate_lines - else - echo "## mode: ready" - echo "## current branch: $(current_ref)" - echo "## default branch: $(default_branch)" - echo "## working tree (uncommitted changes travel to the new branch)" - status=$(git status --porcelain) - if [[ -z "$status" ]]; then - echo "clean" - else - printf '%s\n' "$status" | truncate_lines - fi - echo "## recent branches (match their naming style)" - git for-each-ref --count=10 --sort=-committerdate --format='%(refname:short)' refs/heads - # Every worktree other than the current one — run from a linked - # worktree, the main checkout is "elsewhere" too, and this one is not. - cur_top=$(git rev-parse --show-toplevel) - others=$(other_worktrees "$cur_top") - if [[ -n "$others" ]]; then - echo "## other worktrees (their branches are checked out elsewhere)" - printf '%s\n' "$others" | truncate_lines - fi - fi - ;; - create) - # create [--from ] [--worktree [--at ]] - name="" - base="" - ticket_token="" - worktree=0 - at_path="" - at_set=0 - while [[ $# -gt 0 ]]; do - case "$1" in - --from) - if [[ $# -lt 2 ]]; then - echo "error: --from needs a value" >&2 - exit 2 - fi - base=$2 - shift 2 - ;; - --ticket-token) - if [[ $# -lt 2 || -z "$2" ]]; then - echo "error: --ticket-token needs a non-empty opaque value" >&2 - exit 2 - fi - ticket_token=$2 - shift 2 - ;; - --worktree) - worktree=1 - shift - ;; - --at) - if [[ $# -lt 2 ]]; then - echo "error: --at needs a value" >&2 - exit 2 - fi - at_path=$2 - at_set=1 - shift 2 - ;; - -*) - echo "error: unknown flag: $1" >&2 - exit 2 - ;; - *) - if [[ -n "$name" ]]; then - echo "error: exactly one branch name allowed" >&2 - exit 2 - fi - name=$1 - shift - ;; - esac - done - if [[ -z "$name" ]]; then - echo "error: no branch name given" >&2 - exit 2 - fi - if [[ -n "$ticket_token" ]] && ! [[ "$ticket_token" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]]; then - echo "error: ticket token contains unsupported branch characters: $ticket_token" >&2 - exit 5 - fi - if [[ $at_set -eq 1 && $worktree -eq 0 ]]; then - echo "error: --at requires --worktree" >&2 - exit 2 - fi - # An empty --at must not fall through to the default path — a - # user-named location is used verbatim or rejected, never substituted. - if [[ $at_set -eq 1 && -z "$at_path" ]]; then - echo "error: --at needs a non-empty path" >&2 - exit 2 - fi - if in_progress; then - echo "error: merge/rebase/cherry-pick in progress — resolve it first; do not branch" >&2 - exit 8 - fi - if ! git rev-parse -q --verify HEAD >/dev/null 2>&1; then - echo "error: repository has no commits yet — make the first commit before branching" >&2 - exit 3 - fi - if ! [[ "$name" =~ ^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)/.+$ ]] || - ! git check-ref-format --branch "$name" >/dev/null 2>&1; then - echo "error: branch name must be /: $name" >&2 - exit 5 - fi - slug=${name#*/} - if [[ -n "$ticket_token" ]]; then - if [[ "$slug" != "$ticket_token"-* ]]; then - echo "error: branch must lead with the supplied ticket token exactly: $name" >&2 - exit 5 - fi - token_count=$(printf '%s\n' "$slug" | grep -oF -- "$ticket_token" | wc -l | tr -d '[:space:]') - if [[ "$token_count" != 1 ]]; then - echo "error: supplied ticket token must occur exactly once: $name" >&2 - exit 5 - fi - suffix=${slug#"$ticket_token"-} - if ! [[ "$suffix" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then - echo "error: branch suffix must be lowercase kebab-case: $name" >&2 - exit 5 - fi - else - IFS='-' read -ra segs <<< "$slug" - for i in "${!segs[@]}"; do - s=${segs[$i]} - if [[ "$s" =~ ^[a-z0-9]+$ ]]; then - continue - fi - next=${segs[$((i + 1))]:-} - if [[ "$s" =~ ^[A-Z]+$ ]] && [[ "$next" =~ ^[0-9]+$ ]]; then - continue - fi - echo "error: slug segments must be lowercase (ticket ids like DAR-123 may be caps): $name" >&2 - exit 5 - done - fi - if [[ ${#name} -gt 60 ]]; then - echo "error: branch name exceeds 60 chars (${#name})" >&2 - exit 5 - fi - # Case-insensitive exists-check: on case-insensitive filesystems a - # case-variant loose ref shadows a packed ref, silently redirecting the - # existing branch to the new tip. - collision=$(git for-each-ref refs/heads --format='%(refname:short)' | grep -ixF -- "$name" || true) - if [[ -n "$collision" ]]; then - echo "error: branch already exists: $collision — will not reuse or reset it" >&2 - exit 9 - fi - if [[ -n "$base" ]] && ! git rev-parse --verify -q "$base^{commit}" >/dev/null; then - echo "error: base not found: $base" >&2 - exit 2 - fi - from=$(current_ref) - [[ -z "$base" ]] || from=$base - if [[ $worktree -eq 1 ]]; then - default_root="" - default_parent="" - exclude="" - exclude_needs_append=0 - pre_status=$(git status --porcelain) - if [[ $at_set -eq 1 ]]; then - path=$at_path - [[ "$path" == /* ]] || path="$PWD/$path" - # A worktree inside .git corrupts expectations of every git tool. - # Best-effort prefix check, not a full canonicalization. - abs=$path - gitdir=$(cd "$(git rev-parse --git-dir)" && pwd) - case "$abs" in - "$gitdir"|"$gitdir"/*) - echo "error: worktree path is inside the .git directory: $path" >&2 - exit 2 - ;; - esac - else - # Anchor at the main worktree root: --show-toplevel inside a linked - # worktree would nest worktrees, and removing the outer one takes - # the inner working tree with it. - main_root=$(git worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}') - default_root="$main_root/.worktrees" - path="$default_root/$name" - fi - # [[ -e "file/" ]] is false for a regular file — strip trailing - # slashes so the clobber check sees it. - probe=$path - while [[ ${#probe} -gt 1 && "$probe" == */ ]]; do probe=${probe%/}; done - if [[ -e "$probe" || -L "$probe" ]]; then - echo "error: path already exists: $path — will not reuse it" >&2 - exit 9 - fi - if [[ $at_set -eq 0 ]]; then - default_parent=$(dirname "$path") - if [[ ! -d "$default_root" ]]; then - if err=$(mkdir "$default_root" 2>&1); then - : - elif [[ ! -d "$default_root" ]]; then - echo "error: cannot create worktree parent dir: $err" >&2 - exit 9 - fi - fi - if [[ ! -d "$default_parent" ]]; then - if err=$(mkdir "$default_parent" 2>&1); then - : - elif [[ ! -d "$default_parent" ]]; then - echo "error: cannot create worktree parent dir: $err" >&2 - exit 9 - fi - fi - # Keep the default location out of git status. info/exclude is - # shared across worktrees and never a tracked file; the anchored - # pattern applies at each worktree's root. - exclude=$(git rev-parse --git-path info/exclude) - if [[ -e "$exclude" && ! -r "$exclude" ]]; then - echo "error: repository exclude file is unreadable: $exclude" >&2 - exit 9 - fi - exclude_status=0 - grep -qxF '/.worktrees/' "$exclude" 2>/dev/null || exclude_status=$? - if [[ $exclude_status -gt 1 && -e "$exclude" ]]; then - echo "error: cannot inspect repository exclude file: $exclude" >&2 - exit 9 - fi - if [[ $exclude_status -eq 1 ]]; then - if [[ ! -w "$exclude" ]]; then - echo "error: repository exclude file is not writable: $exclude" >&2 - exit 9 - fi - exclude_needs_append=1 - elif [[ ! -e "$exclude" ]]; then - exclude_needs_append=1 - fi - fi - out=$(git worktree add "$path" -b "$name" ${base:+"$base"} 2>&1) || { - echo "$out" >&2 - exit 4 - } - if [[ $exclude_needs_append -eq 1 ]]; then - if ! printf '%s\n' '/.worktrees/' >> "$exclude"; then - echo "error: worktree created but cannot update repository exclude file: $exclude" >&2 - exit 9 - fi - fi - echo "$name (from $from) at $path" - if [[ -n "$pre_status" ]]; then - echo "## note: uncommitted changes stay in the current worktree — they were not carried into $path" - fi - if [[ $at_set -eq 1 ]]; then - # rc 0: ignored; rc 1: inside the work tree and visible to status; - # rc >1: outside the work tree — nothing to flag. - rc=0 - git check-ignore -q -- "$path" 2>/dev/null || rc=$? - if [[ $rc -eq 1 ]]; then - echo "## note: $path is inside the repository and not ignored — git status will list it; consider adding it to .git/info/exclude" - fi - fi - else - # switch -c carries uncommitted changes along; refusal surfaces verbatim. - out=$(git switch -c "$name" ${base:+"$base"} 2>&1) || { - echo "$out" >&2 - exit 4 - } - echo "$name (from $from)" - fi - ;; - *) - echo "usage: branch.sh inspect | create [--ticket-token ] [--from ] [--worktree [--at ]]" >&2 - exit 64 - ;; -esac +plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) +exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-create-branch "$@" diff --git a/plugins/capability/darrow-git/skills/create-commit/SKILL.md b/plugins/capability/darrow-git/skills/create-commit/SKILL.md index 9267d0f0..200fcf77 100644 --- a/plugins/capability/darrow-git/skills/create-commit/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-commit/SKILL.md @@ -14,12 +14,16 @@ Select the operation from the user's literal request before inspecting Git: | `commit`, including “commit this; it belongs with the previous commit” or “commit this and keep history clean/compact” | Create one new commit through this workflow without asking whether to amend. The literal commit request takes precedence over the implied history preference. | | literal `amend`, `rewrite`, or `rebase` request | Stop before any Git operation because history rewriting is outside this workflow. | -Run every Git operation for this task through -`/scripts/commit.sh`, where `` contains this file. Run -the script with Bash. It owns state inspection, staging, message validation, +Run every Git operation for this task through the frozen UV entrypoint below; +`` contains this file. It supports Linux, macOS, and Windows and owns +state inspection, staging, message validation, hook execution, and the final commit; execute it without reading or reimplementing it. Treat its refusals as authoritative. +The package lives at `/../../backend` inside this plugin. The existing +`scripts/commit.sh` path remains a POSIX compatibility launcher for the same +entrypoint, arguments, records, refusals, and exit statuses. + ## Working model - **Staged means selected:** an existing staged set is the complete commit set. @@ -38,7 +42,7 @@ Leave pushing, branching, and pull requests outside this workflow. Run: ```sh -bash /scripts/commit.sh inspect +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-commit inspect ``` Follow the reported mode: @@ -52,7 +56,7 @@ Follow the reported mode: Inspect an uncertain candidate with: ```sh - bash /scripts/commit.sh diff ... + uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-commit diff ... ``` Keep unrelated paths untouched. If the user's description conflicts with @@ -85,16 +89,16 @@ diff. For `staged` mode, pass no paths: ```sh -bash /scripts/commit.sh commit -m "" [-m ""] +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-commit commit -m "" [-m ""] ``` For `unstaged` mode, pass every selected literal path and no others: ```sh -bash /scripts/commit.sh commit -m "" [-m ""] ... +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-commit commit -m "" [-m ""] ... ``` -This `commit.sh commit` call is the only commit-creation command in the +This `darrow-create-commit commit` call is the only commit-creation command in the workflow. Use neither raw Git nor a history-rewriting option. Do not use sweep paths such as `.` or globs. If the script rejects the input, correct only the proposed selection or message and retry. If commit execution fails because of @@ -118,7 +122,7 @@ command. Ask for the exact staged paths to refresh. A normal staged commit would retain stale index content, so it is not an authorized substitute. ```sh -bash /scripts/commit.sh retry --after-hook-failure \ +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-commit retry --after-hook-failure \ --refresh-staged ... -m "" [-m ""] ``` @@ -147,7 +151,7 @@ Pass the literal command and every path the user explicitly authorizes to the guarded script: ```sh -bash /scripts/commit.sh remediate --after-hook-failure \ +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-commit remediate --after-hook-failure \ --command "" \ --refresh-staged ... -m "" [-m ""] ``` diff --git a/plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh b/plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh index e9f4d5d6..53058518 100755 --- a/plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh +++ b/plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh @@ -1,353 +1,5 @@ #!/usr/bin/env bash -# Deterministic git steps for the create-commit skill. -# stdout is read by a model: print only decision-relevant data, never raw -# intermediate git output. Checkable invariants (conventional format, no AI -# attribution, staged-set integrity) are enforced here, not in the prompt. +# Compatibility launcher; all mechanics live in the contained Python package. set -euo pipefail - -# `head` would SIGPIPE git under pipefail on large diffs; awk consumes input. -truncate_lines() { awk 'NR<=300'; } - -in_conflict() { [[ -n "$(git ls-files -u)" ]]; } - -git_dir_path() { - git_dir=$(git rev-parse --git-dir) - case "$git_dir" in - /*) printf '%s\n' "$git_dir" ;; - *) printf '%s/%s\n' "$PWD" "$git_dir" ;; - esac -} - -hook_failure_state_path() { - printf '%s/darrow-create-commit-hook-failure\n' "$(git_dir_path)" -} - -save_hook_failure_state() { - state_path=$(hook_failure_state_path) - state_head=$2 - state_tree=$3 - umask 077 - { - printf 'format\tdarrow-create-commit-hook-failure-v1\n' - printf 'head\t%s\n' "$state_head" - printf 'index_tree\t%s\n' "$state_tree" - printf 'output\n%s\n' "$1" - } >"$state_path" -} - -validate_refresh_files() { - for f in ${refresh_files[@]+"${refresh_files[@]}"}; do - if [[ "$f" == "." || "$f" == ".." || "$f" == -* || "$f" == :* || "$f" == *[\*\?\[]* ]]; then - echo "error: only explicit file paths allowed, got: $f" >&2 - return 7 - fi - staged_path=$(git diff --cached --name-only -- "$f") - if [[ "$staged_path" != "$f" ]]; then - echo "error: retry path is not in the existing staged set: $f" >&2 - return 7 - fi - done -} - -cmd=${1:-} -shift || true - -case "$cmd" in - inspect) - # Mode-aware: with a staged set the commit scope is already decided, so - # only message context is printed. Without one, selection context. - if in_conflict; then - echo "## mode: conflict (merge/rebase in progress — do not commit; inform the user)" - echo "## unmerged files" - git diff --name-only --diff-filter=U - elif ! git diff --cached --quiet; then - echo "## mode: staged (commit exactly this set; pass no paths)" - echo "## staged files" - git diff --cached --name-status - echo "## not included (unstaged/untracked)" - git diff --name-only - git ls-files --others --exclude-standard - echo "## recent subjects" - git log -5 --format='%s' 2>/dev/null || true - echo "## staged diff (truncated at 300 lines)" - git diff --cached --unified=2 | truncate_lines - else - echo "## mode: unstaged (select only files belonging to the change)" - echo "## unstaged files" - git diff --name-status - echo "## untracked files" - git ls-files --others --exclude-standard - echo "## recent subjects" - git log -5 --format='%s' 2>/dev/null || true - echo "## unstaged diff (truncated at 300 lines)" - git diff --unified=2 | truncate_lines - fi - ;; - - diff) - # Compact diff of specific working-tree paths, for choosing what to stage. - if [[ $# -eq 0 ]]; then - echo "error: diff needs at least one path" >&2 - exit 64 - fi - for p in "$@"; do - if git ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then - git diff --unified=2 -- "$p" | truncate_lines - elif [[ -f "$p" ]]; then - # Untracked: show as an all-new diff. - git diff --no-index --unified=2 -- /dev/null "$p" | truncate_lines || true - else - echo "error: no such file: $p" >&2 - exit 64 - fi - done - ;; - - commit) - # commit [-m ]... []... - # Paths (if given) are staged explicitly; -m repeats like git commit. - files=() - msgs=() - while [[ $# -gt 0 ]]; do - case "$1" in - -m) - if [[ $# -lt 2 ]]; then - echo "error: -m needs a value" >&2 - exit 2 - fi - msgs+=("$2") - shift 2 - ;; - *) - files+=("$1") - shift - ;; - esac - done - if [[ ${#msgs[@]} -eq 0 ]]; then - echo "error: no -m message given" >&2 - exit 2 - fi - - if in_conflict; then - echo "error: merge/rebase in progress — resolve conflicts first; do not commit" >&2 - exit 8 - fi - - # Staged-set integrity: an existing staged set IS the commit set. - if ! git diff --cached --quiet && [[ ${#files[@]} -gt 0 ]]; then - echo "error: a staged set exists; pass no paths (commit exactly the staged set)" >&2 - exit 7 - fi - # No sweep shortcuts: only explicit literal paths. - for f in ${files[@]+"${files[@]}"}; do - if [[ "$f" == "." || "$f" == ".." || "$f" == -* || "$f" == :* || "$f" == *[\*\?\[]* ]]; then - echo "error: only explicit file paths allowed, got: $f" >&2 - exit 7 - fi - done - - subject=${msgs[0]%%$'\n'*} - if ! [[ "$subject" =~ ^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)(\([^\)]+\))?\!?:\ [^[:space:]] ]]; then - echo "error: subject not Conventional Commits format: $subject" >&2 - exit 5 - fi - if [[ ${#subject} -gt 72 ]]; then - echo "error: subject exceeds 72 chars (${#subject})" >&2 - exit 5 - fi - if [[ "$subject" == *. ]]; then - echo "error: subject has trailing period" >&2 - exit 5 - fi - full_message=$(printf '%s\n\n' "${msgs[@]}") - # Attribution needs tool context: "generated by openapi-generator" is - # legitimate prose, "Generated using Claude Code" is not. - # Herestring, not a pipe: grep -q exits at the first match, and on a - # >64KB message the writer's SIGPIPE (141) would make pipefail discard - # the match — silently disabling this check. - if grep -qiE 'co-authored-by:.*\b(claude|gpt|chatgpt|codex|copilot|cursor|gemini|ai)\b|co[- ]?authored[- ]by +(claude|gpt|chatgpt|codex|copilot|cursor|gemini)\b|(generated|built|written|created|made|assisted)[- ](with|by|using) +\[?(claude|gpt|chatgpt|codex|copilot|cursor|gemini|an? ai\b|ai\b)|🤖' <<< "$full_message"; then - echo "error: AI attribution is not allowed in commit messages" >&2 - exit 6 - fi - - if [[ ${#files[@]} -gt 0 ]]; then - git add -- "${files[@]}" - fi - if git diff --cached --quiet; then - echo "error: nothing staged" >&2 - exit 3 - fi - - msg_args=() - for m in "${msgs[@]}"; do msg_args+=(-m "$m"); done - if git rev-parse --verify HEAD >/dev/null 2>&1; then - failed_head=$(git rev-parse HEAD) - else - failed_head=unborn - fi - failed_tree=$(git write-tree) - out=$(git commit -q "${msg_args[@]}" 2>&1) || { - # Commit failed (hook, identity, etc.): surface verbatim, never bypass. - if [[ "$failed_head" == unborn ]]; then - git rev-parse --verify HEAD >/dev/null 2>&1 || git read-tree "$failed_tree" - elif [[ "$(git rev-parse HEAD)" == "$failed_head" ]]; then - git read-tree "$failed_tree" - fi - save_hook_failure_state "$out" "$failed_head" "$failed_tree" - echo "$out" >&2 - exit 4 - } - git log -1 --format='%h %s' - ;; - - retry) - # A hook-failure retry may refresh only named paths already in the index. - # It then delegates to `commit`, retaining that command's normal checks and - # hook execution rather than creating a bypass path. - refresh_files=() - msgs=() - after_hook_failure=0 - while [[ $# -gt 0 ]]; do - case "$1" in - --after-hook-failure) - after_hook_failure=1 - shift - ;; - --refresh-staged) - shift - while [[ $# -gt 0 && "$1" != -m && "$1" != --after-hook-failure && "$1" != --refresh-staged ]]; do - refresh_files+=("$1") - shift - done - ;; - -m) - if [[ $# -lt 2 ]]; then - echo "error: -m needs a value" >&2 - exit 2 - fi - msgs+=("$2") - shift 2 - ;; - *) - echo "error: retry accepts only --after-hook-failure, --refresh-staged, and -m" >&2 - exit 2 - ;; - esac - done - if [[ $after_hook_failure -ne 1 || ${#refresh_files[@]} -eq 0 || ${#msgs[@]} -eq 0 ]]; then - echo "error: retry needs --after-hook-failure, --refresh-staged ..., and -m" >&2 - exit 2 - fi - if in_conflict; then - echo "error: merge/rebase in progress — resolve conflicts first; do not commit" >&2 - exit 8 - fi - if git diff --cached --quiet; then - echo "error: retry needs an existing staged set" >&2 - exit 7 - fi - validate_refresh_files || exit $? - git add -- "${refresh_files[@]}" - msg_args=() - for m in "${msgs[@]}"; do msg_args+=(-m "$m"); done - exec "$0" commit "${msg_args[@]}" - ;; - - remediate) - # The skill interprets trusted hook diagnostics. This script executes the - # supplied literal command only against the exact failed index snapshot, - # then refuses and restores the index if remediation staged anything. - refresh_files=() - msgs=() - after_hook_failure=0 - remediation_command= - while [[ $# -gt 0 ]]; do - case "$1" in - --after-hook-failure) - after_hook_failure=1 - shift - ;; - --command) - if [[ $# -lt 2 ]]; then - echo "error: --command needs a value" >&2 - exit 2 - fi - remediation_command=$2 - shift 2 - ;; - --refresh-staged) - shift - while [[ $# -gt 0 && "$1" != -m && "$1" != --after-hook-failure && "$1" != --command && "$1" != --refresh-staged ]]; do - refresh_files+=("$1") - shift - done - ;; - -m) - if [[ $# -lt 2 ]]; then - echo "error: -m needs a value" >&2 - exit 2 - fi - msgs+=("$2") - shift 2 - ;; - *) - echo "error: remediate accepts only --after-hook-failure, --command, --refresh-staged, and -m" >&2 - exit 2 - ;; - esac - done - if [[ $after_hook_failure -ne 1 || -z "$remediation_command" || ${#refresh_files[@]} -eq 0 || ${#msgs[@]} -eq 0 ]]; then - echo "error: remediate needs --after-hook-failure, --command, --refresh-staged ..., and -m" >&2 - exit 2 - fi - state_path=$(hook_failure_state_path) - if [[ ! -f "$state_path" || ! -r "$state_path" ]]; then - echo "error: no readable hook-failure state for remediation" >&2 - exit 7 - fi - state_head=$(awk -F '\t' '$1 == "head" { print $2 }' "$state_path") - state_tree=$(awk -F '\t' '$1 == "index_tree" { print $2 }' "$state_path") - state_output=$(sed -n '/^output$/,$p' "$state_path") - state_output=${state_output#output$'\n'} - if [[ -z "$state_head" || -z "$state_tree" ]] || { [[ "$state_head" != unborn ]] && ! git rev-parse --verify "$state_head^{commit}" >/dev/null 2>&1; } || ! git rev-parse --verify "$state_tree^{tree}" >/dev/null 2>&1; then - echo "error: hook-failure state is invalid" >&2 - exit 7 - fi - if { [[ "$state_head" == unborn ]] && git rev-parse --verify HEAD >/dev/null 2>&1; } || { [[ "$state_head" != unborn ]] && [[ "$(git rev-parse HEAD)" != "$state_head" ]]; } || [[ "$(git write-tree)" != "$state_tree" ]]; then - echo "error: failed commit state changed; do not remediate" >&2 - exit 7 - fi - validate_refresh_files || exit $? - if [[ "$state_output" != *"$remediation_command"* ]]; then - echo "error: remediation command is not present in the failed hook diagnostic" >&2 - exit 7 - fi - if remediation_out=$(sh -c "$remediation_command" 2>&1); then - remediation_status=0 - else - remediation_status=$? - fi - if { [[ "$state_head" == unborn ]] && git rev-parse --verify HEAD >/dev/null 2>&1; } || { [[ "$state_head" != unborn ]] && [[ "$(git rev-parse HEAD)" != "$state_head" ]]; }; then - echo "error: remediation changed HEAD; do not retry" >&2 - exit 8 - fi - if [[ "$(git write-tree)" != "$state_tree" ]]; then - git read-tree "$state_tree" - echo "error: remediation changed the index; restored failed commit staging and stopped" >&2 - exit 7 - fi - if [[ $remediation_status -ne 0 ]]; then - echo "$remediation_out" >&2 - exit 4 - fi - msg_args=() - for m in "${msgs[@]}"; do msg_args+=(-m "$m"); done - exec "$0" retry --after-hook-failure --refresh-staged "${refresh_files[@]}" "${msg_args[@]}" - ;; - - *) - echo "usage: commit.sh inspect | diff ... | commit [-m ]... []... | retry --after-hook-failure --refresh-staged ... -m | remediate --after-hook-failure --command --refresh-staged ... -m " >&2 - exit 64 - ;; -esac +plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) +exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-create-commit "$@" diff --git a/plugins/capability/darrow-git/skills/create-pr/SKILL.md b/plugins/capability/darrow-git/skills/create-pr/SKILL.md index 133b62f5..3a78f69d 100644 --- a/plugins/capability/darrow-git/skills/create-pr/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-pr/SKILL.md @@ -7,12 +7,16 @@ description: Publish the current feature branch through one pull request. Use wh Propose the current branch's committed delta through one pull request. -Run every Git and GitHub operation through `/scripts/pr.sh`, where -`` contains this file. Run the script with Bash. It owns repository +Run every Git and GitHub operation through the frozen UV entrypoint below; +`` contains this file. It supports Linux, macOS, and Windows and owns repository inspection, duplicate detection, validation, non-force pushing, and PR creation; execute it without reading or reimplementing it. Treat its refusals as authoritative. +The package lives at `/../../backend` inside this plugin. The existing +`scripts/pr.sh` path remains a POSIX compatibility launcher for the same +entrypoint, arguments, records, refusals, and exit statuses. + ## Working model - **Committed delta:** branch commits relative to the base define the PR. @@ -31,7 +35,7 @@ as authoritative. Run: ```sh -bash /scripts/pr.sh inspect [--base ] [--template ] +uv run --quiet --frozen --no-dev --project "/../../backend" darrow-create-pr inspect [--base ] [--template ] ``` When the user names a base, pass that exact `--base` to inspection, every @@ -92,7 +96,7 @@ template when present, and preserves any known ticket identifier. Run exactly one creation command: ```sh -bash /scripts/pr.sh create --title -b <section>... [--template <filename>] [--base <branch>] [--draft] +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-create-pr create --title <title> -b <section>... [--template <filename>] [--base <branch>] [--draft] ``` Pass `--base` only when the user named that base. Pass `--draft` only when the @@ -139,7 +143,7 @@ inspection; when the enclosing owner supplies an ID for its verified content, require that same ID. Do not silently replace it with a later local head. ```sh -bash <skill-dir>/scripts/pr.sh publish-existing --expected-head <full-commit-id> [--base <branch>] [--draft] +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-create-pr publish-existing --expected-head <full-commit-id> [--base <branch>] [--draft] ``` The script checks one open same-repository PR and its exact branch, base and @@ -155,7 +159,7 @@ When the caller requires verified published content after creation, or needs to observe an ambiguous prior publication, run the read-only operation: ```sh -bash <skill-dir>/scripts/pr.sh verify --expected-head <full-commit-id> [--base <branch>] [--draft] +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-create-pr verify --expected-head <full-commit-id> [--base <branch>] [--draft] ``` Use the same concise success report as §4, retaining the complete publication diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/base-user-named.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/base-user-named.yaml index 72c54203..c97e0e83 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/base-user-named.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/base-user-named.yaml @@ -37,6 +37,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/body-context-ticket.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/body-context-ticket.yaml index df9077c3..38b4e8b4 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/body-context-ticket.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/body-context-ticket.yaml @@ -38,6 +38,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/default-branch-refuse.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/default-branch-refuse.yaml index 9e0d9e19..418731ca 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/default-branch-refuse.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/default-branch-refuse.yaml @@ -29,6 +29,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/dirty-committed-only.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/dirty-committed-only.yaml index 4ee1c9d2..8091b5ae 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/dirty-committed-only.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/dirty-committed-only.yaml @@ -38,6 +38,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/draft-request.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/draft-request.yaml index 77aacfd1..40e8351e 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/draft-request.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/draft-request.yaml @@ -40,6 +40,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/dup-stop.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/dup-stop.yaml index 2d2c5f69..4fabfc34 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/dup-stop.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/dup-stop.yaml @@ -30,6 +30,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/template-choice-selected.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/template-choice-selected.yaml index 202ae4ca..33af5468 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/template-choice-selected.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/template-choice-selected.yaml @@ -42,6 +42,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/template-fill.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/template-fill.yaml index 0d9eb2f4..cf2e3a4b 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/template-fill.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/template-fill.yaml @@ -46,6 +46,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/evals/title-conventional.yaml b/plugins/capability/darrow-git/skills/create-pr/evals/title-conventional.yaml index 04f76794..b5b156b8 100644 --- a/plugins/capability/darrow-git/skills/create-pr/evals/title-conventional.yaml +++ b/plugins/capability/darrow-git/skills/create-pr/evals/title-conventional.yaml @@ -38,6 +38,7 @@ fixture: case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/pr.sh b/plugins/capability/darrow-git/skills/create-pr/scripts/pr.sh index f6ffc9fb..f840b9a4 100755 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/pr.sh +++ b/plugins/capability/darrow-git/skills/create-pr/scripts/pr.sh @@ -1,626 +1,5 @@ #!/usr/bin/env bash -# Deterministic git/gh steps for the create-pr skill. -# stdout is read by a model: print only decision-relevant data, never raw -# intermediate git output. Checkable invariants (conventional title, no AI -# attribution, deliberate base, no duplicates, push without rewrite, -# template shape) are enforced here, not in the prompt. +# Compatibility launcher; all mechanics live in the contained Python package. set -euo pipefail - -# `head` would SIGPIPE git under pipefail on large output; awk consumes input. -truncate_lines() { awk 'NR<=50'; } - -# Unmerged index entries alone miss resolved-but-uncontinued merges, -# rebase stopped at edit/break, and am conflicts — and ls-files is -# cwd-scoped, so check the op state files too. -in_progress() { - local p - for p in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply; do - if [[ -e "$(git rev-parse --git-path "$p")" ]]; then - return 0 - fi - done - [[ -n "$(git ls-files -u -- ':/')" ]] -} - -current_branch() { - local ref - ref=$(git symbolic-ref -q HEAD) || return $? - printf '%s\n' "${ref#refs/heads/}" -} -current_ref() { current_branch || echo "(detached @ $(git rev-parse --short HEAD))"; } - -# Sets DEFAULT_BRANCH and DEFAULT_SRC=tracked|remote|guess (globals, not -# stdout: a command substitution would drop DEFAULT_SRC in a subshell). -# Callers can flag a guess: the local main/master fallback may point at a -# branch the remote never merges. -detect_default_branch() { - local b - DEFAULT_SRC=tracked - b=$(git symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [[ -n "$b" ]]; then - DEFAULT_BRANCH=${b#origin/} - return - fi - # origin/HEAD is only set by clone/set-head; ask the remote directly - # (resolves offline for path remotes). - DEFAULT_SRC=remote - b=$(git ls-remote --symref origin HEAD 2>/dev/null | awk '$1 == "ref:" && !seen {sub("refs/heads/", "", $2); print $2; seen=1}' || true) - if [[ -n "$b" ]]; then - DEFAULT_BRANCH=$b - return - fi - DEFAULT_SRC=guess - for b in main master; do - if git show-ref -q --verify "refs/heads/$b"; then - DEFAULT_BRANCH=$b - return - fi - done - DEFAULT_BRANCH="(none)" -} - -# origin/<branch> when the remote-tracking ref exists, else the local branch. -compare_ref() { - if git rev-parse -q --verify "refs/remotes/origin/$1" >/dev/null; then - echo "origin/$1" - else - echo "$1" - fi -} - -# Named bases must be usable before either inspection or publication. A -# local-only branch would fail on the forge after the push already happened. -validate_selected_base() { - local base=$1 branch=$2 - if [[ "$base" == "$branch" ]]; then - echo "error: base equals the current branch: $branch" >&2 - exit 2 - fi - if ! git rev-parse -q --verify "refs/remotes/origin/$base" >/dev/null; then - echo "error: base not found on origin: $base — fetch or push it first" >&2 - exit 2 - fi -} - -# Sets PR_TEMPLATE to the repo's PR template path, GitHub's lookup order -# (.github/, repo root, docs/). Global, not stdout: callers branch on the -# return code and then need the path. -find_pr_template() { - PR_TEMPLATE="" - local top d f - top=$(git rev-parse --show-toplevel) - for d in .github "" docs; do - for f in PULL_REQUEST_TEMPLATE.md pull_request_template.md; do - if [[ -f "$top/${d:+$d/}$f" ]]; then - PR_TEMPLATE="$top/${d:+$d/}$f" - return 0 - fi - done - done - return 1 -} - -list_pr_template_choices() { - local top dir - top=$(git rev-parse --show-toplevel) - dir="$top/.github/PULL_REQUEST_TEMPLATE" - if [[ -d "$dir" ]]; then - find "$dir" -mindepth 1 -maxdepth 1 -type f ! -name '.*' \ - -exec basename -- {} \; | LC_ALL=C sort - fi -} - -valid_pr_template_choice() { - case "$1" in - ""|.*|*/*) return 1 ;; - *) return 0 ;; - esac -} - -pr_template_choice_is_listed() { - local wanted=$1 choices=$2 choice - while IFS= read -r choice; do - if [[ "$choice" == "$wanted" ]]; then - return 0 - fi - done <<< "$choices" - return 1 -} - -print_pr_template() { - local top rel tlines - top=$(git rev-parse --show-toplevel) - rel=${PR_TEMPLATE#"$top/"} - if [[ ! -r "$PR_TEMPLATE" ]]; then - # Not silently skippable: create refuses, so say why up front. - echo "## note: pr template $rel exists but is not readable — fix its permissions; create will refuse" - return - fi - echo "## pr template ($rel) — the body must follow it: keep headings verbatim, fill every section, follow comment instructions then delete the comments" - awk 'NR<=100' "$PR_TEMPLATE" - tlines=$(awk 'END{print NR}' "$PR_TEMPLATE") - if [[ "$tlines" -gt 100 ]]; then - # Absolute path: a toplevel-relative one does not resolve from a - # subdirectory cwd, and create enforces headings past the cut. - echo "## note: template truncated at 100 lines ($tlines total) — read $PR_TEMPLATE for the rest" - fi -} - -# Template ATX headings outside fenced code blocks, trailing whitespace -# trimmed. Fences: ``` or ~~~ indented up to 3 spaces (CommonMark), matched -# with substr — interval regexes are not portable across awks. A leading -# UTF-8 BOM would hide the first heading. -template_headings() { - awk ' - NR==1 && index($0, "\357\273\277")==1 { $0 = substr($0, 4) } - { n=0; while (substr($0, n+1, 1)==" ") n++; c = substr($0, n+1, 3) } - n<=3 && (c=="```" || c=="~~~") { f=!f; next } - f { next } - n<=3 && substr($0, n+1, 1)=="#" { - m=0; while (substr($0, n+m+1, 1)=="#") m++ - rest = substr($0, n+m+1, 1) - if (m<=6 && (rest==" " || rest=="\t")) { l=$0; sub(/[[:space:]]+$/, "", l); print l } - }' "$1" -} - -if [[ "$(git rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]]; then - echo "error: not inside a git work tree" >&2 - exit 3 -fi - -cmd=${1:-} -shift || true -case "$cmd" in - publish-existing|verify) - # The sourced file is checked separately and exercised by publication.test.sh. - # shellcheck disable=SC1091 - source "$(cd "$(dirname "$0")" && pwd -P)/publication.sh" "$cmd" "$@" - ;; - inspect) - selected_template="" - base="" - user_base="" - while [[ $# -gt 0 ]]; do - case "$1" in - --base) - if [[ $# -lt 2 || -z "$2" ]]; then - echo "error: --base needs a non-empty value" >&2 - exit 2 - fi - base=$2 - user_base=1 - shift 2 - ;; - --template) - if [[ $# -lt 2 ]]; then - echo "error: --template needs a filename" >&2 - exit 2 - fi - selected_template=$2 - shift 2 - ;; - *) - echo "error: unknown argument: $1" >&2 - exit 2 - ;; - esac - done - if [[ -n "$selected_template" ]] && ! valid_pr_template_choice "$selected_template"; then - echo "error: --template must be one exact visible filename from .github/PULL_REQUEST_TEMPLATE/" >&2 - exit 2 - fi - if in_progress; then - echo "## mode: conflict (merge/rebase/cherry-pick in progress — do not open a PR; inform the user)" - echo "## unmerged files" - git diff --name-only --diff-filter=U | truncate_lines - elif ! git rev-parse -q --verify HEAD >/dev/null 2>&1; then - echo "## mode: empty (no commits yet — nothing to propose)" - elif ! git remote get-url origin >/dev/null 2>&1; then - echo "## mode: no-remote (no 'origin' remote — cannot create a PR; inform the user)" - else - cur=$(current_ref) - detect_default_branch - def=$DEFAULT_BRANCH - if [[ "$cur" == "(detached"* ]]; then - echo "## mode: wrong-branch (detached HEAD — a PR needs a branch; suggest create-branch)" - echo "## cur: $cur" - elif [[ "$cur" == "$def" ]]; then - echo "## mode: wrong-branch (on the default branch — a PR needs a feature branch; suggest create-branch)" - echo "## cur branch: $cur" - else - base_source=default - if [[ -n "$user_base" ]]; then - validate_selected_base "$base" "$cur" - base_source=selected - else - base=$def - fi - # Tolerant here (create re-checks hard), but a failed check must not - # masquerade as "no open PR". - existing="" - pr_check_note="" - if ! command -v gh >/dev/null 2>&1; then - pr_check_note="## note: gh CLI not found — create will fail until it is installed" - elif ! existing=$(gh pr list --head "$cur" --state open 2>/dev/null); then - existing="" - pr_check_note="## note: could not check for an existing open PR (gh error) — create re-checks" - fi - ahead="" - cmp="" - if [[ "$base" != "(none)" ]]; then - cmp=$(compare_ref "$base") - ahead=$(git rev-list --count "$cmp..HEAD" 2>/dev/null || true) - fi - if [[ -n "$existing" ]]; then - echo "## mode: exists (open PR for this branch — report it; do not create another)" - echo "## intended commit: $(git rev-parse HEAD)" - printf '%s\n' "$existing" | truncate_lines - elif [[ "$ahead" == "0" ]]; then - echo "## mode: no-commits (no commits ahead of $cmp — nothing to propose; report and stop)" - echo "## cur branch: $cur" - echo "## base branch ($base_source): $base" - else - echo "## mode: ready" - echo "## intended commit: $(git rev-parse HEAD)" - echo "## cur branch: $cur" - echo "## base branch ($base_source): $base" - if [[ "$DEFAULT_SRC" == "guess" ]]; then - echo "## note: default branch guessed from local branches (origin/HEAD unset, remote unreachable)" - fi - if [[ -n "$pr_check_note" ]]; then - echo "$pr_check_note" - fi - if u=$(git rev-parse -q --verify --abbrev-ref '@{u}' 2>/dev/null); then - echo "## upstream: $u (ahead $(git rev-list --count '@{u}..HEAD'), behind $(git rev-list --count 'HEAD..@{u}'))" - else - echo "## upstream: none (create will push with -u)" - fi - if [[ -n "$cmp" && -n "$ahead" ]]; then - echo "## commits to include ($cmp..HEAD)" - { git log --format='%h %s' "$cmp..HEAD" 2>/dev/null || true; } | truncate_lines - echo "## diffstat" - { git diff --stat "$cmp...HEAD" 2>/dev/null || true; } | truncate_lines - fi - top=$(git rev-parse --show-toplevel) - if find_pr_template; then - if [[ -n "$selected_template" ]]; then - echo "error: --template is not valid when the repository has the single template $PR_TEMPLATE" >&2 - exit 7 - fi - print_pr_template - else - choices=$(list_pr_template_choices) - choice_count=$(awk 'NF {n++} END {print n+0}' <<< "$choices") - if [[ -n "$selected_template" ]]; then - PR_TEMPLATE="$top/.github/PULL_REQUEST_TEMPLATE/$selected_template" - if ! pr_template_choice_is_listed "$selected_template" "$choices"; then - echo "error: selected pr template is not available: $PR_TEMPLATE" >&2 - exit 7 - fi - print_pr_template - elif [[ "$choice_count" -eq 1 ]]; then - PR_TEMPLATE="$top/.github/PULL_REQUEST_TEMPLATE/$choices" - print_pr_template - elif [[ "$choice_count" -gt 1 ]]; then - echo "## note: multiple PR templates in .github/PULL_REQUEST_TEMPLATE/ — ask the user which one to follow" - printf '%s\n' "$choices" | truncate_lines - fi - fi - echo "## working tree (uncommitted changes will NOT be in the PR)" - status=$(git status --porcelain) - if [[ -z "$status" ]]; then - echo "clean" - else - printf '%s\n' "$status" | truncate_lines - fi - fi - fi - fi - ;; - create) - # create --title <t> -b <body-section>... [--template <filename>] - # [--base <branch>] [--draft] - title="" - bodies=() - base="" - user_base="" - draft="" - selected_template="" - while [[ $# -gt 0 ]]; do - case "$1" in - --title) - if [[ $# -lt 2 ]]; then - echo "error: --title needs a value" >&2 - exit 2 - fi - title=$2 - shift 2 - ;; - -b) - if [[ $# -lt 2 ]]; then - echo "error: -b needs a value" >&2 - exit 2 - fi - # =~ not ${var//}: pattern substitution is O(n^2) in bash and - # takes a minute on a 10KB section. - if ! [[ "$2" =~ [^[:space:]] ]]; then - echo "error: -b section is empty" >&2 - exit 2 - fi - bodies+=("$2") - shift 2 - ;; - --base) - if [[ $# -lt 2 || -z "$2" ]]; then - echo "error: --base needs a non-empty value" >&2 - exit 2 - fi - base=$2 - user_base=1 - shift 2 - ;; - --template) - if [[ $# -lt 2 ]]; then - echo "error: --template needs a filename" >&2 - exit 2 - fi - selected_template=$2 - shift 2 - ;; - --draft) - draft=1 - shift - ;; - *) - echo "error: unknown argument: $1" >&2 - exit 2 - ;; - esac - done - if [[ -z "$title" ]]; then - echo "error: --title required" >&2 - exit 2 - fi - if [[ ${#bodies[@]} -eq 0 ]]; then - echo "error: at least one -b body section required" >&2 - exit 2 - fi - if [[ -n "$selected_template" ]] && ! valid_pr_template_choice "$selected_template"; then - echo "error: --template must be one exact visible filename from .github/PULL_REQUEST_TEMPLATE/" >&2 - exit 2 - fi - if in_progress; then - echo "error: merge/rebase/cherry-pick in progress — resolve it first; do not open a PR" >&2 - exit 8 - fi - if ! git rev-parse -q --verify HEAD >/dev/null 2>&1; then - echo "error: repo has no commits yet — nothing to propose" >&2 - exit 3 - fi - if ! branch=$(current_branch); then - echo "error: detached HEAD — a PR needs a branch (see create-branch)" >&2 - exit 3 - fi - if ! git remote get-url origin >/dev/null 2>&1; then - echo "error: no 'origin' remote — cannot create a PR" >&2 - exit 3 - fi - if ! command -v gh >/dev/null 2>&1; then - echo "error: gh CLI not found — cannot create a PR" >&2 - exit 3 - fi - detect_default_branch - def=$DEFAULT_BRANCH - if [[ "$branch" == "$def" ]]; then - echo "error: on the default branch ($branch) — a PR needs a feature branch (see create-branch)" >&2 - exit 9 - fi - if [[ -z "$base" ]]; then - if [[ "$def" == "(none)" ]]; then - echo "error: cannot determine the default branch — pass --base" >&2 - exit 3 - fi - base=$def - fi - if [[ "$base" == "$branch" ]]; then - echo "error: base equals the current branch: $branch" >&2 - exit 2 - fi - if [[ -n "$user_base" ]]; then - validate_selected_base "$base" "$branch" - fi - if ! [[ "$title" =~ ^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)(\([^\)]+\))?\!?:\ [^[:space:]] ]]; then - echo "error: title not Conventional Commits format: $title" >&2 - exit 5 - fi - if [[ ${#title} -gt 72 ]]; then - echo "error: title exceeds 72 chars (${#title})" >&2 - exit 5 - fi - if [[ "$title" == *. ]]; then - echo "error: title has trailing period" >&2 - exit 5 - fi - body="" - for b in "${bodies[@]}"; do - if [[ -z "$body" ]]; then - body=$b - else - body="$body"$'\n\n'"$b" - fi - done - top=$(git rev-parse --show-toplevel) - if find_pr_template; then - if [[ -n "$selected_template" ]]; then - echo "error: --template is not valid when the repository has the single template $PR_TEMPLATE" >&2 - exit 7 - fi - else - choices=$(list_pr_template_choices) - choice_count=$(awk 'NF {n++} END {print n+0}' <<< "$choices") - if [[ -n "$selected_template" ]]; then - PR_TEMPLATE="$top/.github/PULL_REQUEST_TEMPLATE/$selected_template" - if ! pr_template_choice_is_listed "$selected_template" "$choices"; then - echo "error: selected pr template is not available: $PR_TEMPLATE" >&2 - exit 7 - fi - elif [[ "$choice_count" -eq 1 ]]; then - PR_TEMPLATE="$top/.github/PULL_REQUEST_TEMPLATE/$choices" - elif [[ "$choice_count" -gt 1 ]]; then - echo "error: multiple PR templates require --template <filename>; run inspect and ask the user which one to follow" >&2 - exit 7 - fi - fi - # Attribution needs tool context: "generated by openapi-generator" is - # legitimate prose, "Generated using Claude Code" is not. - # Herestring, not a pipe: grep -q exits at the first match, and on a - # >64KB body the writer's SIGPIPE (141) would make pipefail discard - # the match — silently disabling this check. - if grep -qiE 'co-authored-by:.*\b(claude|gpt|chatgpt|codex|copilot|cursor|gemini|ai)\b|co[- ]?authored[- ]by +(claude|gpt|chatgpt|codex|copilot|cursor|gemini)\b|(generated|built|written|created|made|assisted)[- ](with|by|using) +\[?(claude|gpt|chatgpt|codex|copilot|cursor|gemini|an? ai\b|ai\b)|🤖' <<< "$title"$'\n'"$body"; then - echo "error: AI attribution is not allowed in PR titles or bodies" >&2 - exit 6 - fi - # Template shape is checkable — headings present with content, no - # leftover instruction comments. Content quality stays with the model. - # Runs before any push or PR call: a rejected body must mutate nothing. - if [[ -n "$PR_TEMPLATE" ]]; then - # An unreadable template must refuse, not skip: chmod 000 would - # otherwise silently disable GW-P8 enforcement (and awk would die - # with a raw error under set -e). - if [[ ! -r "$PR_TEMPLATE" ]]; then - echo "error: pr template is not readable: $PR_TEMPLATE — fix its permissions" >&2 - exit 3 - fi - rel=${PR_TEMPLATE#"$(git rev-parse --show-toplevel)/"} - theads=$(template_headings "$PR_TEMPLATE") - if [[ -n "$theads" ]]; then - while IFS= read -r h; do - # A section ends at the next heading of the same or higher level; - # deeper sub-headings inside it count as content. Fenced lines - # never start or end a section but do count as content. - # Herestring, not a pipe: the early exits would SIGPIPE the writer - # under pipefail (false "empty" on large bodies). The heading rides - # in via ENVIRON — awk -v mangles backslashes. - rc=0 - TPL_H="$h" awk ' - BEGIN { - h=ENVIRON["TPL_H"] - hn=0; while (substr(h, hn+1, 1) == " ") hn++ - hl=0; while (substr(h, hn+hl+1, 1) == "#") hl++ - } - { - l=$0; sub(/[[:space:]]+$/, "", l) - n=0; while (substr(l, n+1, 1)==" ") n++ - c = substr(l, n+1, 3) - isfence = (n<=3 && (c=="```" || c=="~~~")) - isheading=0; level=0 - if (n<=3 && substr(l, n+1, 1)=="#") { - while (substr(l, n+level+1, 1)=="#") level++ - rest=substr(l, n+level+1, 1) - if (level<=6 && (rest==" " || rest=="\t")) isheading=1 - } - } - isfence {f=!f} - !f && !isfence && !insec && l==h {insec=1; seen=1; next} - insec && !f && !isfence && isheading && level<=hl {exit} - insec && NF {ok=1; exit} - END {if (!seen) exit 2; exit ok ? 0 : 1}' <<< "$body" || rc=$? - if [[ $rc -eq 2 ]]; then - echo "error: template section missing from the body: $h ($rel)" >&2 - exit 7 - elif [[ $rc -ne 0 ]]; then - echo "error: template section is empty in the body: $h ($rel)" >&2 - exit 7 - fi - done <<< "$theads" - fi - if awk ' - {n=0; while (substr($0, n+1, 1)==" ") n++; c = substr($0, n+1, 3)} - n<=3 && (c=="```" || c=="~~~") {f=!f; next} - !f && index($0, "<!--") {found=1} - END {exit found ? 0 : 1}' <<< "$body"; then - echo "error: body still contains template comments (<!-- ... -->) — follow their instructions, then remove them" >&2 - exit 7 - fi - fi - cmp=$(compare_ref "$base") - if ! ahead=$(git rev-list --count "$cmp..HEAD" 2>/dev/null); then - echo "error: cannot compare against $cmp — fetch origin first" >&2 - exit 3 - fi - if [[ "$ahead" -eq 0 ]]; then - echo "error: no commits ahead of $cmp — nothing to propose" >&2 - exit 3 - fi - # A failed check must abort, not pass as "no duplicates found". - gh_err=$(mktemp) - if ! existing=$(gh pr list --head "$branch" --state open 2>"$gh_err"); then - echo "error: could not check for an existing open PR — fix gh before retrying:" >&2 - truncate_lines <"$gh_err" >&2 - rm -f "$gh_err" - exit 4 - fi - rm -f "$gh_err" - if [[ -n "$existing" ]]; then - echo "error: an open PR for $branch already exists — report it; do not create another:" >&2 - printf '%s\n' "$existing" | truncate_lines >&2 - exit 9 - fi - # Fully qualified source and destination bypass configured push mappings. - # Disable implicit tags and mirroring so only this feature ref is published. - # Never force-push; a refusal (diverged remote branch) surfaces verbatim. - upstream=$(git rev-parse -q --verify --abbrev-ref '@{u}' 2>/dev/null || true) - if [[ -n "$upstream" ]]; then - out=$(git -c push.followTags=false -c remote.origin.mirror=false push origin "refs/heads/$branch:refs/heads/$branch" 2>&1) || { - echo "$out" >&2 - exit 4 - } - else - out=$(git -c push.followTags=false -c remote.origin.mirror=false push -u origin "refs/heads/$branch:refs/heads/$branch" 2>&1) || { - echo "$out" >&2 - exit 4 - } - fi - # --head pins the PR head; without it gh resolves the head from - # tracking config, which may name a different branch or fork. - args=(--title "$title" --body "$body" --base "$base" --head "$branch") - if [[ -n "$draft" ]]; then - args+=(--draft) - fi - export publication_initial_push=completed - export publication_initial_create=uncertain - set +e - out=$(gh pr create "${args[@]}" 2>&1) - create_rc=$? - set -e - if [[ "$create_rc" -ne 0 ]]; then - echo "$out" >&2 - # The forge may have accepted creation before the CLI failed. Observe - # once; never retry or create a duplicate based on exit status alone. - # shellcheck disable=SC1091 - source "$(cd "$(dirname "$0")" && pwd -P)/publication.sh" verify \ - --expected-head "$(git rev-parse HEAD)" --base "$base" ${draft:+--draft} - exit 0 - fi - created_url=$(printf '%s\n' "$out" | awk 'NF {l=$0} END {print l}') - if [[ "$created_url" != https://* ]]; then - # A URL-less success is uncertain remote state. Observe once. - # shellcheck disable=SC1091 - source "$(cd "$(dirname "$0")" && pwd -P)/publication.sh" verify \ - --expected-head "$(git rev-parse HEAD)" --base "$base" ${draft:+--draft} - exit 0 - fi - export publication_initial_create=completed - export publication_initial_url=$created_url - # shellcheck disable=SC1091 - source "$(cd "$(dirname "$0")" && pwd -P)/publication.sh" verify \ - --expected-head "$(git rev-parse HEAD)" --base "$base" ${draft:+--draft} - if [[ -n "$upstream" && "$upstream" != "origin/$branch" ]]; then - echo "note: upstream is $upstream; pushed and opened the PR from origin/$branch" - fi - ;; - *) - echo "usage: pr.sh inspect [--base <branch>] [--template <filename>] | create --title <t> -b <body-section>... [--template <filename>] [--base <branch>] [--draft] | publish-existing|verify --expected-head <full-commit-id> [--base <branch>] [--draft]" >&2 - exit 64 - ;; -esac +plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) +exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-create-pr "$@" diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/pr.test.sh b/plugins/capability/darrow-git/skills/create-pr/scripts/pr.test.sh index 072e7a86..fe0a9b18 100644 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/pr.test.sh +++ b/plugins/capability/darrow-git/skills/create-pr/scripts/pr.test.sh @@ -73,6 +73,7 @@ case "$1 $2" in case "$1" in --title) printf '%s' "$2" > "$d/title"; shift 2 ;; --body) printf '%s' "$2" > "$d/body"; shift 2 ;; + --body-file) cp "$2" "$d/body"; shift 2 ;; --base) printf '%s' "$2" > "$d/base"; shift 2 ;; --head) printf '%s' "$2" > "$d/head"; shift 2 ;; --draft) : > "$d/draft"; shift ;; @@ -172,7 +173,9 @@ ready_repo if PATH="/usr/bin:/bin" command -v gh >/dev/null 2>&1; then echo " ok: gh-missing case skipped (system gh is present)" else - PATH="/usr/bin:/bin" "$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 + mkdir -p "$REPO/.git/no-gh-bin" + ln -s "$(command -v uv)" "$REPO/.git/no-gh-bin/uv" + PATH="$REPO/.git/no-gh-bin:/usr/bin:/bin" "$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 check "gh missing, create exit 3" 3 $? fi diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh b/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh index e38d8935..f840b9a4 100644 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh +++ b/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh @@ -1,142 +1,5 @@ #!/usr/bin/env bash -# Sourced by pr.sh for the explicit publication and read-only verification paths. -publication_command=$1 -shift -publication_fail() { - if [[ "$publication_push" == completed ]]; then - echo 'note: push completed; publication verification is incomplete' >&2 - fi - if [[ "${publication_create:-none}" == completed ]]; then - echo 'note: PR creation completed; do not create another PR' >&2 - elif [[ "${publication_create:-none}" == uncertain ]]; then - echo 'note: PR creation command had an uncertain effect; do not create another PR without fresh observation' >&2 - fi - printf '%s\n' "push: ${publication_push:-none}" \ - "pr-create: ${publication_create:-none}" \ - "initial-url: ${publication_initial_url:-unknown}" \ - "observed-url: ${publication_url:-unknown}" >&2 - echo "error: $*" >&2 - exit 4 -} - -publication_observe() { - local rows count state cross extra remote_line remote_ref - rows=$(gh pr list --repo "$publication_origin" --head "$publication_branch" \ - --state open --limit 2 \ - --json number,url,state,headRefName,headRefOid,baseRefName,isDraft,isCrossRepository \ - --jq '.[] | [.number,.url,.state,.headRefName,.headRefOid,.baseRefName,.isDraft,.isCrossRepository] | @tsv') || - publication_fail 'cannot observe the open PR' - count=$(awk 'NF {n++} END {print n+0}' <<< "$rows") - [[ "$count" == 1 ]] || publication_fail 'exactly one open PR is required' - IFS=$'\t' read -r publication_number publication_url state publication_head publication_pr_commit publication_pr_base publication_pr_draft cross extra <<< "$rows" - [[ "$publication_number" =~ ^[0-9]+$ && "$publication_url" == https://* && -z "$extra" ]] || - publication_fail 'malformed PR identity evidence' - [[ "$state" == OPEN && "$cross" == false && "$publication_head" == "$publication_branch" ]] || - publication_fail 'PR is not open on the expected same-repository branch' - [[ "$publication_pr_base" == "$publication_base" && "$publication_pr_draft" == "$publication_draft" ]] || - publication_fail 'PR base or draft state does not match the requested shape' - remote_line=$(git ls-remote --exit-code origin "refs/heads/$publication_branch") || - publication_fail 'cannot observe the remote branch' - IFS=$'\t' read -r publication_remote_commit remote_ref extra <<< "$remote_line" - [[ "$remote_line" != *$'\n'* && "$remote_ref" == "refs/heads/$publication_branch" && -z "$extra" ]] || - publication_fail 'ambiguous remote branch evidence' - [[ "$publication_remote_commit" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || - publication_fail 'malformed remote commit evidence' - [[ "$publication_pr_commit" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || - publication_fail 'malformed forge commit evidence' -} - -publication_observe_converged() { - local attempt=1 - publication_observe - local pinned_url=$publication_url pinned_number=$publication_number - while [[ "$publication_pr_commit" != "$publication_remote_commit" ]]; do - [[ "$publication_remote_commit" == "$publication_expected" ]] || - publication_fail 'forge and remote branch commits do not agree' - [[ "$attempt" -lt 5 ]] || publication_fail 'forge and remote branch commits do not agree after bounded propagation observation' - sleep 1 - [[ "$(git rev-parse HEAD)" == "$publication_expected" && "$(git symbolic-ref -q HEAD)" == "refs/heads/$publication_branch" ]] || - publication_fail 'local publication target changed' - publication_observe - [[ "$publication_url" == "$pinned_url" && "$publication_number" == "$pinned_number" ]] || - publication_fail 'PR identity changed during publication' - [[ "$publication_remote_commit" == "$publication_expected" ]] || - publication_fail 'remote branch moved during publication observation' - attempt=$((attempt + 1)) - done -} - -publication_push=${publication_initial_push:-none} -publication_create=${publication_initial_create:-none} -publication_expected='' -publication_base='' -publication_draft=false -while [[ $# -gt 0 ]]; do - case "$1" in - --expected-head|--base) - [[ $# -ge 2 && -n "$2" ]] || publication_fail "$1 needs a value" - if [[ "$1" == --expected-head ]]; then publication_expected=$2; else publication_base=$2; fi - shift 2 - ;; - --draft) publication_draft=true; shift ;; - *) publication_fail "unknown argument: $1" ;; - esac -done -[[ "$publication_expected" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || - publication_fail '--expected-head requires the intended full commit ID' -if in_progress; then publication_fail 'Git operation in progress'; fi -publication_ref=$(git symbolic-ref -q HEAD) || publication_fail 'a feature branch is required' -publication_branch=${publication_ref#refs/heads/} -[[ "$(git rev-parse HEAD)" == "$publication_expected" ]] || publication_fail 'local HEAD differs from the intended commit' -publication_origin=$(git remote get-url origin) || publication_fail 'origin is required' -# An explicit refspec cannot constrain multiple push URLs. Refuse a different -# push endpoint rather than claiming forge evidence for some other repository. -[[ "$(git remote get-url --all origin)" == "$publication_origin" && - "$(git remote get-url --push --all origin)" == "$publication_origin" ]] || - publication_fail 'origin must have one identical fetch and push endpoint' -publication_remote_head=$(git ls-remote --symref origin HEAD) || publication_fail 'repository default branch is unverified' -publication_default_ref=$(awk '$1 == "ref:" && $3 == "HEAD" {print $2}' <<< "$publication_remote_head") -[[ "$publication_default_ref" == refs/heads/* && "$publication_default_ref" != *$'\n'* ]] || - publication_fail 'repository default branch is unverified' -publication_default=${publication_default_ref#refs/heads/} -[[ "$publication_branch" != "$publication_default" ]] || publication_fail 'the default branch cannot be published as a PR' -[[ -n "$publication_base" ]] || publication_base=$publication_default -[[ "$publication_base" != "$publication_branch" ]] || publication_fail 'base equals the current branch' -command -v gh >/dev/null || publication_fail 'gh is required' -publication_repository=$(gh repo view "$publication_origin" --json nameWithOwner --jq '.nameWithOwner') || - publication_fail 'cannot identify the origin repository' -[[ "$publication_repository" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || publication_fail 'malformed origin repository identity' -publication_observe_converged -if [[ "$publication_create" == completed && -n "${publication_initial_url:-}" && "$publication_url" != "$publication_initial_url" ]]; then - publication_fail 'created PR URL differs from the observed canonical PR' -fi -publication_original_url=$publication_url -publication_original_number=$publication_number - -if [[ "$publication_command" == publish-existing && "$publication_remote_commit" != "$publication_expected" ]]; then - # Recheck the local target, then push a pinned commit rather than a moving ref. - [[ "$(git rev-parse HEAD)" == "$publication_expected" && "$(git symbolic-ref -q HEAD)" == "refs/heads/$publication_branch" ]] || - publication_fail 'local publication target changed' - if ! git -c push.followTags=false -c remote.origin.mirror=false push origin \ - "$publication_expected:refs/heads/$publication_branch"; then - publication_fail 'non-force push failed; observe the remote before retrying' - fi - publication_push=completed - publication_observe_converged - [[ "$publication_url" == "$publication_original_url" && "$publication_number" == "$publication_original_number" ]] || - publication_fail 'PR identity changed during publication' -fi - -[[ "$publication_remote_commit" == "$publication_expected" && "$publication_pr_commit" == "$publication_expected" ]] || - publication_fail 'the intended commit is not the published PR head' -[[ "$(git rev-parse HEAD)" == "$publication_expected" && "$(git symbolic-ref -q HEAD)" == "refs/heads/$publication_branch" ]] || - publication_fail 'local publication target changed' -if [[ "$publication_create" == uncertain ]]; then publication_create=observed-after-uncertain-command; fi -printf '%s\n' 'publication: verified' "url: $publication_url" \ - "repository: $publication_repository" "head: $publication_branch" \ - "base: $publication_base" "draft: $publication_draft" \ - "intended-commit: $publication_expected" "remote-commit: $publication_remote_commit" \ - "pr-commit: $publication_pr_commit" "push: $publication_push" \ - "pr-create: $publication_create" -echo '## working tree (excluded from publication)' -git status --porcelain --untracked-files=all +# Compatibility launcher; all mechanics live in the contained Python package. +set -euo pipefail +plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) +exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-create-pr "$@" diff --git a/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md b/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md index d3100438..56e4dcd3 100644 --- a/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md +++ b/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md @@ -7,18 +7,22 @@ description: "Discover local task branches for an exact opaque ticket token, or Prepare exactly one named task branch in the current checkout, or in a linked worktree only when the caller explicitly requests that context. Run every Git -operation through `scripts/branch.sh` with Bash; `<skill-dir>` contains this +operation through the frozen UV entrypoint below; `<skill-dir>` contains this file. The script owns repository inspection, name and ticket-token validation, complete token discovery, additive creation, exact reuse, switching, worktree allocation, and refusal safety. Use its output as the source of truth. +The package lives at `<skill-dir>/../../backend` inside this plugin. The existing +`scripts/branch.sh` path remains a POSIX compatibility launcher for the same +entrypoint, arguments, records, refusals, and exit statuses. + ## 1. Bind discovery or preparation input For discovery, require only the active provider's exact opaque canonical token. Run the read-only operation: ```sh -bash <skill-dir>/scripts/branch.sh discover --ticket-token <opaque-token> +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-prepare-task-branch discover --ticket-token <opaque-token> ``` Relay `mode: discovered`, the unchanged token, the exact count, and every @@ -56,7 +60,7 @@ the missing choice has been requested without repository mutation. Run: ```sh -bash <skill-dir>/scripts/branch.sh inspect +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-prepare-task-branch inspect ``` - `mode: ready`: consider the current branch, deliberate base, dirty state, and @@ -75,7 +79,7 @@ relayed without another operation. Run: ```sh -bash <skill-dir>/scripts/branch.sh prepare \ +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-prepare-task-branch prepare \ <type>/<opaque-token>-<suffix> --ticket-token <opaque-token> [--from <base>] \ [--worktree [--at <path>]] ``` diff --git a/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.sh b/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.sh index 8a644d38..ebf7ce0a 100755 --- a/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.sh +++ b/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.sh @@ -1,447 +1,5 @@ #!/usr/bin/env bash -# Deterministic Git mechanics for prepare-task-branch. +# Compatibility launcher; all mechanics live in the contained Python package. set -euo pipefail - -truncate_lines() { awk 'NR<=50'; } -in_progress() { - local repo=${1:-.} path - for path in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply; do - if [[ -e "$(git -C "$repo" rev-parse --git-path "$path")" ]]; then - return 0 - fi - done - [[ -n "$(git -C "$repo" ls-files -u -- ':/')" ]] -} -current_ref() { - git symbolic-ref -q --short HEAD || - echo "(detached @ $(git rev-parse --short HEAD))" -} -main_worktree() { - local listing line - listing=$(git worktree list --porcelain) - while IFS= read -r line; do - case "$line" in - "worktree "*) - printf '%s\n' "${line#worktree }" - return 0 - ;; - esac - done <<< "$listing" -} -worktree_for_branch() { - local wanted=$1 listing line path="" branch="" - listing=$(git worktree list --porcelain) - while IFS= read -r line; do - case "$line" in - "worktree "*) path=${line#worktree } ;; - "branch "*) branch=${line#branch refs/heads/} ;; - "") - if [[ "$branch" == "$wanted" ]]; then - printf '%s\n' "$path" - return 0 - fi - path="" - branch="" - ;; - esac - done <<< "$listing" - if [[ "$branch" == "$wanted" ]]; then - printf '%s\n' "$path" - fi -} -common_git_dir() { - local repo=$1 dir - dir=$(git -C "$repo" rev-parse --git-common-dir 2>/dev/null) || return 1 - [[ "$dir" == /* ]] || dir="$repo/$dir" - (cd "$dir" 2>/dev/null && pwd -P) -} -remove_created_parents() { - local path=$1 existing_ancestor=$2 - while [[ -n "$path" && "$path" != "$existing_ancestor" ]]; do - rmdir "$path" 2>/dev/null || return 0 - path=$(dirname "$path") - done -} -default_branch() { - local branch - branch=$(git symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [[ -n "$branch" ]]; then - echo "${branch#origin/}" - return - fi - for branch in main master; do - if git show-ref -q --verify "refs/heads/$branch"; then - echo "$branch" - return - fi - done - echo "(none)" -} -validate_token() { - local ticket_token=$1 - if ! [[ "$ticket_token" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]]; then - echo "error: ticket token contains unsupported branch characters: $ticket_token" >&2 - exit 5 - fi - # Use the shortest permitted type and suffix to test whether any name can fit. - if [[ ${#ticket_token} -gt 55 ]] || - ! git check-ref-format --branch "ci/${ticket_token}-a" >/dev/null 2>&1; then - echo "error: ticket token cannot form a valid task branch: $ticket_token" >&2 - exit 5 - fi -} -validate_name() { - local name=$1 ticket_token=$2 slug token_count suffix - validate_token "$ticket_token" - if ! [[ "$name" =~ ^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)/.+$ ]] || - ! git check-ref-format --branch "$name" >/dev/null 2>&1; then - echo "error: branch name must be <type>/<opaque-token>-<kebab-suffix>: $name" >&2 - exit 5 - fi - slug=${name#*/} - if [[ "$slug" != "$ticket_token"-* ]]; then - echo "error: branch must lead with the supplied ticket token exactly: $name" >&2 - exit 5 - fi - token_count=$(printf '%s\n' "$slug" | grep -oF -- "$ticket_token" | wc -l | tr -d '[:space:]') - if [[ "$token_count" != 1 ]]; then - echo "error: supplied ticket token must occur exactly once: $name" >&2 - exit 5 - fi - suffix=${slug#"$ticket_token"-} - if ! [[ "$suffix" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then - echo "error: branch suffix must be lowercase kebab-case: $name" >&2 - exit 5 - fi - if [[ ${#name} -gt 60 ]]; then - echo "error: branch name exceeds 60 chars (${#name})" >&2 - exit 5 - fi -} - -discover_matches() { - local ticket_token=$1 listing name tip - validate_token "$ticket_token" - listing=$(git for-each-ref --sort=refname --format='%(refname:lstrip=2)%09%(objectname)' refs/heads) || { - echo 'error: cannot enumerate local task branches' >&2 - return 3 - } - while IFS=$'\t' read -r name tip; do - [[ -n "$name" ]] || continue - if (validate_name "$name" "$ticket_token") >/dev/null 2>&1; then - printf '%s (at %s)\n' "$name" "$tip" - fi - done <<< "$listing" -} -report_matches() { - local matches=$1 count=0 line - while IFS= read -r line; do - [[ -z "$line" ]] || count=$((count + 1)) - done <<< "$matches" - printf '## matches: %s\n' "$count" - [[ -z "$matches" ]] || printf '%s\n' "$matches" - return 0 -} - -if [[ "$(git rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]]; then - echo "error: not inside a git work tree" >&2 - exit 3 -fi - -command=${1:-} -shift || true - -case "$command" in - discover) - if [[ $# -ne 2 || "$1" != --ticket-token || -z "$2" ]]; then - echo 'error: discover requires --ticket-token <opaque-token>' >&2 - exit 2 - fi - matches=$(discover_matches "$2") || exit $? - echo '## mode: discovered' - printf '## ticket token: %s\n' "$2" - report_matches "$matches" - ;; - inspect) - if in_progress; then - echo "## mode: conflict (merge/rebase/cherry-pick in progress — do not switch; inform the user)" - echo "## unmerged files" - git diff --name-only --diff-filter=U | truncate_lines - exit 0 - fi - echo "## mode: ready" - echo "## current branch: $(current_ref)" - echo "## default branch: $(default_branch)" - echo "## working tree (compatible uncommitted changes travel unchanged)" - status=$(git status --porcelain) - if [[ -z "$status" ]]; then - echo "clean" - else - printf '%s\n' "$status" | truncate_lines - fi - echo "## local branches" - git for-each-ref --sort=refname --format='%(refname:lstrip=2)' refs/heads | truncate_lines - ;; - prepare) - name="" - base="" - ticket_token="" - worktree=0 - at_path="" - at_set=0 - while [[ $# -gt 0 ]]; do - case "$1" in - --from) - if [[ $# -lt 2 ]]; then - echo "error: --from needs a value" >&2 - exit 2 - fi - base=$2 - shift 2 - ;; - --ticket-token) - if [[ $# -lt 2 || -z "$2" ]]; then - echo "error: --ticket-token needs a non-empty opaque value" >&2 - exit 2 - fi - ticket_token=$2 - shift 2 - ;; - --worktree) - worktree=1 - shift - ;; - --at) - if [[ $# -lt 2 || -z "$2" ]]; then - echo "error: --at needs a non-empty path" >&2 - exit 2 - fi - at_path=$2 - at_set=1 - shift 2 - ;; - -*) - echo "error: unknown flag: $1" >&2 - exit 2 - ;; - *) - if [[ -n "$name" ]]; then - echo "error: exactly one branch name allowed" >&2 - exit 2 - fi - name=$1 - shift - ;; - esac - done - if [[ -z "$name" ]]; then - echo "error: no branch name given" >&2 - exit 2 - fi - if [[ -z "$ticket_token" ]]; then - echo "error: prepare requires the active provider's opaque ticket token" >&2 - exit 2 - fi - if [[ $at_set -eq 1 && $worktree -eq 0 ]]; then - echo "error: --at requires --worktree" >&2 - exit 2 - fi - if in_progress; then - echo "error: merge/rebase/cherry-pick in progress — resolve it first; do not switch" >&2 - exit 8 - fi - if ! git rev-parse -q --verify HEAD >/dev/null 2>&1; then - echo "error: repository has no commits yet — make the first commit before branching" >&2 - exit 3 - fi - validate_name "$name" "$ticket_token" - - exact=$(git for-each-ref refs/heads --format='%(refname:lstrip=2)' | grep -xF -- "$name" || true) - collision=$(git for-each-ref refs/heads --format='%(refname:lstrip=2)' | grep -ixF -- "$name" || true) - if [[ -z "$exact" && -n "$collision" ]]; then - echo "error: branch name differs only by case from existing branch: $collision" >&2 - exit 9 - fi - if [[ -z "$exact" ]]; then - matches=$(discover_matches "$ticket_token") || exit $? - if [[ -n "$matches" ]]; then - echo 'error: correlated local branches exist; bind one exact existing branch before preparation' >&2 - report_matches "$matches" >&2 - exit 9 - fi - fi - if [[ $worktree -eq 1 ]]; then - default_parent="" - existing_ancestor="" - exclude="" - exclude_needs_append=0 - existing_path=$(worktree_for_branch "$name") - if [[ -n "$existing_path" ]]; then - if [[ ! -d "$existing_path" ]] || - [[ "$(git -C "$existing_path" rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]] || - [[ "$(git -C "$existing_path" symbolic-ref -q HEAD 2>/dev/null || true)" != "refs/heads/$name" ]] || - [[ "$(common_git_dir "$existing_path" || true)" != "$(common_git_dir . || true)" ]]; then - echo "error: registered task worktree is missing, inaccessible, or inconsistent: $existing_path" >&2 - exit 9 - fi - if in_progress "$existing_path"; then - echo "error: merge/rebase/cherry-pick in progress in prepared worktree $existing_path — resolve it first" >&2 - exit 8 - fi - current_top=$(git rev-parse --show-toplevel) - if [[ "$existing_path" == "$current_top" ]]; then - echo "error: branch is already active in the current checkout and cannot also be attached to a linked worktree: $name" >&2 - exit 9 - fi - if [[ $at_set -eq 1 ]]; then - requested=$at_path - [[ "$requested" == /* ]] || requested="$PWD/$requested" - if [[ "$requested" != "$existing_path" ]]; then - echo "error: branch is already checked out in another worktree: $existing_path" >&2 - exit 9 - fi - fi - echo "## mode: worktree-current" - echo "$name (at $(git rev-parse "refs/heads/$name")) in $existing_path" - exit 0 - fi - if [[ $at_set -eq 1 ]]; then - path=$at_path - [[ "$path" == /* ]] || path="$PWD/$path" - else - path="$(main_worktree)/.worktrees/$name" - fi - probe=$path - while [[ ${#probe} -gt 1 && "$probe" == */ ]]; do probe=${probe%/}; done - if [[ -e "$probe" || -L "$probe" ]]; then - echo "error: path already exists: $path — will not reuse it" >&2 - exit 9 - fi - gitdir=$(cd "$(git rev-parse --git-dir)" && pwd) - case "$path" in - "$gitdir"|"$gitdir"/*) - echo "error: worktree path is inside the .git directory: $path" >&2 - exit 2 - ;; - esac - if [[ -n "$base" ]] && ! git rev-parse --verify -q "$base^{commit}" >/dev/null; then - echo "error: base not found: $base" >&2 - exit 2 - fi - from=$(current_ref) - [[ -z "$base" ]] || from=$base - pre_status=$(git status --porcelain) - if [[ $at_set -eq 0 ]]; then - default_parent=$(dirname "$path") - existing_ancestor=$default_parent - while [[ ! -e "$existing_ancestor" ]]; do - parent=$(dirname "$existing_ancestor") - [[ "$parent" != "$existing_ancestor" ]] || break - existing_ancestor=$parent - done - output=$(mkdir -p "$default_parent" 2>&1) || { - echo "error: cannot create worktree parent dir: $output" >&2 - exit 9 - } - exclude=$(git rev-parse --git-path info/exclude) - [[ "$exclude" == /* ]] || exclude="$PWD/$exclude" - if [[ ( -e "$exclude" || -L "$exclude" ) && ! -r "$exclude" ]]; then - echo "error: repository exclude file is unreadable: $exclude" >&2 - remove_created_parents "$default_parent" "$existing_ancestor" - exit 9 - fi - output=$(mkdir -p "$(dirname "$exclude")" 2>&1) || { - echo "error: cannot prepare repository exclude directory: $output" >&2 - remove_created_parents "$default_parent" "$existing_ancestor" - exit 9 - } - exclude_status=1 - if [[ -e "$exclude" || -L "$exclude" ]]; then - exclude_status=0 - grep -qxF '/.worktrees/' "$exclude" 2>/dev/null || exclude_status=$? - fi - if [[ $exclude_status -gt 1 ]]; then - echo "error: cannot inspect repository exclude file: $exclude" >&2 - remove_created_parents "$default_parent" "$existing_ancestor" - exit 9 - fi - if [[ $exclude_status -eq 1 ]]; then - if [[ ( -e "$exclude" && ! -w "$exclude" ) || ( ! -e "$exclude" && ! -w "$(dirname "$exclude")" ) ]]; then - echo "error: repository exclude file is not writable: $exclude" >&2 - remove_created_parents "$default_parent" "$existing_ancestor" - exit 9 - fi - exclude_needs_append=1 - fi - fi - if [[ -n "$exact" ]]; then - output=$(git worktree add "$path" "$name" 2>&1) || { - remove_created_parents "$default_parent" "$existing_ancestor" - echo "$output" >&2 - exit 4 - } - result_mode="worktree-reused" - result_evidence="$name (at $(git rev-parse "refs/heads/$name")) in $path" - else - output=$(git worktree add "$path" -b "$name" ${base:+"$base"} 2>&1) || { - remove_created_parents "$default_parent" "$existing_ancestor" - echo "$output" >&2 - exit 4 - } - result_mode="worktree-created" - result_evidence="$name (from $from) in $path" - fi - if [[ $exclude_needs_append -eq 1 ]]; then - if ! printf '%s\n' '/.worktrees/' >> "$exclude"; then - echo "error: worktree prepared but cannot update repository exclude file: $exclude" >&2 - exit 9 - fi - fi - echo "## mode: $result_mode" - echo "$result_evidence" - if [[ -n "$pre_status" ]]; then - echo "## note: uncommitted changes stay in the current worktree — they were not carried into $path" - fi - if [[ $at_set -eq 1 ]]; then - ignore_status=0 - git check-ignore -q -- "$path" 2>/dev/null || ignore_status=$? - if [[ $ignore_status -eq 1 ]]; then - echo "## note: $path is inside the repository and not ignored — git status will list it; consider adding it to .git/info/exclude" - fi - fi - exit 0 - fi - current=$(git symbolic-ref -q HEAD || true) - current=${current#refs/heads/} - if [[ "$current" == "$name" ]]; then - echo "## mode: current" - echo "$name (at $(git rev-parse "refs/heads/$name"))" - exit 0 - fi - if [[ -n "$exact" ]]; then - output=$(git switch --no-guess "$name" 2>&1) || { - echo "$output" >&2 - exit 4 - } - echo "## mode: reused" - echo "$name (at $(git rev-parse "refs/heads/$name"))" - exit 0 - fi - if [[ -n "$base" ]] && ! git rev-parse --verify -q "$base^{commit}" >/dev/null; then - echo "error: base not found: $base" >&2 - exit 2 - fi - from=$(current_ref) - [[ -z "$base" ]] || from=$base - output=$(git switch -c "$name" ${base:+"$base"} 2>&1) || { - echo "$output" >&2 - exit 4 - } - echo "## mode: created" - echo "$name (from $from)" - ;; - *) - echo "usage: branch.sh discover --ticket-token <opaque-token> | inspect | prepare <name> --ticket-token <opaque-token> [--from <base>] [--worktree [--at <path>]]" >&2 - exit 64 - ;; -esac +plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) +exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-prepare-task-branch "$@" diff --git a/plugins/capability/darrow-git/skills/publish-pr-evidence/SKILL.md b/plugins/capability/darrow-git/skills/publish-pr-evidence/SKILL.md index 4049d56e..7d5a0092 100644 --- a/plugins/capability/darrow-git/skills/publish-pr-evidence/SKILL.md +++ b/plugins/capability/darrow-git/skills/publish-pr-evidence/SKILL.md @@ -5,13 +5,17 @@ description: Publish one candidate-bound evidence comment, with optional ordered # Publish pull-request evidence -Publish one top-level PR conversation comment through -`<skill-dir>/scripts/publish.sh`. Run the script with Bash and treat every +Publish one top-level PR conversation comment through the frozen UV entrypoint +below on Linux, macOS, and Windows; `<skill-dir>` contains this file. Treat every result as authoritative. The script owns GitHub/CLI preflight, candidate identity, attachment validation, deterministic identity, reconciliation, the single comment invocation, and final observation. Do not reproduce those mechanics with raw `gh` commands. +The package lives at `<skill-dir>/../../backend` inside this plugin. The existing +`scripts/publish.sh` path remains a POSIX compatibility launcher for the same +entrypoint, arguments, records, refusals, and exit statuses. + ## Inputs Require explicit authority for one evidence operation and: @@ -30,7 +34,7 @@ unspecified current head. Run exactly one command: ```sh -bash <skill-dir>/scripts/publish.sh publish \ +uv run --quiet --frozen --no-dev --project "<skill-dir>/../../backend" darrow-publish-pr-evidence publish \ --expected-head <full-commit-id> --body-file <path> \ [--image <path> --alt <text> | --video <path> --explanation <text>]... ``` diff --git a/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.sh b/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.sh index e311f0bf..3366be79 100644 --- a/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.sh +++ b/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.sh @@ -1,306 +1,5 @@ #!/usr/bin/env bash +# Compatibility launcher; all mechanics live in the contained Python package. set -euo pipefail - -stage=arguments -expected='' -body_file='' -attachment_count=0 -attachment_paths=() -attachment_kinds=() -attachment_texts=() - -emit_refusal() { - printf '%s\n' "outcome: refused" "stage: $stage" "effects: none" "uncertainty: none" "error: $*" - exit 4 -} - -canonical_file() { - local path=$1 dir base - dir=${path%/*}; base=${path##*/} - [[ "$dir" != "$path" ]] || dir=. - dir=$(cd "$dir" 2>/dev/null && pwd -P) || return 1 - printf '%s/%s\n' "$dir" "$base" -} - -media_matches_extension() { - local path=$1 ext=$2 size=$3 sig prefix suffix width height - case "$ext" in - png) - [[ "$size" -ge 45 ]] || return 1 - sig=$(od -An -tx1 -N24 "$path" | tr -d '[:space:]') - suffix=$(tail -c 12 "$path" | od -An -tx1 | tr -d '[:space:]') - width=${sig:32:8}; height=${sig:40:8} - [[ "$sig" == 89504e470d0a1a0a0000000d49484452* && "$width" != 00000000 && "$height" != 00000000 && "$suffix" == 0000000049454e44ae426082 ]] - ;; - jpg|jpeg) - [[ "$size" -ge 12 ]] || return 1 - sig=$(od -An -tx1 -N3 "$path" | tr -d '[:space:]') - prefix=$(od -An -tx1 -N65536 "$path" | tr -d '[:space:]') - suffix=$(tail -c 2 "$path" | od -An -tx1 | tr -d '[:space:]') - [[ "$sig" == ffd8ff && ( "$prefix" == *ffc0* || "$prefix" == *ffc1* || "$prefix" == *ffc2* ) && "$suffix" == ffd9 ]] - ;; - gif) - [[ "$size" -ge 14 ]] || return 1 - sig=$(od -An -tx1 -N10 "$path" | tr -d '[:space:]') - suffix=$(tail -c 1 "$path" | od -An -tx1 | tr -d '[:space:]') - [[ ( "$sig" == 474946383761* || "$sig" == 474946383961* ) && "$sig" != 47494638376100000000 && "$sig" != 47494638396100000000 && "$suffix" == 3b ]] - ;; - webp) - [[ "$size" -ge 20 ]] || return 1 - sig=$(od -An -tx1 -N16 "$path" | tr -d '[:space:]') - [[ "$sig" == 52494646????????5745425056503820* || "$sig" == 52494646????????574542505650384c* || "$sig" == 52494646????????5745425056503858* ]] - ;; - svg) - [[ "$size" -ge 12 ]] || return 1 - prefix=$(dd if="$path" bs=4096 count=1 2>/dev/null) - grep -q '<svg\([[:space:]>]\)' <<< "$prefix" && grep -q '</svg[[:space:]]*>' "$path" - ;; - mp4|mov) - [[ "$size" -ge 20 ]] || return 1 - sig=$(od -An -tx1 -j4 -N4 "$path" | tr -d '[:space:]') - prefix=$(od -An -tx1 -N65536 "$path" | tr -d '[:space:]') - [[ "$sig" == 66747970 && ( "$prefix" == *6d6f6f76* || "$prefix" == *6d646174* ) ]] - ;; - webm) - [[ "$size" -ge 8 ]] || return 1 - sig=$(od -An -tx1 -N4 "$path" | tr -d '[:space:]') - prefix=$(od -An -tx1 -N4096 "$path" | tr -d '[:space:]') - [[ "$sig" == 1a45dfa3 && "$prefix" == *18538067* ]] - ;; - *) return 1 ;; - esac -} - -cmd=${1-} -[[ "$cmd" == publish ]] || { echo 'usage: publish.sh publish --expected-head <full-id> --body-file <path> [--image <path> --alt <text> | --video <path> --explanation <text>]...' >&2; exit 64; } -shift -while [[ $# -gt 0 ]]; do - case "$1" in - --expected-head|--body-file) - [[ $# -ge 2 && -n "$2" ]] || emit_refusal "$1 needs a value" - if [[ "$1" == --expected-head ]]; then expected=$2; else body_file=$2; fi - shift 2 - ;; - --image|--video) - kind=${1#--} - [[ $# -ge 4 && -n "$2" ]] || emit_refusal "$1 needs a path and presentation text" - path=$2 - if [[ "$kind" == image && "$3" == --alt ]]; then text_value=$4 - elif [[ "$kind" == video && "$3" == --explanation ]]; then text_value=$4 - else emit_refusal "$1 must be followed by $([[ "$kind" == image ]] && echo --alt || echo --explanation)" - fi - [[ "$text_value" =~ [^[:space:]] ]] || emit_refusal "$kind presentation text must be meaningful" - [[ "$text_value" != *$'\n'* && "$text_value" != *$'\r'* && "$text_value" != *$'\t'* ]] || emit_refusal "$kind presentation text must be one line" - attachment_paths+=("$path"); attachment_kinds+=("$kind"); attachment_texts+=("$text_value") - attachment_count=$((attachment_count + 1)); shift 4 - ;; - *) emit_refusal "unknown argument: $1" ;; - esac -done -[[ "$expected" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || emit_refusal '--expected-head requires a full commit ID' -[[ -n "$body_file" ]] || emit_refusal '--body-file is required' -[[ "$attachment_count" -le 50 ]] || emit_refusal 'at most 50 attachments are supported' -[[ "$(git rev-parse --is-inside-work-tree 2>/dev/null || true)" == true ]] || emit_refusal 'not inside a git work tree' -top=$(git rev-parse --show-toplevel) -head_before=$(git rev-parse HEAD) -[[ "$head_before" == "$expected" ]] || emit_refusal 'local HEAD differs from expected head' -branch=$(git symbolic-ref -q --short HEAD) || emit_refusal 'a feature branch is required' -repo_row=$(gh repo view --json nameWithOwner,url --jq '[.nameWithOwner,.url] | @tsv' 2>/dev/null) || emit_refusal 'cannot identify the repository' -IFS=$'\t' read -r repo repo_web extra <<< "$repo_row" -[[ "$repo" =~ ^[^/[:space:]]+/[^/[:space:]]+$ && "$repo_web" == https://github.com/* && -z "${extra-}" ]] || emit_refusal 'active repository is not on a supported GitHub attachment host' - -stage=pr-preflight -rows=$(gh pr list --repo "$repo" --head "$branch" --state open --limit 2 --json number,url,headRefOid,isCrossRepository --jq '.[] | [.number,.url,.headRefOid,.isCrossRepository] | @tsv' 2>/dev/null) || emit_refusal 'cannot observe open pull requests' -count=$(awk 'NF {n++} END {print n+0}' <<< "$rows") -[[ "$count" -eq 1 ]] || emit_refusal 'exactly one open PR is required' -IFS=$'\t' read -r pr_number pr_url observed_head cross extra <<< "$rows" -[[ "$pr_number" =~ ^[0-9]+$ && "$pr_url" == https://* && "$cross" == false && -z "${extra-}" ]] || emit_refusal 'malformed or cross-repository PR identity' -[[ "$observed_head" == "$expected" ]] || emit_refusal 'current PR head differs from expected head' - -stage=cli-preflight -help=$(gh pr comment --help 2>&1) || emit_refusal 'cannot inspect gh pr comment support' -grep -q -- '--attach' <<< "$help" || emit_refusal 'installed gh lacks pr comment --attach; install a supported GitHub CLI before retrying' -advertised=$(printf '%s\n' "$help" | tr '[:upper:]' '[:lower:]' | awk ' - /--attach/ {active=1} - active { - if ($0 !~ /--attach/ && $0 ~ /^[[:space:]]+--[a-z0-9-]+/) active=0 - if (!active) next - line=$0 - while (match(line, /(maximum|max|up to)[^0-9]*[0-9]+/)) { - v=substr(line,RSTART,RLENGTH); sub(/^[^0-9]*/, "", v) - if (minimum == "" || v+0 < minimum+0) minimum=v - line=substr(line,RSTART+RLENGTH) - } - if ($0 ~ /^[[:space:]]*$/) active=0 - } - END {if (minimum != "") print minimum}') -if [[ -n "$advertised" && "$advertised" -lt 50 ]]; then emit_refusal "installed gh advertises a stricter attachment limit: $advertised"; fi -gh api meta >/dev/null 2>&1 || emit_refusal 'active GitHub host does not confirm attachment publication support' - -stage=attachment-preflight -body_file=$(canonical_file "$body_file") || emit_refusal 'body file path cannot be resolved' -[[ -f "$body_file" && -r "$body_file" && -s "$body_file" ]] || emit_refusal 'body file must be readable and non-empty' -grep -q 'darrow-evidence-attachment-' "$body_file" && emit_refusal 'body file contains reserved attachment reference text' -grep -q 'github\.com/user-attachments/assets/' "$body_file" && emit_refusal 'body file contains an attachment URL that prevents deterministic reconciliation' -canonical_paths=(); snapshot_paths=(); attachment_hashes=(); attachment_ids=(); manifest=''; seen='' -snapshot_parent=${TMPDIR:-/tmp}; case "$snapshot_parent" in /) snapshot_template=/darrow-pr-evidence-files.XXXXXX ;; */) snapshot_template=${snapshot_parent}darrow-pr-evidence-files.XXXXXX ;; *) snapshot_template=$snapshot_parent/darrow-pr-evidence-files.XXXXXX ;; esac -snapshot_dir=$(mktemp -d "$snapshot_template") || emit_refusal 'cannot create attachment snapshot directory' -snapshot_dir=$(cd "$snapshot_dir" && pwd -P) || emit_refusal 'cannot resolve attachment snapshot directory' -case "$snapshot_dir" in "$top"/*) emit_refusal 'attachment snapshot location is inside the repository' ;; esac -i=0 -while [[ "$i" -lt "$attachment_count" ]]; do - p=$(canonical_file "${attachment_paths[$i]}") || emit_refusal "attachment path cannot be resolved: ${attachment_paths[$i]}" - [[ -f "$p" && -r "$p" && -s "$p" ]] || emit_refusal "attachment must be a readable non-empty regular file: $p" - case "$p" in "$top"/*) emit_refusal "evidence attachment must remain outside the repository: $p" ;; esac - size=$(wc -c < "$p" | tr -d ' ') - lower=$(printf '%s' "${p##*.}" | tr '[:upper:]' '[:lower:]') - case "$lower" in - png|jpg|jpeg|gif|webp|svg) actual=image; limit=10485760 ;; - mp4|mov|webm) actual=video; limit=104857600 ;; - *) emit_refusal "unsupported attachment type: $p" ;; - esac - [[ "$actual" == "${attachment_kinds[$i]}" ]] || emit_refusal "attachment kind does not match file type: $p" - [[ "$size" -le "$limit" ]] || emit_refusal "attachment exceeds supported size: $p" - media_matches_extension "$p" "$lower" "$size" || emit_refusal "attachment content does not match its supported type: $p" - content=$(git hash-object "$p") || emit_refusal "cannot hash attachment: $p" - case "$seen" in *"|$content|"*) emit_refusal "duplicate attachment content: $p" ;; esac - seen="$seen|$content|" - canonical_paths+=("$p") - attachment_hashes+=("$content") - presentation_hash=$(printf '%s' "${attachment_texts[$i]}" | git hash-object --stdin) - aid="$((i+1)):$actual:$content:$size:$presentation_hash" - attachment_ids+=("$aid") - manifest="$manifest"$'\n'"attachment-$((i+1))=$aid" - mkdir "$snapshot_dir/$((i+1))" || emit_refusal 'cannot create attachment snapshot slot' - snap="$snapshot_dir/darrow-evidence-attachment-$((i+1)).$lower" - cp "$p" "$snap" || emit_refusal "cannot snapshot attachment: $p" - [[ "$(git hash-object "$snap")" == "$content" ]] || emit_refusal "attachment changed while snapshotting: $p" - snapshot_paths+=("$snap") - i=$((i + 1)) -done - -body_snapshot="$snapshot_dir/body" -cp "$body_file" "$body_snapshot" || emit_refusal 'cannot snapshot body file' -body_hash=$(git hash-object "$body_snapshot") -[[ "$body_hash" == "$(git hash-object "$body_file")" ]] || emit_refusal 'body file changed while snapshotting' -candidate="$repo#$pr_number@$expected" -identity_input="repository=$repo -pr=$pr_number -head=$expected -body=$body_hash -attachment-count=$attachment_count$manifest" -identity=$(printf '%s' "$identity_input" | git hash-object --stdin) -marker="<!-- darrow-pr-evidence:v1 candidate=$candidate identity=$identity -->" -candidate_marker="darrow-pr-evidence:v1 candidate=$candidate" -temp_parent=${TMPDIR:-/tmp}; case "$temp_parent" in /) template=/darrow-pr-evidence.XXXXXX ;; */) template=${temp_parent}darrow-pr-evidence.XXXXXX ;; *) template=$temp_parent/darrow-pr-evidence.XXXXXX ;; esac -prepared=$(mktemp "$template") || emit_refusal 'cannot create prepared body' -prepared=$(canonical_file "$prepared") || emit_refusal 'cannot resolve prepared body path' -case "$prepared" in "$top"/*) emit_refusal 'temporary body location is inside the repository' ;; esac -{ - printf '%s\n\n' "$marker"; cat "$body_snapshot" - if [[ "$attachment_count" -gt 0 ]]; then - printf '\n\n### Attached evidence\n' - i=0 - while [[ "$i" -lt "$attachment_count" ]]; do - if [[ "${attachment_kinds[$i]}" == image ]]; then - printf -- '- Image %s alt text: %s\n' "$((i+1))" "${attachment_texts[$i]}" - printf -- '![%s; content identity %s](%s)\n' "${attachment_texts[$i]}" "${attachment_hashes[$i]}" "${snapshot_paths[$i]##*/}" - else - printf -- '- Video %s explanation: %s; content identity %s\n' "$((i+1))" "${attachment_texts[$i]}" "${attachment_hashes[$i]}" - printf -- '![](%s)\n' "${snapshot_paths[$i]##*/}" - fi - i=$((i + 1)) - done - fi - printf '\n<!-- darrow-pr-evidence-manifest\n%s\n-->' "$identity_input" -} > "$prepared" -prepared_escaped=$(awk '{gsub(/\\/, "\\\\"); gsub(/\r/, "\\r"); gsub(/\t/, "\\t"); if (NR>1) printf "\\n"; printf "%s",$0}' "$prepared") -expected_normalized=$(printf '%s\n' "$prepared_escaped" | awk '{s=$0; while (match(s,/darrow-evidence-attachment-[0-9]+\.(png|jpg|jpeg|gif|webp|svg|mp4|mov|webm)/)) {printf "%s__DARROW_ATTACHMENT_URL__",substr(s,1,RSTART-1); s=substr(s,RSTART+RLENGTH)} print s}') -prepared_hash=$(git hash-object "$prepared") -head_snapshot=$(git rev-parse HEAD); index_snapshot=$(git write-tree) - -observe_comments() { - gh api "repos/$repo/issues/$pr_number/comments" --paginate --jq '.[] | [.id,.html_url,.body] | @tsv' -} -classify() { - local data=$1 line body matches=0 complete=0 related=0 url='' links link_count normalized unique_seen link duplicate - observed_attachment_ids='' - while IFS= read -r line; do - [[ -n "$line" ]] || continue - body=${line#*$'\t'}; body=${body#*$'\t'} - if [[ "$body" == *"$candidate_marker"* ]]; then - related=$((related + 1)); url=$(printf '%s' "$line" | awk -F '\t' '{print $2}') - if [[ "$body" == *"$marker"* ]]; then - matches=$((matches + 1)) - links=$(awk '{s=$0; while (match(s,/https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._?=&%\/-]+/)) {print substr(s,RSTART,RLENGTH); s=substr(s,RSTART+RLENGTH)}}' <<< "$body") - link_count=$(awk 'NF {n++} END {print n+0}' <<< "$links") - normalized=$(printf '%s\n' "$body" | awk '{s=$0; while (match(s,/https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._?=&%\/-]+/)) {printf "%s__DARROW_ATTACHMENT_URL__",substr(s,1,RSTART-1); s=substr(s,RSTART+RLENGTH)} print s}') - unique_seen='|'; duplicate=false - while IFS= read -r link; do - [[ -n "$link" ]] || continue - case "$unique_seen" in *"|$link|"*) duplicate=true ;; *) unique_seen="$unique_seen$link|" ;; esac - done <<< "$links" - if [[ "$normalized" == "$expected_normalized" && "$link_count" -eq "$attachment_count" && "$duplicate" == false ]]; then - complete=$((complete + 1)); observed_attachment_ids=$links - fi - fi - fi - done <<< "$data" - if [[ "$complete" -eq 1 && "$matches" -eq 1 && "$related" -eq 1 ]]; then class=complete; comment_url=$url - elif [[ "$related" -gt 1 || "$matches" -eq 0 && "$related" -eq 1 ]]; then class=ambiguous; comment_url=$url - elif [[ "$matches" -eq 1 ]]; then class=partial; comment_url=$url - else class=none; comment_url=''; fi -} - -stage=reconciliation -comments=$(observe_comments 2>/dev/null) || emit_refusal 'cannot reconcile top-level PR comments' -classify "$comments" -emit_result() { - local result=$1 effects=$2 uncertainty=$3 files_preserved=true - i=0 - while [[ "$i" -lt "$attachment_count" ]]; do - [[ -f "${canonical_paths[$i]}" ]] || files_preserved=false - i=$((i + 1)) - done - printf '%s\n' "outcome: $result" "stage: $stage" "repository: $repo" "pr-url: $pr_url" "expected-head: $expected" "observed-head: $observed_head" "identity: $identity" "comment-url: ${comment_url:-unknown}" "attachment-count: $attachment_count" - i=0; while [[ "$i" -lt "$attachment_count" ]]; do printf 'attachment-%s: %s\n' "$((i+1))" "${attachment_ids[$i]}"; i=$((i+1)); done - i=0; while IFS= read -r observed_id; do [[ -n "$observed_id" ]] && { i=$((i+1)); printf 'observed-attachment-%s: %s\n' "$i" "$observed_id"; }; done <<< "${observed_attachment_ids-}" - printf '%s\n' "prepared-body: $prepared" "attachment-snapshot: $snapshot_dir" "effects: $effects" "uncertainty: $uncertainty" "head-preserved: $([[ "$(git rev-parse HEAD)" == "$head_snapshot" ]] && echo true || echo false)" "index-preserved: $([[ "$(git write-tree)" == "$index_snapshot" ]] && echo true || echo false)" "files-preserved: $files_preserved" -} -if [[ "$class" == complete ]]; then emit_result existing none none; exit 0 -elif [[ "$class" == partial ]]; then emit_result partial none 'one incomplete candidate-bound comment exists'; exit 5 -elif [[ "$class" == ambiguous ]]; then emit_result ambiguous none 'conflicting candidate-bound comments exist'; exit 5; fi - -stage=publication -[[ "$(git rev-parse HEAD)" == "$expected" ]] || { observed_head=$(git rev-parse HEAD); emit_result refused none 'local head changed before publication'; exit 4; } -[[ "$(git hash-object "$prepared")" == "$prepared_hash" ]] || { emit_result refused none 'prepared body changed before publication'; exit 4; } -i=0 -while [[ "$i" -lt "$attachment_count" ]]; do - [[ "$(git hash-object "${snapshot_paths[$i]}" 2>/dev/null || true)" == "${attachment_hashes[$i]}" ]] || { emit_result refused none 'attachment snapshot changed before publication'; exit 4; } - i=$((i + 1)) -done -args=(pr comment "$pr_url" --body-file "$prepared") -i=0 -while [[ "$i" -lt "$attachment_count" ]]; do - attach_arg=${snapshot_paths[$i]##*/} - if [[ "${attachment_kinds[$i]}" == image ]]; then attach_arg="$attach_arg#${attachment_texts[$i]}"; fi - args+=(--attach "$attach_arg") - i=$((i+1)) -done -set +e -comment_stdout="$snapshot_dir/comment-command.stdout" -comment_stderr="$snapshot_dir/comment-command.stderr" -(cd "$snapshot_dir" && DARROW_EVIDENCE_REPOSITORY=$top gh "${args[@]}") >"$comment_stdout" 2>"$comment_stderr" -comment_rc=$? -set -e -stage=post-publication -rows=$(gh pr list --repo "$repo" --head "$branch" --state open --limit 2 --json number,url,headRefOid,isCrossRepository --jq '.[] | [.number,.url,.headRefOid,.isCrossRepository] | @tsv' 2>/dev/null || true) -post_count=$(awk 'NF {n++} END {print n+0}' <<< "$rows") -IFS=$'\t' read -r post_number post_url observed_head post_cross post_extra <<< "$rows" -comments=$(observe_comments 2>/dev/null || true); classify "$comments" -post_ref=$(git symbolic-ref -q HEAD || true) -post_local=$(git rev-parse HEAD 2>/dev/null || true) -if [[ "$post_count" -ne 1 || "$post_number" != "$pr_number" || "$post_url" != "$pr_url" || "$post_cross" != false || -n "${post_extra-}" || "$observed_head" != "$expected" || "$post_ref" != "refs/heads/$branch" || "$post_local" != "$expected" ]]; then emit_result partial 'one gh pr comment invocation attempted' 'candidate head or PR identity changed'; exit 5 -elif [[ "$class" == complete ]]; then emit_result published 'one gh pr comment invocation completed' none; exit 0 -elif [[ "$class" == partial ]]; then emit_result partial 'one gh pr comment invocation attempted' 'incomplete candidate-bound comment observed'; exit 5 -else emit_result ambiguous 'one gh pr comment invocation attempted' "publication result unclassifiable (command exit $comment_rc)"; exit 5; fi +plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) +exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-publish-pr-evidence "$@" diff --git a/plugins/capability/darrow-git/tests/fresh-install.test.ps1 b/plugins/capability/darrow-git/tests/fresh-install.test.ps1 new file mode 100644 index 00000000..8d25f14e --- /dev/null +++ b/plugins/capability/darrow-git/tests/fresh-install.test.ps1 @@ -0,0 +1,4 @@ +$ErrorActionPreference = "Stop" +$pluginRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +& uv run --quiet --frozen --no-dev --project (Join-Path $pluginRoot "backend") python (Join-Path $pluginRoot "backend/tests/fresh_install.py") +if ($LASTEXITCODE -ne 0) { throw "Fresh Git plugin validation failed: $LASTEXITCODE" } diff --git a/plugins/capability/darrow-git/tests/fresh-install.test.sh b/plugins/capability/darrow-git/tests/fresh-install.test.sh new file mode 100644 index 00000000..b30113a5 --- /dev/null +++ b/plugins/capability/darrow-git/tests/fresh-install.test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" \ + python "$plugin_root/backend/tests/fresh_install.py" diff --git a/python-packages.txt b/python-packages.txt index 2e8c2a26..a231a35b 100644 --- a/python-packages.txt +++ b/python-packages.txt @@ -1,5 +1,6 @@ # Repository-relative Python package roots, one per line. plugins/capability/darrow-discovery/skills/plan-implementation/backend +plugins/capability/darrow-git/backend plugins/capability/darrow-observability-langfuse/backend plugins/capability/darrow-verification/skills/verify-change/backend plugins/foundation/darrow-skill-authoring/skills/author-agent-skill/backend From 74efa1c31be69b3d913b24381e87c966eed23edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= <bjoern@bjro.de> Date: Thu, 17 Sep 2026 20:25:45 +0200 Subject: [PATCH 04/10] fix(git): correct portable fixtures and ticket branch routing --- docs/specs/git-workflow.md | 5 ++ .../darrow-git/.claude-plugin/plugin.json | 2 +- .../darrow-git/.codex-plugin/plugin.json | 2 +- .../darrow-git/backend/pyproject.toml | 2 +- .../tests/test_templates_properties.py | 4 +- plugins/capability/darrow-git/backend/uv.lock | 2 +- .../darrow-git/skills/create-branch/SKILL.md | 2 +- .../create-commit/scripts/commit.test.sh | 56 ++++++++++--------- .../skills/prepare-task-branch/SKILL.md | 2 +- 9 files changed, 43 insertions(+), 34 deletions(-) diff --git a/docs/specs/git-workflow.md b/docs/specs/git-workflow.md index 84add036..283fda19 100644 --- a/docs/specs/git-workflow.md +++ b/docs/specs/git-workflow.md @@ -92,6 +92,11 @@ splitting one described change into multiple commits unless asked. ## create-branch +A request to create a branch from described ticket work belongs to +`create-branch` even when it supplies a canonical ticket token. That token does +not bind a complete branch name or turn creation into task-branch discovery; +derive the new name without asking the caller to supply one. + ### Intent triggers "create a branch", "branch for this", "start a branch", "new branch for X", diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index 378f0c70..adf549e2 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.6.0", + "version": "0.6.1", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 858752f0..9536563f 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.6.0", + "version": "0.6.1", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/backend/pyproject.toml b/plugins/capability/darrow-git/backend/pyproject.toml index 9fc4f622..9e718a39 100644 --- a/plugins/capability/darrow-git/backend/pyproject.toml +++ b/plugins/capability/darrow-git/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "darrow-git" -version = "0.6.0" +version = "0.6.1" description = "Contained Git and GitHub mechanics for Darrow" requires-python = ">=3.10,<3.14" dependencies = [] diff --git a/plugins/capability/darrow-git/backend/tests/test_templates_properties.py b/plugins/capability/darrow-git/backend/tests/test_templates_properties.py index 844a3453..075ad949 100644 --- a/plugins/capability/darrow-git/backend/tests/test_templates_properties.py +++ b/plugins/capability/darrow-git/backend/tests/test_templates_properties.py @@ -68,7 +68,9 @@ def test_template_fences_and_selection( directory = repository / ".github" / "PULL_REQUEST_TEMPLATE" directory.mkdir(parents=True) first = directory / "one.md" - first.write_text("\ufeff## Why\n```\n## Not a heading\n```\n## What\n") + first.write_text( + "\ufeff## Why\n```\n## Not a heading\n```\n## What\n", encoding="utf-8" + ) assert templates.select("") == first templates.validate( first, "## Why\n### Child\ncontent\n## What\n```\n<!-- literal -->\n```\n" diff --git a/plugins/capability/darrow-git/backend/uv.lock b/plugins/capability/darrow-git/backend/uv.lock index 7edb2a40..98003ef4 100644 --- a/plugins/capability/darrow-git/backend/uv.lock +++ b/plugins/capability/darrow-git/backend/uv.lock @@ -86,7 +86,7 @@ toml = [ [[package]] name = "darrow-git" -version = "0.6.0" +version = "0.6.1" source = { editable = "." } [package.dev-dependencies] diff --git a/plugins/capability/darrow-git/skills/create-branch/SKILL.md b/plugins/capability/darrow-git/skills/create-branch/SKILL.md index 12185570..3044d11b 100644 --- a/plugins/capability/darrow-git/skills/create-branch/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-branch/SKILL.md @@ -1,6 +1,6 @@ --- name: create-branch -description: Create exactly one new Git branch from a work description or an explicit valid branch name, in the current checkout or an explicitly requested worktree. Derive a conventional name when needed and preserve any exact ticket token. Use for additive branch creation only; do not use to switch to or reuse an existing branch, or to prepare an exact ticket branch whose complete name and token are already bound by an authorized delivery workflow. +description: Create one new Git branch from described work or an explicit valid name, including requests to create a branch for a ticket when only the ticket token and work are supplied. Derive the complete name and preserve the exact token; do not ask the caller to name it. Supports the current checkout or an explicitly requested worktree. Do not use for finding prior work, switching to or reusing an existing branch, or preparing an exact branch whose complete name and token are already bound by a delivery workflow. --- # Create a branch diff --git a/plugins/capability/darrow-git/skills/create-commit/scripts/commit.test.sh b/plugins/capability/darrow-git/skills/create-commit/scripts/commit.test.sh index 85e8ae45..2f8535cb 100755 --- a/plugins/capability/darrow-git/skills/create-commit/scripts/commit.test.sh +++ b/plugins/capability/darrow-git/skills/create-commit/scripts/commit.test.sh @@ -34,16 +34,16 @@ echo "# H1: large diff must not SIGPIPE (exit 141)" fresh_repo for i in $(seq 1 5000); do echo "padding line $i for a diff far beyond the truncation cutoff"; done > big.txt git add big.txt -bash "$SCRIPT" inspect > /dev/null 2>&1 +"$BASH" "$SCRIPT" inspect > /dev/null 2>&1 check "inspect exit 0 on huge staged diff" 0 $? echo change >> base.txt -bash "$SCRIPT" diff base.txt > /dev/null 2>&1 +"$BASH" "$SCRIPT" diff base.txt > /dev/null 2>&1 check "diff exit 0" 0 $? echo "# H2: staged set + extra paths rejected" fresh_repo echo a > a.txt && echo b > b.txt && git add a.txt -bash "$SCRIPT" commit -m "feat: a" b.txt > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "feat: a" b.txt > /dev/null 2>&1 check "exit 7" 7 $? check "b.txt not staged" "" "$(git diff --cached --name-only | grep b.txt || true)" @@ -58,10 +58,10 @@ echo outside > outside.txt mkdir -p .git/hooks printf '#!/bin/sh\necho "run: printf corrected > tracked.txt" >&2\ngit show :tracked.txt | grep -qx corrected\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -bash "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 check "hook failure preserves history" 2 "$(git rev-list --count HEAD)" echo corrected > tracked.txt -bash "$SCRIPT" retry --after-hook-failure --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" retry --after-hook-failure --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "authorized retry commits" 0 $? check "retry commits corrected content" "corrected" "$(git show HEAD:tracked.txt)" check "retry preserves other staged blob" "staged-preserved" "$(git show HEAD:preserved.txt)" @@ -74,7 +74,7 @@ echo staged > tracked.txt && git add tracked.txt echo outside > outside.txt before_index=$(git write-tree) before_head=$(git rev-parse HEAD) -bash "$SCRIPT" retry --after-hook-failure --refresh-staged outside.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" retry --after-hook-failure --refresh-staged outside.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "outside staged retry rejected" 7 $? check "outside refusal preserves index" "$before_index" "$(git write-tree)" check "outside refusal preserves history" "$before_head" "$(git rev-parse HEAD)" @@ -88,7 +88,7 @@ echo staged > src/staged.txt && git add src/staged.txt echo unrelated > src/untracked.txt before_index=$(git write-tree) before_head=$(git rev-parse HEAD) -bash "$SCRIPT" retry --after-hook-failure --refresh-staged src -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" retry --after-hook-failure --refresh-staged src -m "fix: correct tracked file" > /dev/null 2>&1 check "directory retry rejected" 7 $? check "directory refusal preserves index" "$before_index" "$(git write-tree)" check "directory refusal preserves history" "$before_head" "$(git rev-parse HEAD)" @@ -101,9 +101,9 @@ echo stale > tracked.txt && git add tracked.txt mkdir -p .git/hooks printf '#!/bin/sh\necho "run: printf corrected > tracked.txt" >&2\ngit show :tracked.txt | grep -qx corrected\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -bash "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 check "hook failure preserves remediation precondition" 2 "$(git rev-list --count HEAD)" -bash "$SCRIPT" remediate --after-hook-failure --command "printf corrected > tracked.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" remediate --after-hook-failure --command "printf corrected > tracked.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "unambiguous remediation commits" 0 $? check "remediation commits corrected content" "corrected" "$(git show HEAD:tracked.txt)" @@ -115,10 +115,10 @@ echo outside > outside.txt mkdir -p .git/hooks printf '#!/bin/sh\necho "run: git add outside.txt" >&2\nexit 1\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -bash "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 before_index=$(git write-tree) before_head=$(git rev-parse HEAD) -bash "$SCRIPT" remediate --after-hook-failure --command "git add outside.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" remediate --after-hook-failure --command "git add outside.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "index-mutating remediation rejected" 7 $? check "index-mutating remediation restores index" "$before_index" "$(git write-tree)" check "index-mutating remediation preserves history" "$before_head" "$(git rev-parse HEAD)" @@ -130,10 +130,10 @@ echo stale > tracked.txt && git add tracked.txt mkdir -p .git/hooks printf '#!/bin/sh\necho "run: printf corrected > tracked.txt" >&2\nexit 1\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -bash "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 before_index=$(git write-tree) before_head=$(git rev-parse HEAD) -bash "$SCRIPT" remediate --after-hook-failure --command "printf unrelated > tracked.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"$BASH" "$SCRIPT" remediate --after-hook-failure --command "printf unrelated > tracked.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "unreported remediation rejected" 7 $? check "unreported remediation preserves index" "$before_index" "$(git write-tree)" check "unreported remediation preserves history" "$before_head" "$(git rev-parse HEAD)" @@ -142,35 +142,37 @@ echo "# M1: sweep shortcuts rejected" fresh_repo echo x > x.txt for arg in "." ".." "*.txt" "-A" ":/"; do - bash "$SCRIPT" commit -m "feat: sweep" "$arg" > /dev/null 2>&1 + "$BASH" "$SCRIPT" commit -m "feat: sweep" "$arg" > /dev/null 2>&1 check "reject '$arg'" 7 $? done echo "# M2: human co-author accepted" fresh_repo echo x > x.txt && git add x.txt -bash "$SCRIPT" commit -m "feat: x" -m "Co-authored-by: Nicolai Parlog <nicolai@example.org>" > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "feat: x" -m "Co-authored-by: Nicolai Parlog <nicolai@example.org>" > /dev/null 2>&1 check "human trailer commits" 0 $? echo "# M3: attribution variants rejected" for msg in "Generated with Claude Code" "Generated by Claude" "Generated using Claude Code" "Built with Claude Code" "Assisted by Codex" "Written by claude" "Written by an AI" "Co-authored by Claude" "Co-authored-by: Claude <noreply@anthropic.com>"; do fresh_repo echo x > x.txt && git add x.txt - bash "$SCRIPT" commit -m "feat: x" -m "$msg" > /dev/null 2>&1 + "$BASH" "$SCRIPT" commit -m "feat: x" -m "$msg" > /dev/null 2>&1 check "reject '$msg'" 6 $? done echo "# M3c: attribution at the end of a huge message still caught" fresh_repo echo x > x.txt && git add x.txt -big=$(awk 'BEGIN{for(i=0;i<20000;i++) printf "word %d ab. ", i}') -bash "$SCRIPT" commit -m "feat: x" -m "$big" -m "Generated with Claude Code" > /dev/null 2>&1 -check "200KB message attribution rejected (no SIGPIPE bypass)" 6 $? +# Linux caps each argv element at 128 KiB. Keep each section below that limit +# while the complete message still exceeds 200 KiB and any pipe buffer. +big=$(awk 'BEGIN{for(i=0;i<7000;i++) printf "word %d ab. ", i}') +"$BASH" "$SCRIPT" commit -m "feat: x" -m "$big" -m "$big" -m "$big" -m "Generated with Claude Code" > /dev/null 2>&1 +check "200KB message across bounded arguments rejected (no SIGPIPE bypass)" 6 $? echo "# M3b: legitimate tool prose accepted" fresh_repo echo x > x.txt && git add x.txt -bash "$SCRIPT" commit -m "chore: regenerate api client" -m "The client is now generated by openapi-generator from the v2 spec." > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "chore: regenerate api client" -m "The client is now generated by openapi-generator from the v2 spec." > /dev/null 2>&1 check "generated-by non-AI tool commits" 0 $? echo "# M4: conflict state detected" @@ -180,16 +182,16 @@ echo side > base.txt && git commit -qam "feat: side" git checkout -q main echo main > base.txt && git commit -qam "feat: main" git merge side -q > /dev/null 2>&1 || true -out=$(bash "$SCRIPT" inspect) +out=$("$BASH" "$SCRIPT" inspect) check "inspect exit 0 in conflict" 0 $? echo "$out" | grep -q "mode: conflict" check "conflict marker present" 0 $? -bash "$SCRIPT" commit -m "feat: merge" > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "feat: merge" > /dev/null 2>&1 check "commit refused, exit 8" 8 $? echo "# L1: dangling -m explains itself" fresh_repo -err=$(bash "$SCRIPT" commit -m 2>&1 >/dev/null) +err=$("$BASH" "$SCRIPT" commit -m 2>&1 >/dev/null) status=$? check "exit 2" 2 $status echo "$err" | grep -q "needs a value" @@ -198,7 +200,7 @@ check "explanatory error" 0 $? echo "# L3: diff works for untracked files" fresh_repo echo new > brand-new.txt -out=$(bash "$SCRIPT" diff brand-new.txt) +out=$("$BASH" "$SCRIPT" diff brand-new.txt) check "exit 0" 0 $? echo "$out" | grep -q "brand-new" check "shows content as new-file diff" 0 $? @@ -206,16 +208,16 @@ check "shows content as new-file diff" 0 $? echo "# L5: subject rules apply to first line only" fresh_repo echo x > x.txt && git add x.txt -bash "$SCRIPT" commit -m "feat: x +"$BASH" "$SCRIPT" commit -m "feat: x this second line inside msgs[0] may be long and end with a period." > /dev/null 2>&1 check "multi-line msgs[0] with valid first line commits" 0 $? echo "# format guards still hold" fresh_repo echo x > x.txt && git add x.txt -bash "$SCRIPT" commit -m "updated stuff" > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "updated stuff" > /dev/null 2>&1 check "non-conventional subject rejected" 5 $? -bash "$SCRIPT" commit -m "feat: x." > /dev/null 2>&1 +"$BASH" "$SCRIPT" commit -m "feat: x." > /dev/null 2>&1 check "trailing period rejected" 5 $? echo diff --git a/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md b/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md index 56e4dcd3..29c87a39 100644 --- a/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md +++ b/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md @@ -1,6 +1,6 @@ --- name: prepare-task-branch -description: "Discover local task branches for an exact opaque ticket token, or prepare one caller-bound exact ticket branch. Use to find prior local work for a canonical token, or switch to, reuse, or create one exact branch in the current checkout or an explicitly requested worktree. Multiple candidate names require an exact caller choice. Do not use to derive branch names from described work or for general branch listing." +description: "Find prior local task branches for an exact ticket token, or prepare an already caller-bound complete branch name. Use for token-based discovery or switching to, reusing, or creating that exact name, optionally in an explicitly requested worktree. A request to create a new branch from ticket work without a complete branch name belongs to create-branch, even when a ticket token is supplied. Multiple discovery candidates require an exact caller choice. Do not use for general branch listing." --- # Prepare a task branch From 7743bdb61813a179a977f8181553480ae4ae4d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= <bjoern@bjro.de> Date: Thu, 17 Sep 2026 20:41:22 +0200 Subject: [PATCH 05/10] test(git): accept contextual correlated-branch refusals --- .../capability/darrow-git/.claude-plugin/plugin.json | 2 +- .../capability/darrow-git/.codex-plugin/plugin.json | 2 +- .../evals/guard-correlated-creation.yaml | 12 +++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index adf549e2..c6732a29 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.6.1", + "version": "0.6.2", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 9536563f..a0f161d2 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.6.1", + "version": "0.6.2", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/skills/prepare-task-branch/evals/guard-correlated-creation.yaml b/plugins/capability/darrow-git/skills/prepare-task-branch/evals/guard-correlated-creation.yaml index c54d5b50..b29416cb 100644 --- a/plugins/capability/darrow-git/skills/prepare-task-branch/evals/guard-correlated-creation.yaml +++ b/plugins/capability/darrow-git/skills/prepare-task-branch/evals/guard-correlated-creation.yaml @@ -40,8 +40,10 @@ checks: semantic_output_checks: - name: refuses duplicate creation and relays complete exact choices proposition: >- - The response refuses to create feat/issue-84-new-suffix because correlated - local branches already exist, identifies both docs/issue-84-notes and - fix/issue-84-original as the available candidates, and stops without - choosing a replacement branch. It does not treat fix/issue-840-similar - as a correlated candidate or claim to have prepared any branch. + The response reports that the requested preparation was refused because + correlated local branches already exist and identifies both + docs/issue-84-notes and fix/issue-84-original as the available candidates. + A contextual refusal with the helper's error is sufficient; it need not + repeat the requested branch name or explicitly narrate stopping. + It does not select a replacement, treat fix/issue-840-similar as a + correlated candidate, or claim to have prepared any branch. From 3d343bb0524c2f898fdc25a35297670a235a4252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= <bjoern@bjro.de> Date: Thu, 17 Sep 2026 20:49:41 +0200 Subject: [PATCH 06/10] fix(git): activate branch guidance for incomplete ticket requests --- plugins/capability/darrow-git/.claude-plugin/plugin.json | 2 +- plugins/capability/darrow-git/.codex-plugin/plugin.json | 2 +- plugins/capability/darrow-git/skills/create-branch/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index c6732a29..5836a4d8 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.6.2", + "version": "0.6.3", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index a0f161d2..7ba5192e 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.6.2", + "version": "0.6.3", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/skills/create-branch/SKILL.md b/plugins/capability/darrow-git/skills/create-branch/SKILL.md index 3044d11b..609fffa2 100644 --- a/plugins/capability/darrow-git/skills/create-branch/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-branch/SKILL.md @@ -1,6 +1,6 @@ --- name: create-branch -description: Create one new Git branch from described work or an explicit valid name, including requests to create a branch for a ticket when only the ticket token and work are supplied. Derive the complete name and preserve the exact token; do not ask the caller to name it. Supports the current checkout or an explicitly requested worktree. Do not use for finding prior work, switching to or reusing an existing branch, or preparing an exact branch whose complete name and token are already bound by a delivery workflow. +description: Create one new Git branch from described work or an explicit valid name, including requests to create a branch for a ticket when only the ticket token and work are supplied. Also use for incomplete ticket-linked branch requests whose canonical token is missing. Derive the complete name when needed and preserve the exact token. Supports the current checkout or an explicitly requested worktree. Do not use for finding prior work, switching to or reusing an existing branch, or preparing an exact branch whose complete name and token are already bound by a delivery workflow. --- # Create a branch From 08bf720f0098ce09aa007187d5821e1af21bfb91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= <bjoern@bjro.de> Date: Thu, 17 Sep 2026 21:08:06 +0200 Subject: [PATCH 07/10] refactor(git): narrow the ticket-routing clarification --- plugins/capability/darrow-git/.claude-plugin/plugin.json | 2 +- plugins/capability/darrow-git/.codex-plugin/plugin.json | 2 +- plugins/capability/darrow-git/skills/create-branch/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index 5836a4d8..b4b1f293 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.6.3", + "version": "0.6.4", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 7ba5192e..6402633e 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.6.3", + "version": "0.6.4", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/skills/create-branch/SKILL.md b/plugins/capability/darrow-git/skills/create-branch/SKILL.md index 609fffa2..12185570 100644 --- a/plugins/capability/darrow-git/skills/create-branch/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-branch/SKILL.md @@ -1,6 +1,6 @@ --- name: create-branch -description: Create one new Git branch from described work or an explicit valid name, including requests to create a branch for a ticket when only the ticket token and work are supplied. Also use for incomplete ticket-linked branch requests whose canonical token is missing. Derive the complete name when needed and preserve the exact token. Supports the current checkout or an explicitly requested worktree. Do not use for finding prior work, switching to or reusing an existing branch, or preparing an exact branch whose complete name and token are already bound by a delivery workflow. +description: Create exactly one new Git branch from a work description or an explicit valid branch name, in the current checkout or an explicitly requested worktree. Derive a conventional name when needed and preserve any exact ticket token. Use for additive branch creation only; do not use to switch to or reuse an existing branch, or to prepare an exact ticket branch whose complete name and token are already bound by an authorized delivery workflow. --- # Create a branch From c40dca01ada3cb4ebfaadd8f565f3a70e59d2ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= <bjoern@bjro.de> Date: Thu, 17 Sep 2026 21:30:43 +0200 Subject: [PATCH 08/10] fix(git): route Claude requests before workflow preflight --- docs/specs/git-workflow.md | 12 +++++++ evals/runner/fixture.test.ts | 7 ++++ evals/runner/fixture.ts | 10 ++++-- .../darrow-git/.claude-plugin/hooks.json | 24 ++++++++++++++ .../darrow-git/.claude-plugin/plugin.json | 3 +- .../darrow-git/.codex-plugin/plugin.json | 2 +- plugins/capability/darrow-git/README.md | 5 +++ .../darrow-git/backend/pyproject.toml | 3 +- .../backend/src/darrow_git/claude_context.py | 20 ++++++++++++ .../backend/src/darrow_git/claude_routing.md | 15 +++++++++ .../darrow-git/backend/tests/fresh_install.py | 14 ++++++++ .../backend/tests/test_claude_context.py | 32 +++++++++++++++++++ plugins/capability/darrow-git/backend/uv.lock | 2 +- 13 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 plugins/capability/darrow-git/.claude-plugin/hooks.json create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/claude_context.py create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/claude_routing.md create mode 100644 plugins/capability/darrow-git/backend/tests/test_claude_context.py diff --git a/docs/specs/git-workflow.md b/docs/specs/git-workflow.md index 283fda19..3e6c09bb 100644 --- a/docs/specs/git-workflow.md +++ b/docs/specs/git-workflow.md @@ -10,6 +10,18 @@ Plugin: `darrow-git`. Skills: `create-commit` (M0), `create-branch`, GW-B6 applies only when the caller explicitly asks the `create-branch` capability to allocate an additional linked worktree. +## Native discovery + +- **GW-A1 — Route before preflight.** A matching Git workflow request selects + its skill before repository inspection or a terminal response. Missing input, + a clean tree, conflicts, and hook failures remain within the owning skill's + scope. General inspection and unrelated comments do not select a mutation + skill. Claude receives a plugin-local SessionStart routing reminder through + its native context hook. The hook supplies static context only: no provider + calls, prompt classification, workflow execution, state, or authority grants. + Native skill discovery and the selected skill retain contextual decisions; + installation never starts an operation or chains capabilities. + ## Executable mechanics - **GW-M1 — Contained portable implementation.** The five capabilities share diff --git a/evals/runner/fixture.test.ts b/evals/runner/fixture.test.ts index a78a6253..3c732af6 100644 --- a/evals/runner/fixture.test.ts +++ b/evals/runner/fixture.test.ts @@ -248,6 +248,10 @@ describe("eval fixture skill mounts", () => { await writeFile(join(backend, "pyproject.toml"), "[project]\n"); await writeFile(join(backend, ".venv", "generated"), "private\n"); await writeFile(join(backend, "evals", "secret.yaml"), "hidden\n"); + await writeFile( + join(plugin, ".claude-plugin", "hooks.json"), + '{"hooks":{"SessionStart":[]}}\n', + ); for (const manifest of [".claude-plugin", ".codex-plugin"]) { await writeFile( join(plugin, manifest, "plugin.json"), @@ -271,6 +275,9 @@ describe("eval fixture skill mounts", () => { expect(existsSync(join(destination, "pyproject.toml"))).toBe(true); expect(existsSync(join(destination, ".venv"))).toBe(false); expect(existsSync(join(destination, "evals"))).toBe(false); + expect( + existsSync(join(dirname(destination), ".claude-plugin", "hooks.json")), + ).toBe(host !== "project"); }, ); diff --git a/evals/runner/fixture.ts b/evals/runner/fixture.ts index 2c2719c8..df58f7e6 100644 --- a/evals/runner/fixture.ts +++ b/evals/runner/fixture.ts @@ -278,7 +278,10 @@ async function mountSourceClaudePlugin( evalPlugin = join(repoDir, ".git", "eval-plugin"), ): Promise<void> { await mkdir(join(evalPlugin, ".claude-plugin"), { recursive: true }); - await cp(paths.manifest, join(evalPlugin, ".claude-plugin", "plugin.json")); + await copySkillWithoutEvals( + dirname(paths.manifest), + join(evalPlugin, ".claude-plugin"), + ); for (const mountedSkillDir of skillDirs) { const name = mountedSkillDir.split("/").filter(Boolean).pop()!; await copySkillWithoutEvals( @@ -305,7 +308,10 @@ async function mountSourceCodexPlugin( ): Promise<string> { await mkdir(join(plugin, ".claude-plugin"), { recursive: true }); await mkdir(join(plugin, ".codex-plugin"), { recursive: true }); - await cp(paths.manifest, join(plugin, ".claude-plugin", "plugin.json")); + await copySkillWithoutEvals( + dirname(paths.manifest), + join(plugin, ".claude-plugin"), + ); await cp(paths.codexManifest, join(plugin, ".codex-plugin", "plugin.json")); for (const mountedSkillDir of skillDirs) { const name = mountedSkillDir.split("/").filter(Boolean).pop()!; diff --git a/plugins/capability/darrow-git/.claude-plugin/hooks.json b/plugins/capability/darrow-git/.claude-plugin/hooks.json new file mode 100644 index 00000000..1f263c49 --- /dev/null +++ b/plugins/capability/darrow-git/.claude-plugin/hooks.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "uv", + "args": [ + "run", + "--quiet", + "--frozen", + "--no-dev", + "--project", + "${CLAUDE_PLUGIN_ROOT}/backend", + "darrow-git-claude-context" + ], + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index b4b1f293..2a89dec9 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,6 +1,7 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.6.4", + "version": "0.7.0", + "hooks": "./.claude-plugin/hooks.json", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 6402633e..50da1eed 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.6.4", + "version": "0.7.0", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/README.md b/plugins/capability/darrow-git/README.md index af886e55..98302f7c 100644 --- a/plugins/capability/darrow-git/README.md +++ b/plugins/capability/darrow-git/README.md @@ -9,6 +9,11 @@ publication, and candidate-bound reviewer evidence. Each workflow is independently intent-triggered. Installing the plugin does not run Git commands automatically. +Claude Code receives a static SessionStart reminder to select the matching +installed skill before preflight, including clean trees, missing inputs, and +refusals. This native context hook runs no Git or GitHub commands, classifies no +prompts, and grants no authority. It is declared only in the Claude manifest. + ## What it provides ### `create-branch` diff --git a/plugins/capability/darrow-git/backend/pyproject.toml b/plugins/capability/darrow-git/backend/pyproject.toml index 9e718a39..0095fd9f 100644 --- a/plugins/capability/darrow-git/backend/pyproject.toml +++ b/plugins/capability/darrow-git/backend/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "darrow-git" -version = "0.6.1" +version = "0.7.0" description = "Contained Git and GitHub mechanics for Darrow" requires-python = ">=3.10,<3.14" dependencies = [] [project.scripts] +darrow-git-claude-context = "darrow_git.claude_context:main" darrow-create-branch = "darrow_git.cli:create_branch" darrow-prepare-task-branch = "darrow_git.cli:prepare_branch" darrow-create-commit = "darrow_git.cli:create_commit" diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/claude_context.py b/plugins/capability/darrow-git/backend/src/darrow_git/claude_context.py new file mode 100644 index 00000000..d688ed03 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/claude_context.py @@ -0,0 +1,20 @@ +"""Supply static native discovery context without inspecting a repository.""" + +import json +from importlib.resources import files + + +def main() -> None: + context = ( + files("darrow_git").joinpath("claude_routing.md").read_text(encoding="utf-8") + ) + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": context, + } + } + ) + ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/claude_routing.md b/plugins/capability/darrow-git/backend/src/darrow_git/claude_routing.md new file mode 100644 index 00000000..35318c74 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/claude_routing.md @@ -0,0 +1,15 @@ +The installed Darrow Git skills own requests to create or prepare branches, +create commits, publish pull requests, and publish candidate-bound PR evidence. +For a matching request, invoke the matching installed skill before initial +repository inspection or your final response. Select from the installed skill +descriptions; its body supplies the workflow and authority boundaries. + +Route by the user's requested operation, before deciding whether it can succeed. +Missing information, existing branches, a clean tree, conflicts, and commit-hook +failures are outcomes for the owning skill to handle. They do not remove the +request from that skill's scope. A request to commit changes still needs the +commit skill when the user mentions compact history or the previous commit. + +Ordinary inspection, branch or PR listing, staged-diff explanation, and generic +comments do not by themselves request these workflows. Loading this context +does not authorize an operation or select another skill automatically. diff --git a/plugins/capability/darrow-git/backend/tests/fresh_install.py b/plugins/capability/darrow-git/backend/tests/fresh_install.py index 49fb4bdb..6585c0ca 100644 --- a/plugins/capability/darrow-git/backend/tests/fresh_install.py +++ b/plugins/capability/darrow-git/backend/tests/fresh_install.py @@ -1,5 +1,6 @@ """Fresh copied-artifact validation on native Linux, macOS, and Windows.""" +import json import os import shutil import subprocess @@ -46,9 +47,22 @@ def repository(path: Path, remote: Path) -> None: command(path, "git", "remote", "set-head", "origin", "main") +def validate_context(copy: Path, fixture: Path) -> None: + config = json.loads((copy / ".claude-plugin" / "hooks.json").read_text()) + hook = config["hooks"]["SessionStart"][0]["hooks"][0] + arguments = [ + arg.replace("${CLAUDE_PLUGIN_ROOT}", str(copy)) for arg in hook["args"] + ] + output = json.loads(command(fixture, hook["command"], *arguments)) + context = output["hookSpecificOutput"] + assert context["hookEventName"] == "SessionStart" + assert context["additionalContext"].strip() + + def validate(copy: Path, fixture: Path) -> None: backend = copy / "backend" command(fixture, "uv", "sync", "--frozen", "--no-dev", "--project", str(backend)) + validate_context(copy, fixture) tree = command( fixture, "uv", "tree", "--frozen", "--no-dev", "--project", str(backend) ) diff --git a/plugins/capability/darrow-git/backend/tests/test_claude_context.py b/plugins/capability/darrow-git/backend/tests/test_claude_context.py new file mode 100644 index 00000000..99775fd7 --- /dev/null +++ b/plugins/capability/darrow-git/backend/tests/test_claude_context.py @@ -0,0 +1,32 @@ +"""The native context boundary carries guidance without repository effects.""" + +import json +import subprocess +from pathlib import Path + +import pytest + + +def test_session_context_is_nonmutating( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from darrow_git.claude_context import main + + def unexpected_provider(*args: object, **kwargs: object) -> None: + pytest.fail("context hook attempted a subprocess") + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(subprocess, "run", unexpected_provider) + main() + output = capsys.readouterr() + payload = json.loads(output.out) + assert set(payload) == {"hookSpecificOutput"} + context = payload["hookSpecificOutput"] + assert set(context) == {"hookEventName", "additionalContext"} + assert context["hookEventName"] == "SessionStart" + assert isinstance(context["additionalContext"], str) + assert context["additionalContext"].strip() + assert not output.err + assert list(tmp_path.iterdir()) == [] diff --git a/plugins/capability/darrow-git/backend/uv.lock b/plugins/capability/darrow-git/backend/uv.lock index 98003ef4..daddfde7 100644 --- a/plugins/capability/darrow-git/backend/uv.lock +++ b/plugins/capability/darrow-git/backend/uv.lock @@ -86,7 +86,7 @@ toml = [ [[package]] name = "darrow-git" -version = "0.6.1" +version = "0.7.0" source = { editable = "." } [package.dev-dependencies] From 6f5e84b67156c1537c94d7f770b487aba2e97953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= <bjoern@bjro.de> Date: Fri, 18 Sep 2026 07:36:23 +0200 Subject: [PATCH 09/10] refactor(git)!: remove legacy shell entrypoints BREAKING CHANGE: Call the frozen UV entrypoints directly; legacy shell paths are removed. Retain regression coverage against the Python commands and update composition fixtures to observe those commands. --- .github/workflows/python-quality.yml | 4 +- docs/specs/git-workflow.md | 8 +- evals/runner/composition-fixture.test.ts | 54 +++- evals/runner/goal-real-commit-fixture.test.ts | 24 +- .../darrow-git/.claude-plugin/plugin.json | 2 +- .../darrow-git/.codex-plugin/plugin.json | 2 +- plugins/capability/darrow-git/README.md | 15 +- .../darrow-git/backend/pyproject.toml | 2 +- .../backend/src/darrow_git/branch.py | 3 +- .../backend/src/darrow_git/branch_options.py | 2 +- .../backend/src/darrow_git/commit.py | 2 +- .../backend/src/darrow_git/evidence.py | 2 +- .../backend/src/darrow_git/evidence_files.py | 2 +- .../darrow-git/backend/src/darrow_git/pr.py | 2 +- .../tests/shell/create-branch-branch.test.sh} | 151 +++++------ .../tests/shell/create-commit-commit.test.sh} | 57 ++-- .../tests/shell/create-pr-pr.test.sh} | 251 +++++++++--------- .../shell/create-pr-publication.test.sh} | 25 +- .../shell/prepare-task-branch-branch.test.sh} | 46 ++-- .../prepare-task-branch-discovery.test.sh} | 32 +-- .../publish-pr-evidence-publish.test.sh} | 23 +- .../backend/tests/test_cli_media.py | 21 +- plugins/capability/darrow-git/backend/uv.lock | 2 +- .../darrow-git/skills/create-branch/SKILL.md | 4 +- .../skills/create-branch/scripts/branch.sh | 5 - .../darrow-git/skills/create-commit/SKILL.md | 4 +- .../skills/create-commit/scripts/commit.sh | 5 - .../darrow-git/skills/create-pr/SKILL.md | 4 +- .../darrow-git/skills/create-pr/scripts/pr.sh | 5 - .../skills/create-pr/scripts/publication.sh | 5 - .../skills/prepare-task-branch/SKILL.md | 4 +- .../prepare-task-branch/scripts/branch.sh | 5 - .../skills/publish-pr-evidence/SKILL.md | 4 +- .../publish-pr-evidence/scripts/publish.sh | 5 - .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .../evals/fixtures/composition-bash | 22 -- .../evals/fixtures/composition-uv | 22 ++ .../evals/fixtures/setup-composition.sh | 5 +- 39 files changed, 431 insertions(+), 404 deletions(-) rename plugins/capability/darrow-git/{skills/create-branch/scripts/branch.test.sh => backend/tests/shell/create-branch-branch.test.sh} (80%) rename plugins/capability/darrow-git/{skills/create-commit/scripts/commit.test.sh => backend/tests/shell/create-commit-commit.test.sh} (75%) rename plugins/capability/darrow-git/{skills/create-pr/scripts/pr.test.sh => backend/tests/shell/create-pr-pr.test.sh} (75%) rename plugins/capability/darrow-git/{skills/create-pr/scripts/publication.test.sh => backend/tests/shell/create-pr-publication.test.sh} (85%) rename plugins/capability/darrow-git/{skills/prepare-task-branch/scripts/branch.test.sh => backend/tests/shell/prepare-task-branch-branch.test.sh} (85%) rename plugins/capability/darrow-git/{skills/prepare-task-branch/scripts/discovery.test.sh => backend/tests/shell/prepare-task-branch-discovery.test.sh} (77%) rename plugins/capability/darrow-git/{skills/publish-pr-evidence/scripts/publish.test.sh => backend/tests/shell/publish-pr-evidence-publish.test.sh} (89%) delete mode 100644 plugins/capability/darrow-git/skills/create-branch/scripts/branch.sh delete mode 100755 plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh delete mode 100755 plugins/capability/darrow-git/skills/create-pr/scripts/pr.sh delete mode 100644 plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh delete mode 100755 plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.sh delete mode 100644 plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.sh delete mode 100644 plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/composition-bash create mode 100644 plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/composition-uv diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index 3a04294e..03760b47 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -186,12 +186,12 @@ jobs: - uses: astral-sh/setup-uv@v6 - if: runner.os != 'Windows' run: bash plugins/capability/darrow-git/tests/fresh-install.test.sh - - name: Verify POSIX compatibility launchers + - name: Verify Git CLI regression scenarios if: runner.os != 'Windows' shell: bash run: | /bin/bash --version - for test in plugins/capability/darrow-git/skills/*/scripts/*.test.sh; do + for test in plugins/capability/darrow-git/backend/tests/shell/*.test.sh; do /bin/bash "$test" done - if: runner.os == 'Windows' diff --git a/docs/specs/git-workflow.md b/docs/specs/git-workflow.md index 3e6c09bb..83408d06 100644 --- a/docs/specs/git-workflow.md +++ b/docs/specs/git-workflow.md @@ -27,10 +27,10 @@ capability to allocate an additional linked worktree. - **GW-M1 — Contained portable implementation.** The five capabilities share one Python package contained in `darrow-git/backend`, with frozen UV runtime entrypoints and no runtime dependency on another plugin. Git and GitHub CLI - remain the provider boundaries. Existing script paths, command arguments, - stable records, refusal statuses, and mutation safeguards remain compatible. - POSIX scripts are dispatch-only compatibility launchers; native Windows uses - the same Python entrypoints without requiring a Unix shell. + remain the provider boundaries. Frozen UV console commands are the runtime + interface on every platform; legacy shell-script paths are not retained. + Command arguments, stable records, refusal statuses, and mutation safeguards + remain intact. No workflow requires a Bash launcher. - **GW-M2 — Literal process and filesystem boundaries.** Provider commands use argument vectors, never interpolated shell programs. Paths and temporary artifacts use native filesystem APIs. Hook remediation retains its diagnostic diff --git a/evals/runner/composition-fixture.test.ts b/evals/runner/composition-fixture.test.ts index d3788849..eb70613f 100644 --- a/evals/runner/composition-fixture.test.ts +++ b/evals/runner/composition-fixture.test.ts @@ -1,11 +1,63 @@ import { expect, test } from "bun:test"; import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { parse } from "yaml"; import { runChecks } from "./checks"; import { buildFixture, destroyFixture } from "./fixture"; import type { EvalCase } from "./types"; +test("composition records only successful UV publication", async () => { + const path = resolve( + import.meta.dir, + "../../plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/composition-existing-pr.yaml", + ); + const entry = parse(await readFile(path, "utf8")) as EvalCase; + const repo = await buildFixture({ + fixture: entry.fixture, + caseDir: dirname(path), + skillDir: resolve( + import.meta.dir, + "../../plugins/capability/darrow-git/skills/create-pr", + ), + skillMounts: [], + sourceClaudePlugin: true, + }); + const command = + "uv run --quiet --frozen --no-dev --project .git/eval-plugin/backend darrow-create-pr"; + try { + const results = await runChecks(repo, [ + { + name: "inspection is not publication", + run: `${command} inspect && test ! -e .git/builtin-publications`, + }, + { + name: "stale remote refuses verification", + run: `${command} verify --expected-head "$(git rev-parse HEAD)"`, + exit_code: 4, + }, + { + name: "failure is not evidence", + run: "test ! -e .git/builtin-publications", + }, + { + name: "publish current commit", + run: `${command} publish-existing --expected-head "$(git rev-parse HEAD)"`, + }, + { + name: "verify current commit", + run: `${command} verify --expected-head "$(git rev-parse HEAD)"`, + }, + { + name: "exact successful observations", + run: 'test "$(wc -l < .git/builtin-publications | tr -d " ")" = 2 && test "$(sort -u .git/builtin-publications)" = "$(git rev-parse HEAD)" && test ! -e .git/forbidden-forge-effects', + }, + ]); + expect(results.filter((result) => !result.passed)).toEqual([]); + } finally { + await destroyFixture(repo); + } +}, 30000); + test("ticket-to-pr options oracle preserves a stricter caller repair limit", async () => { const path = resolve( import.meta.dir, diff --git a/evals/runner/goal-real-commit-fixture.test.ts b/evals/runner/goal-real-commit-fixture.test.ts index 2fad8e5e..58ff81f4 100644 --- a/evals/runner/goal-real-commit-fixture.test.ts +++ b/evals/runner/goal-real-commit-fixture.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { parse } from "yaml"; import { runChecks } from "./checks"; import { buildFixture, destroyFixture } from "./fixture"; @@ -9,8 +9,8 @@ const source = new URL( "../../plugins/orchestration/darrow-adaptive-delivery/skills/adaptive-delivery/evals/real-create-commit-composition.yaml", import.meta.url, ); -const commitSource = new URL( - "../../plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh", +const commitSkill = new URL( + "../../plugins/capability/darrow-git/skills/create-commit", import.meta.url, ); const notes = "# Notes\nReal commit capability composes.\n"; @@ -19,26 +19,23 @@ const reviewCheck = "review clears the final uncommitted candidate before commit"; async function publish(repo: string) { - await writeFile( - `${repo}/.git/real-commit.sh`, - await Bun.file(commitSource).text(), - { mode: 0o755 }, - ); + const commit = + "uv run --quiet --frozen --no-dev --project .git/eval-plugin/backend darrow-create-commit"; await Bun.write(`${repo}/NOTES.md`, notes); const checks = await runChecks(repo, [ { name: "current check", run: "bash check.sh" }, { name: "actual review", run: "independent-review-protocol" }, { name: "real helper records hook failure", - run: "bash .git/real-commit.sh commit -m 'docs: add real-commit note' NOTES.md", + run: `${commit} commit -m 'docs: add real-commit note' NOTES.md`, exit_code: 4, }, ]); - expect(checks.every((result) => result.passed)).toBe(true); + expect(checks.filter((result) => !result.passed)).toEqual([]); const remediation = await runChecks(repo, [ { name: "real helper executes the authorized guarded remediation", - run: `bash .git/real-commit.sh remediate --after-hook-failure --command ${quote("grep -Fx 'Real commit capability composes.' NOTES.md >/dev/null")} --refresh-staged NOTES.md -m 'docs: add real-commit note'`, + run: `${commit} remediate --after-hook-failure --command ${quote("grep -Fx 'Real commit capability composes.' NOTES.md >/dev/null")} --refresh-staged NOTES.md -m 'docs: add real-commit note'`, }, ]); expect(remediation[0]?.passed).toBe(true); @@ -62,8 +59,9 @@ for (const scenario of [ const entry = parse(await Bun.file(source).text()) as EvalCase; const repo = await buildFixture({ fixture: entry.fixture, - skillDir: "", + skillDir: fileURLToPath(commitSkill), skillMounts: [], + sourceClaudePlugin: true, }); try { if (scenario === "help") { @@ -104,7 +102,7 @@ for (const scenario of [ } finally { await destroyFixture(repo); } - }); + }, 30000); } for (const [arg, exitCode] of [ diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index 2a89dec9..3148b4b6 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.7.0", + "version": "0.8.0", "hooks": "./.claude-plugin/hooks.json", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 50da1eed..1fcdd8a8 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.7.0", + "version": "0.8.0", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/README.md b/plugins/capability/darrow-git/README.md index 98302f7c..d9959e18 100644 --- a/plugins/capability/darrow-git/README.md +++ b/plugins/capability/darrow-git/README.md @@ -2,7 +2,7 @@ This plugin turns common Git publication requests into small, explicit workflows. The agent still decides what the user's change means, while bundled -scripts enforce the mechanical boundaries that are easy to get subtly wrong: +Python commands enforce the mechanical boundaries that are easy to get subtly wrong: branch naming, staging, commit shape, duplicate pull requests, non-force publication, and candidate-bound reviewer evidence. @@ -83,15 +83,13 @@ remote state stops without retry or cleanup. Example: _“Attach these verification screenshots to the current PR.”_ -### Bundled workflow scripts +### Bundled workflow commands Each skill uses a frozen UV entrypoint from the contained Python package in `backend/`. The helpers inspect and validate repository state, perform only the authorized Git or GitHub operation, and return structured evidence for the skill to interpret. They are implementation details of the workflows rather than a -general Git wrapper. Existing Bash script paths remain thin compatibility -launchers with the same commands, records, and exit statuses. Native Windows -calls the UV entrypoints directly. +general Git wrapper. All supported platforms call the UV entrypoints directly. ## Design boundaries @@ -111,8 +109,7 @@ workflows to rewrite history, merge, release, deploy, or post generic comments. ## Hosts and prerequisites Codex and Claude Code on Linux, macOS, and Windows; Git, UV, and Python -3.10–3.13 (UV can provision Python). Bash is needed only for POSIX compatibility -launchers. PR work also requires +3.10–3.13 (UV can provision Python). PR work also requires authenticated GitHub CLI access and a usable remote. Attachment publication requires a GitHub host and a `gh pr comment` implementation that advertises `--attach`. @@ -136,8 +133,8 @@ has no runtime dependencies. Fresh-install checks copy the whole plugin and exercise all five workflows with real local Git repositories and mocked GitHub boundaries, using `tests/fresh-install.test.sh` on POSIX and `tests/fresh-install.test.ps1` in native PowerShell. CI covers Python 3.10–3.13 -on Linux, macOS, and Windows. Existing shell regressions continue to exercise -the compatibility launchers. +on Linux, macOS, and Windows. Additional POSIX regression scenarios under +`backend/tests/shell/` invoke the same UV entrypoints directly. ## Installation diff --git a/plugins/capability/darrow-git/backend/pyproject.toml b/plugins/capability/darrow-git/backend/pyproject.toml index 0095fd9f..502795e4 100644 --- a/plugins/capability/darrow-git/backend/pyproject.toml +++ b/plugins/capability/darrow-git/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "darrow-git" -version = "0.7.0" +version = "0.8.0" description = "Contained Git and GitHub mechanics for Darrow" requires-python = ">=3.10,<3.14" dependencies = [] diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/branch.py b/plugins/capability/darrow-git/backend/src/darrow_git/branch.py index d5adfaef..52e734d8 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/branch.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/branch.py @@ -164,7 +164,8 @@ def run(args: list[str], *, task: bool = False) -> None: ) else: raise RefusalError( - "usage: branch.sh " + "usage: " + + ("darrow-prepare-task-branch " if task else "darrow-create-branch ") + ( "discover --ticket-token <opaque-token> | inspect | prepare <name> --ticket-token <opaque-token>" if task diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py index 12453992..45f5ef5a 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py @@ -1,4 +1,4 @@ -"""Parse the compatibility branch command arguments without changing refusals.""" +"""Parse branch command arguments without changing refusals.""" from dataclasses import dataclass diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/commit.py b/plugins/capability/darrow-git/backend/src/darrow_git/commit.py index 170e621f..e63508bf 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/commit.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/commit.py @@ -161,6 +161,6 @@ def run(args: list[str]) -> None: operations[operation](parse(args[1:], operation)) else: raise RefusalError( - "usage: commit.sh inspect | diff <path>... | commit [-m <msg>]... [<path>]... | retry --after-hook-failure --refresh-staged <path>... -m <msg> | remediate --after-hook-failure --command <command> --refresh-staged <path>... -m <msg>", + "usage: darrow-create-commit inspect | diff <path>... | commit [-m <msg>]... [<path>]... | retry --after-hook-failure --refresh-staged <path>... -m <msg> | remediate --after-hook-failure --command <command> --refresh-staged <path>... -m <msg>", 64, ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence.py index f092d4c5..65b6db38 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/evidence.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence.py @@ -212,7 +212,7 @@ def complete(state: Evidence, returncode: int) -> None: def run(args: list[str]) -> None: if not args or args[0] != "publish": raise RefusalError( - "usage: publish.sh publish --expected-head <full-id> --body-file <path> [--image <path> --alt <text> | --video <path> --explanation <text>]...", + "usage: darrow-publish-pr-evidence publish --expected-head <full-id> --body-file <path> [--image <path> --alt <text> | --video <path> --explanation <text>]...", 64, ) state = prepare(args[1:]) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_files.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_files.py index 9eafa41e..cdebb817 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_files.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_files.py @@ -35,7 +35,7 @@ def hash_bytes(value: bytes) -> str: def canonical_file(path: Path) -> Path: - # Resolve the parent, as in the compatibility interface; preserve filename. + # Resolve the parent while preserving the supplied filename. return path.parent.resolve(strict=True) / path.name diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/pr.py b/plugins/capability/darrow-git/backend/src/darrow_git/pr.py index 0ea7a66c..9b50031b 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/pr.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/pr.py @@ -161,6 +161,6 @@ def run(args: list[str]) -> None: create(parse(args[1:], creating=True)) else: raise RefusalError( - "usage: pr.sh inspect [--base <branch>] [--template <filename>] | create --title <t> -b <body-section>... [--template <filename>] [--base <branch>] [--draft] | publish-existing|verify --expected-head <full-commit-id> [--base <branch>] [--draft]", + "usage: darrow-create-pr inspect [--base <branch>] [--template <filename>] | create --title <t> -b <body-section>... [--template <filename>] [--base <branch>] [--draft] | publish-existing|verify --expected-head <full-commit-id> [--base <branch>] [--draft]", 64, ) diff --git a/plugins/capability/darrow-git/skills/create-branch/scripts/branch.test.sh b/plugins/capability/darrow-git/backend/tests/shell/create-branch-branch.test.sh similarity index 80% rename from plugins/capability/darrow-git/skills/create-branch/scripts/branch.test.sh rename to plugins/capability/darrow-git/backend/tests/shell/create-branch-branch.test.sh index 52456a22..a89858fc 100644 --- a/plugins/capability/darrow-git/skills/create-branch/scripts/branch.test.sh +++ b/plugins/capability/darrow-git/backend/tests/shell/create-branch-branch.test.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# Deterministic tests for branch.sh. Covers the script-enforced invariants so -# model evals only need to cover judgment. Run: bash branch.test.sh +# Deterministic tests for darrow-create-branch. Covers the script-enforced invariants so +# model evals only need to cover judgment. Run with Bash from any directory. set -uo pipefail -SCRIPT="$(cd "$(dirname "$0")" && pwd)/branch.sh" +backend=$(cd "$(dirname "$0")/../.." && pwd -P) +ENTRYPOINT=(uv run --quiet --frozen --no-dev --project "$backend" darrow-create-branch) FAILURES=0 check() { @@ -32,50 +33,50 @@ fresh_repo() { echo "# N1: valid names accepted" fresh_repo -out=$(bash "$SCRIPT" create feat/add-login) +out=$("${ENTRYPOINT[@]}" create feat/add-login) check "exit 0" 0 $? check "reports name + base" "feat/add-login (from main)" "$out" check "on new branch" feat/add-login "$(git symbolic-ref --short HEAD)" -bash "$SCRIPT" create fix/DAR-123-null-check > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/DAR-123-null-check > /dev/null 2>&1 check "ticket-id caps accepted" 0 $? -bash "$SCRIPT" create feat/dar-456-retry > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/dar-456-retry > /dev/null 2>&1 check "lowercased ticket accepted" 0 $? name60="feat/$(printf 'a%.0s' $(seq 1 55))" -bash "$SCRIPT" create "$name60" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create "$name60" > /dev/null 2>&1 check "exactly 60 chars accepted" 0 $? fresh_repo -out=$(bash "$SCRIPT" create fix/issue-64-preserve-ticket-identifiers --ticket-token issue-64) +out=$("${ENTRYPOINT[@]}" create fix/issue-64-preserve-ticket-identifiers --ticket-token issue-64) check "issue token preserved at slug start" 0 $? check "issue token branch exact" "fix/issue-64-preserve-ticket-identifiers (from main)" "$out" fresh_repo -out=$(bash "$SCRIPT" create feat/DAR-123-retry-logic --ticket-token DAR-123) +out=$("${ENTRYPOINT[@]}" create feat/DAR-123-retry-logic --ticket-token DAR-123) check "opaque provider token preserved" 0 $? check "opaque provider token branch exact" "feat/DAR-123-retry-logic (from main)" "$out" fresh_repo -out=$(bash "$SCRIPT" create feat/ABC_42-retry-logic --ticket-token ABC_42) +out=$("${ENTRYPOINT[@]}" create feat/ABC_42-retry-logic --ticket-token ABC_42) check "underscore provider token preserved" 0 $? check "underscore provider token branch exact" "feat/ABC_42-retry-logic (from main)" "$out" fresh_repo -out=$(bash "$SCRIPT" create feat/Dar-123-retry-logic --ticket-token Dar-123) +out=$("${ENTRYPOINT[@]}" create feat/Dar-123-retry-logic --ticket-token Dar-123) check "mixed-case provider token preserved" 0 $? check "mixed-case provider token branch exact" "feat/Dar-123-retry-logic (from main)" "$out" fresh_repo -bash "$SCRIPT" create fix/preserve-issue-64 --ticket-token issue-64 > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/preserve-issue-64 --ticket-token issue-64 > /dev/null 2>&1 check "refuse non-leading token" 5 $? check "refusal creates no branch" 1 "$(git for-each-ref refs/heads | wc -l | tr -d ' ')" fresh_repo -bash "$SCRIPT" create fix/issue-64-preserve-issue-64 --ticket-token issue-64 > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/issue-64-preserve-issue-64 --ticket-token issue-64 > /dev/null 2>&1 check "refuse repeated token" 5 $? check "repeat refusal creates no branch" 1 "$(git for-each-ref refs/heads | wc -l | tr -d ' ')" echo "# N2: invalid names rejected (exit 5)" fresh_repo for name in "add-login" "feature/add-login" "feat/Add-Login" "feat/addLogin" "feat/add_login" "feat/add--login" "feat/-login" "feat/ADD-LOGIN" "feat/X" "feat/DAR-abc"; do - bash "$SCRIPT" create "$name" > /dev/null 2>&1 + "${ENTRYPOINT[@]}" create "$name" > /dev/null 2>&1 check "reject '$name'" 5 $? done long="feat/$(printf 'a%.0s' $(seq 1 60))" -bash "$SCRIPT" create "$long" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create "$long" > /dev/null 2>&1 check "reject >60 chars" 5 $? check "no branch created" 1 "$(git for-each-ref refs/heads | wc -l | tr -d ' ')" @@ -83,16 +84,16 @@ echo "# N3: existing branch not clobbered (exit 9)" fresh_repo git branch feat/login echo more > base.txt && git commit -qam "feat: advance main" -bash "$SCRIPT" create feat/login > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/login > /dev/null 2>&1 check "exit 9" 9 $? check "tip unmoved" 1 "$(git rev-list --count feat/login)" check "still on main" main "$(git symbolic-ref --short HEAD)" git branch feat/CAPS-1-loose -bash "$SCRIPT" create feat/caps-1-loose > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/caps-1-loose > /dev/null 2>&1 check "case-variant of loose ref, exit 9" 9 $? git branch feat/PACKED-1-x git pack-refs --all -bash "$SCRIPT" create feat/packed-1-x > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/packed-1-x > /dev/null 2>&1 check "case-variant of packed ref, exit 9" 9 $? check "packed tip unmoved" "$(git rev-parse main)" "$(git rev-parse feat/PACKED-1-x)" @@ -103,27 +104,27 @@ echo side > base.txt && git commit -qam "feat: side" git checkout -q main echo main > base.txt && git commit -qam "feat: main" git merge side -q > /dev/null 2>&1 || true -out=$(bash "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "inspect exit 0 in conflict" 0 $? echo "$out" | grep -q "mode: conflict" check "conflict marker present" 0 $? -bash "$SCRIPT" create chore/cleanup > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create chore/cleanup > /dev/null 2>&1 check "create refused, exit 8" 8 $? mkdir -p sub -out=$(cd sub && bash "$SCRIPT" inspect) +out=$(cd sub && "${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "mode: conflict" check "conflict detected from subdirectory" 0 $? git add base.txt -out=$(bash "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "mode: conflict" check "resolved-but-uncommitted merge still blocks" 0 $? -bash "$SCRIPT" create chore/cleanup > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create chore/cleanup > /dev/null 2>&1 check "create refused mid-merge, exit 8" 8 $? echo "# N5: dirty tree travels along, nothing stashed" fresh_repo echo dirty >> base.txt -bash "$SCRIPT" create fix/typo > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/typo > /dev/null 2>&1 check "exit 0" 0 $? check "on new branch" fix/typo "$(git symbolic-ref --short HEAD)" check "change still present" " M base.txt" "$(git status --porcelain)" @@ -133,29 +134,29 @@ echo "# N6: --from base handling" fresh_repo git checkout -qb feat/other echo other > other.txt && git add other.txt && git commit -qm "feat: other" -out=$(bash "$SCRIPT" create fix/from-main --from main) +out=$("${ENTRYPOINT[@]}" create fix/from-main --from main) check "exit 0" 0 $? check "reports given base" "fix/from-main (from main)" "$out" check "tip equals main" "$(git rev-parse main)" "$(git rev-parse HEAD)" -bash "$SCRIPT" create fix/x --from no-such-ref > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/x --from no-such-ref > /dev/null 2>&1 check "unknown base, exit 2" 2 $? -bash "$SCRIPT" create fix/x --from > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/x --from > /dev/null 2>&1 check "dangling --from, exit 2" 2 $? echo "# N7: usage errors" fresh_repo -bash "$SCRIPT" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" > /dev/null 2>&1 check "no command, exit 64" 64 $? -bash "$SCRIPT" create > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create > /dev/null 2>&1 check "no name, exit 2" 2 $? -bash "$SCRIPT" create feat/a feat/b > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/a feat/b > /dev/null 2>&1 check "two names, exit 2" 2 $? -bash "$SCRIPT" create feat/a -f > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/a -f > /dev/null 2>&1 check "unknown flag, exit 2" 2 $? echo "# N8: inspect output" fresh_repo -out=$(bash "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "exit 0" 0 $? echo "$out" | grep -q "mode: ready" check "ready marker" 0 $? @@ -164,11 +165,11 @@ check "clean tree reported" 0 $? echo "$out" | grep -q "current branch: main" check "current branch shown" 0 $? echo dirty >> base.txt -out=$(bash "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q " M base.txt" check "dirty file listed" 0 $? git checkout -q --detach -out=$(bash "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "inspect exit 0 on detached HEAD" 0 $? echo "$out" | grep -q "detached @" check "detached reported" 0 $? @@ -178,7 +179,7 @@ fresh_repo git checkout -qb feat/other echo other > base.txt && git commit -qam "feat: other" echo dirty >> base.txt -bash "$SCRIPT" create fix/refused --from main > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/refused --from main > /dev/null 2>&1 check "switch refused, exit 4" 4 $? check "still on feat/other" feat/other "$(git symbolic-ref --short HEAD)" grep -q dirty base.txt @@ -189,7 +190,7 @@ check "stash empty" "" "$(git stash list)" echo "# N10: create works from detached HEAD" fresh_repo git checkout -q --detach -out=$(bash "$SCRIPT" create feat/from-detached) +out=$("${ENTRYPOINT[@]}" create feat/from-detached) check "exit 0" 0 $? check "on new branch" feat/from-detached "$(git symbolic-ref --short HEAD)" echo "$out" | grep -q "detached @" @@ -198,92 +199,92 @@ check "detached base reported" 0 $? echo "# N11: unborn repo refused (exit 3)" UNBORN=$(mktemp -d) cd "$UNBORN" && git init -qb main -bash "$SCRIPT" create feat/first > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/first > /dev/null 2>&1 check "create refused, exit 3" 3 $? check "no half-made branch" "" "$(git for-each-ref refs/heads)" -bash "$SCRIPT" inspect > /dev/null 2>&1 +"${ENTRYPOINT[@]}" inspect > /dev/null 2>&1 check "inspect still works" 0 $? echo "# N12: outside a work tree refused (exit 3)" cd "$(mktemp -d)" || exit 1 -bash "$SCRIPT" inspect > /dev/null 2>&1 +"${ENTRYPOINT[@]}" inspect > /dev/null 2>&1 check "inspect exit 3" 3 $? -bash "$SCRIPT" create feat/x > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/x > /dev/null 2>&1 check "create exit 3" 3 $? echo "# N13: worktree default path" fresh_repo TOP=$(git rev-parse --show-toplevel) # mktemp path may be a symlink on macOS -out=$(bash "$SCRIPT" create feat/wt-default --worktree) +out=$("${ENTRYPOINT[@]}" create feat/wt-default --worktree) check "exit 0" 0 $? check "reports name, base, path" "feat/wt-default (from main) at $TOP/.worktrees/feat/wt-default" "$out" check "worktree registered" 2 "$(git worktree list --porcelain | grep -c '^worktree ')" check "branch checked out there" feat/wt-default "$(git -C .worktrees/feat/wt-default symbolic-ref --short HEAD)" check "current checkout untouched" main "$(git symbolic-ref --short HEAD)" check "status stays clean (excluded)" "" "$(git status --porcelain)" -bash "$SCRIPT" create feat/wt-second --worktree > /dev/null +"${ENTRYPOINT[@]}" create feat/wt-second --worktree > /dev/null check "second worktree ok" 0 $? check "exclude entry not duplicated" 1 "$(grep -cxF '/.worktrees/' .git/info/exclude)" mkdir -p sub -out=$(cd sub && bash "$SCRIPT" create feat/wt-subdir --worktree) +out=$(cd sub && "${ENTRYPOINT[@]}" create feat/wt-subdir --worktree) check "default path is toplevel-based from subdir" "feat/wt-subdir (from main) at $TOP/.worktrees/feat/wt-subdir" "$out" -out=$(cd .worktrees/feat/wt-default && bash "$SCRIPT" create feat/wt-nested --worktree) +out=$(cd .worktrees/feat/wt-default && "${ENTRYPOINT[@]}" create feat/wt-nested --worktree) check "anchored at main root from linked worktree" "feat/wt-nested (from feat/wt-default) at $TOP/.worktrees/feat/wt-nested" "$out" echo "# N14: worktree --at custom path" fresh_repo CALLER=$PWD AT=$(mktemp -d)/custom-wt -out=$(bash "$SCRIPT" create fix/at-outside --worktree --at "$AT") +out=$("${ENTRYPOINT[@]}" create fix/at-outside --worktree --at "$AT") check "exit 0" 0 $? check "path used verbatim" "fix/at-outside (from main) at $AT" "$out" check "branch checked out there" fix/at-outside "$(git -C "$AT" symbolic-ref --short HEAD)" echo "$out" | grep -q "## note:.*git status" check "no status note for outside path" 1 $? -out=$(bash "$SCRIPT" create fix/at-inside --worktree --at inside-wt) +out=$("${ENTRYPOINT[@]}" create fix/at-inside --worktree --at inside-wt) check "inside-repo path accepted" 0 $? echo "$out" | grep -q "fix/at-inside (from main) at $CALLER/inside-wt" check "relative custom path is reported as absolute" 0 $? echo "$out" | grep -q "## note: $CALLER/inside-wt is inside the repository and not ignored" check "status-visibility note printed" 0 $? -bash "$SCRIPT" create fix/at-alone --at somewhere > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/at-alone --at somewhere > /dev/null 2>&1 check "--at without --worktree, exit 2" 2 $? -bash "$SCRIPT" create fix/at-dangling --worktree --at > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/at-dangling --worktree --at > /dev/null 2>&1 check "dangling --at, exit 2" 2 $? -bash "$SCRIPT" create fix/at-empty --worktree --at "" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/at-empty --worktree --at "" > /dev/null 2>&1 check "empty --at rejected, exit 2" 2 $? -bash "$SCRIPT" create fix/at-empty2 --at "" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/at-empty2 --at "" > /dev/null 2>&1 check "empty --at without --worktree, exit 2" 2 $? check "nothing created for empty --at" "" "$(git for-each-ref refs/heads --format='%(refname:short)' | grep at-empty || true)" -bash "$SCRIPT" create fix/at-gitdir --worktree --at .git/inner-wt > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create fix/at-gitdir --worktree --at .git/inner-wt > /dev/null 2>&1 check "--at inside .git rejected, exit 2" 2 $? echo "# N15: worktree path and branch collisions (exit 9)" fresh_repo mkdir -p .worktrees/feat/wt-clash -bash "$SCRIPT" create feat/wt-clash --worktree > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/wt-clash --worktree > /dev/null 2>&1 check "existing default path, exit 9" 9 $? check "no branch created" "" "$(git for-each-ref refs/heads --format='%(refname:short)' | grep wt-clash || true)" check "no exclude side effect before refusal" "" "$(grep -xF '/.worktrees/' .git/info/exclude 2>/dev/null || true)" touch clashfile -bash "$SCRIPT" create feat/wt-file --worktree --at clashfile > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/wt-file --worktree --at clashfile > /dev/null 2>&1 check "existing file at --at, exit 9" 9 $? -bash "$SCRIPT" create feat/wt-slash --worktree --at clashfile/ > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/wt-slash --worktree --at clashfile/ > /dev/null 2>&1 check "trailing-slash file caught, exit 9" 9 $? git branch feat/wt-exists -bash "$SCRIPT" create feat/wt-exists --worktree > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/wt-exists --worktree > /dev/null 2>&1 check "existing branch with --worktree, exit 9" 9 $? check "no worktree created" 1 "$(git worktree list --porcelain | grep -c '^worktree ')" fresh_repo touch .worktrees -bash "$SCRIPT" create feat/wt-parentfile --worktree > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/wt-parentfile --worktree > /dev/null 2>&1 check ".worktrees as regular file, exit 9" 9 $? check "no branch created" "" "$(git for-each-ref refs/heads --format='%(refname:short)' | grep parentfile || true)" echo "# N16: worktree leaves dirty tree behind" fresh_repo echo dirty >> base.txt -out=$(bash "$SCRIPT" create fix/wt-dirty --worktree) +out=$("${ENTRYPOINT[@]}" create fix/wt-dirty --worktree) check "exit 0" 0 $? echo "$out" | grep -q "## note: uncommitted changes stay in the current worktree" check "stay-behind note printed" 0 $? @@ -295,7 +296,7 @@ echo "# N17: worktree --from base" fresh_repo git checkout -qb feat/other echo other > other.txt && git add other.txt && git commit -qm "feat: other" -out=$(bash "$SCRIPT" create fix/wt-from --worktree --from main) +out=$("${ENTRYPOINT[@]}" create fix/wt-from --worktree --from main) check "exit 0" 0 $? echo "$out" | grep -q "^fix/wt-from (from main) at " check "reports given base" 0 $? @@ -308,28 +309,28 @@ echo side > base.txt && git commit -qam "feat: side" git checkout -q main echo main > base.txt && git commit -qam "feat: main" git merge side -q > /dev/null 2>&1 || true -bash "$SCRIPT" create chore/wt-mid-merge --worktree > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create chore/wt-mid-merge --worktree > /dev/null 2>&1 check "mid-merge refused, exit 8" 8 $? fresh_repo -bash "$SCRIPT" create "feat/Bad_Name" --worktree > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create "feat/Bad_Name" --worktree > /dev/null 2>&1 check "invalid name with --worktree, exit 5" 5 $? UNBORN=$(mktemp -d) cd "$UNBORN" && git init -qb main -bash "$SCRIPT" create feat/wt-unborn --worktree > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/wt-unborn --worktree > /dev/null 2>&1 check "unborn repo with --worktree, exit 3" 3 $? echo "# N19: inspect lists other worktrees" fresh_repo -out=$(bash "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "other worktrees" check "no worktree section without worktrees" 1 $? -bash "$SCRIPT" create feat/wt-listed --worktree > /dev/null -out=$(bash "$SCRIPT" inspect) +"${ENTRYPOINT[@]}" create feat/wt-listed --worktree > /dev/null +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "## other worktrees" check "worktree section present" 0 $? echo "$out" | grep -q "\.worktrees/feat/wt-listed \[feat/wt-listed\]" check "worktree path and branch listed" 0 $? -out=$(cd .worktrees/feat/wt-listed && bash "$SCRIPT" inspect) +out=$(cd .worktrees/feat/wt-listed && "${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "\[main\]" check "main listed as other from linked worktree" 0 $? echo "$out" | grep -q "\.worktrees/feat/wt-listed" @@ -338,12 +339,12 @@ check "current worktree not listed as other" 1 $? echo "# N20: failed worktree add never deletes the created branch" fresh_repo touch exfile -bash "$SCRIPT" create feat/stray --worktree --at exfile/sub > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/stray --worktree --at exfile/sub > /dev/null 2>&1 check "add fails, exit 4" 4 $? git show-ref -q --verify refs/heads/feat/stray check "created branch survives worktree failure" 0 $? STRAY_TIP=$(git rev-parse refs/heads/feat/stray) -bash "$SCRIPT" create feat/stray --worktree > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/stray --worktree > /dev/null 2>&1 check "retry refuses to reuse the surviving branch" 9 $? check "surviving branch tip is unchanged" "$STRAY_TIP" "$(git rev-parse refs/heads/feat/stray)" @@ -364,7 +365,7 @@ printf '%s\n' \ 'exec "$RACE_REAL_GIT" "$@"' > "$FAKE_BIN/git" chmod +x "$FAKE_BIN/git" PATH="$FAKE_BIN:$PATH" RACE_REAL_GIT="$REAL_GIT" RACE_BRANCH=feat/concurrent \ - bash "$SCRIPT" create feat/concurrent --worktree --at "$TOP/race-worktree" > /dev/null 2>&1 + "${ENTRYPOINT[@]}" create feat/concurrent --worktree --at "$TOP/race-worktree" > /dev/null 2>&1 check "concurrent branch makes worktree creation fail" 4 $? git show-ref -q --verify refs/heads/feat/concurrent check "concurrently created branch survives refusal" 0 $? @@ -388,7 +389,7 @@ chmod +x "$FAKE_BIN/git" EXCLUDE=$(git rev-parse --git-path info/exclude) EXCLUDE_BEFORE=$(cat "$EXCLUDE") PATH="$FAKE_BIN:$PATH" FAIL_REAL_GIT="$REAL_GIT" \ - bash "$SCRIPT" create feat/default-refusal --worktree > /dev/null 2>&1 + "${ENTRYPOINT[@]}" create feat/default-refusal --worktree > /dev/null 2>&1 check "simulated default worktree creation fails" 4 $? check "exclude file is unchanged after refusal" "$EXCLUDE_BEFORE" "$(cat "$EXCLUDE")" if [[ -d "$TOP/.worktrees" ]]; then @@ -410,7 +411,7 @@ git config user.name t echo base > base.txt git add base.txt git commit -qm "chore: init" -out=$(bash "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "## other worktrees" check "backslash path is not listed as another worktree" 1 $? @@ -441,7 +442,7 @@ printf '%s\n' \ chmod +x "$FAKE_BIN/git" "$FAKE_BIN/mkdir" PATH="$FAKE_BIN:$PATH" DIR_RACE_REAL_GIT="$REAL_GIT" \ DIR_RACE_REAL_MKDIR="$REAL_MKDIR" DIR_RACE_PATH="$TOP/.worktrees/feat" \ - bash "$SCRIPT" create feat/directory-race --worktree > /dev/null 2>&1 + "${ENTRYPOINT[@]}" create feat/directory-race --worktree > /dev/null 2>&1 check "directory race worktree creation fails" 4 $? if [[ -d "$TOP/.worktrees/feat" ]]; then concurrent_directory_status=0 @@ -472,7 +473,7 @@ chmod +x "$FAKE_BIN/git" PATH="$FAKE_BIN:$PATH" REPLACE_REAL_GIT="$REAL_GIT" \ REPLACE_REAL_MKDIR="$REAL_MKDIR" REPLACE_REAL_RMDIR="$REAL_RMDIR" \ REPLACE_PATH="$TOP/.worktrees/feat" \ - bash "$SCRIPT" create feat/directory-replacement --worktree > /dev/null 2>&1 + "${ENTRYPOINT[@]}" create feat/directory-replacement --worktree > /dev/null 2>&1 check "directory replacement worktree creation fails" 4 $? if [[ -d "$TOP/.worktrees/feat" ]]; then replaced_directory_status=0 @@ -486,7 +487,7 @@ fresh_repo TOP=$(git rev-parse --show-toplevel) EXCLUDE=$(git rev-parse --git-path info/exclude) chmod a-r "$EXCLUDE" -bash "$SCRIPT" create feat/unreadable-exclude --worktree > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create feat/unreadable-exclude --worktree > /dev/null 2>&1 check "unreadable exclude is refused" 9 $? chmod u+r "$EXCLUDE" git show-ref -q --verify refs/heads/feat/unreadable-exclude @@ -520,7 +521,7 @@ printf '%s\n' \ chmod +x "$FAKE_BIN/git" output=$(PATH="$FAKE_BIN:$PATH" APPEND_FAIL_REAL_GIT="$REAL_GIT" \ APPEND_FAIL_EXCLUDE="$EXCLUDE" \ - bash "$SCRIPT" create feat/exclude-append-failure --worktree 2>&1) + "${ENTRYPOINT[@]}" create feat/exclude-append-failure --worktree 2>&1) append_failure_status=$? chmod u+w "$EXCLUDE" check "post-create exclude update failure is nonzero" 9 "$append_failure_status" @@ -554,7 +555,7 @@ chmod +x "$FAKE_BIN/git" PATH="$FAKE_BIN:$PATH" PREFLIGHT_REAL_GIT="$REAL_GIT" \ PREFLIGHT_REAL_MKDIR="$REAL_MKDIR" PREFLIGHT_REAL_RMDIR="$REAL_RMDIR" \ PREFLIGHT_REPLACE_PATH="$TOP/.worktrees/feat" \ - bash "$SCRIPT" create feat/preflight-replacement --worktree > /dev/null 2>&1 + "${ENTRYPOINT[@]}" create feat/preflight-replacement --worktree > /dev/null 2>&1 check "preflight replacement is refused" 9 $? chmod u+r "$EXCLUDE" if [[ -d "$TOP/.worktrees/feat" ]]; then diff --git a/plugins/capability/darrow-git/skills/create-commit/scripts/commit.test.sh b/plugins/capability/darrow-git/backend/tests/shell/create-commit-commit.test.sh similarity index 75% rename from plugins/capability/darrow-git/skills/create-commit/scripts/commit.test.sh rename to plugins/capability/darrow-git/backend/tests/shell/create-commit-commit.test.sh index 2f8535cb..953509a7 100755 --- a/plugins/capability/darrow-git/skills/create-commit/scripts/commit.test.sh +++ b/plugins/capability/darrow-git/backend/tests/shell/create-commit-commit.test.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# Deterministic tests for commit.sh. Covers the script-enforced invariants so -# model evals only need to cover judgment. Run: bash commit.test.sh +# Deterministic tests for darrow-create-commit. Covers the script-enforced invariants so +# model evals only need to cover judgment. Run with Bash from any directory. set -uo pipefail -SCRIPT="$(cd "$(dirname "$0")" && pwd)/commit.sh" +backend=$(cd "$(dirname "$0")/../.." && pwd -P) +ENTRYPOINT=(uv run --quiet --frozen --no-dev --project "$backend" darrow-create-commit) FAILURES=0 check() { @@ -34,16 +35,16 @@ echo "# H1: large diff must not SIGPIPE (exit 141)" fresh_repo for i in $(seq 1 5000); do echo "padding line $i for a diff far beyond the truncation cutoff"; done > big.txt git add big.txt -"$BASH" "$SCRIPT" inspect > /dev/null 2>&1 +"${ENTRYPOINT[@]}" inspect > /dev/null 2>&1 check "inspect exit 0 on huge staged diff" 0 $? echo change >> base.txt -"$BASH" "$SCRIPT" diff base.txt > /dev/null 2>&1 +"${ENTRYPOINT[@]}" diff base.txt > /dev/null 2>&1 check "diff exit 0" 0 $? echo "# H2: staged set + extra paths rejected" fresh_repo echo a > a.txt && echo b > b.txt && git add a.txt -"$BASH" "$SCRIPT" commit -m "feat: a" b.txt > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "feat: a" b.txt > /dev/null 2>&1 check "exit 7" 7 $? check "b.txt not staged" "" "$(git diff --cached --name-only | grep b.txt || true)" @@ -58,10 +59,10 @@ echo outside > outside.txt mkdir -p .git/hooks printf '#!/bin/sh\necho "run: printf corrected > tracked.txt" >&2\ngit show :tracked.txt | grep -qx corrected\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -"$BASH" "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "fix: correct tracked file" > /dev/null 2>&1 check "hook failure preserves history" 2 "$(git rev-list --count HEAD)" echo corrected > tracked.txt -"$BASH" "$SCRIPT" retry --after-hook-failure --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" retry --after-hook-failure --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "authorized retry commits" 0 $? check "retry commits corrected content" "corrected" "$(git show HEAD:tracked.txt)" check "retry preserves other staged blob" "staged-preserved" "$(git show HEAD:preserved.txt)" @@ -74,7 +75,7 @@ echo staged > tracked.txt && git add tracked.txt echo outside > outside.txt before_index=$(git write-tree) before_head=$(git rev-parse HEAD) -"$BASH" "$SCRIPT" retry --after-hook-failure --refresh-staged outside.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" retry --after-hook-failure --refresh-staged outside.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "outside staged retry rejected" 7 $? check "outside refusal preserves index" "$before_index" "$(git write-tree)" check "outside refusal preserves history" "$before_head" "$(git rev-parse HEAD)" @@ -88,7 +89,7 @@ echo staged > src/staged.txt && git add src/staged.txt echo unrelated > src/untracked.txt before_index=$(git write-tree) before_head=$(git rev-parse HEAD) -"$BASH" "$SCRIPT" retry --after-hook-failure --refresh-staged src -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" retry --after-hook-failure --refresh-staged src -m "fix: correct tracked file" > /dev/null 2>&1 check "directory retry rejected" 7 $? check "directory refusal preserves index" "$before_index" "$(git write-tree)" check "directory refusal preserves history" "$before_head" "$(git rev-parse HEAD)" @@ -101,9 +102,9 @@ echo stale > tracked.txt && git add tracked.txt mkdir -p .git/hooks printf '#!/bin/sh\necho "run: printf corrected > tracked.txt" >&2\ngit show :tracked.txt | grep -qx corrected\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -"$BASH" "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "fix: correct tracked file" > /dev/null 2>&1 check "hook failure preserves remediation precondition" 2 "$(git rev-list --count HEAD)" -"$BASH" "$SCRIPT" remediate --after-hook-failure --command "printf corrected > tracked.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" remediate --after-hook-failure --command "printf corrected > tracked.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "unambiguous remediation commits" 0 $? check "remediation commits corrected content" "corrected" "$(git show HEAD:tracked.txt)" @@ -115,10 +116,10 @@ echo outside > outside.txt mkdir -p .git/hooks printf '#!/bin/sh\necho "run: git add outside.txt" >&2\nexit 1\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -"$BASH" "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "fix: correct tracked file" > /dev/null 2>&1 before_index=$(git write-tree) before_head=$(git rev-parse HEAD) -"$BASH" "$SCRIPT" remediate --after-hook-failure --command "git add outside.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" remediate --after-hook-failure --command "git add outside.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "index-mutating remediation rejected" 7 $? check "index-mutating remediation restores index" "$before_index" "$(git write-tree)" check "index-mutating remediation preserves history" "$before_head" "$(git rev-parse HEAD)" @@ -130,10 +131,10 @@ echo stale > tracked.txt && git add tracked.txt mkdir -p .git/hooks printf '#!/bin/sh\necho "run: printf corrected > tracked.txt" >&2\nexit 1\n' > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -"$BASH" "$SCRIPT" commit -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "fix: correct tracked file" > /dev/null 2>&1 before_index=$(git write-tree) before_head=$(git rev-parse HEAD) -"$BASH" "$SCRIPT" remediate --after-hook-failure --command "printf unrelated > tracked.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" remediate --after-hook-failure --command "printf unrelated > tracked.txt" --refresh-staged tracked.txt -m "fix: correct tracked file" > /dev/null 2>&1 check "unreported remediation rejected" 7 $? check "unreported remediation preserves index" "$before_index" "$(git write-tree)" check "unreported remediation preserves history" "$before_head" "$(git rev-parse HEAD)" @@ -142,21 +143,21 @@ echo "# M1: sweep shortcuts rejected" fresh_repo echo x > x.txt for arg in "." ".." "*.txt" "-A" ":/"; do - "$BASH" "$SCRIPT" commit -m "feat: sweep" "$arg" > /dev/null 2>&1 + "${ENTRYPOINT[@]}" commit -m "feat: sweep" "$arg" > /dev/null 2>&1 check "reject '$arg'" 7 $? done echo "# M2: human co-author accepted" fresh_repo echo x > x.txt && git add x.txt -"$BASH" "$SCRIPT" commit -m "feat: x" -m "Co-authored-by: Nicolai Parlog <nicolai@example.org>" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "feat: x" -m "Co-authored-by: Nicolai Parlog <nicolai@example.org>" > /dev/null 2>&1 check "human trailer commits" 0 $? echo "# M3: attribution variants rejected" for msg in "Generated with Claude Code" "Generated by Claude" "Generated using Claude Code" "Built with Claude Code" "Assisted by Codex" "Written by claude" "Written by an AI" "Co-authored by Claude" "Co-authored-by: Claude <noreply@anthropic.com>"; do fresh_repo echo x > x.txt && git add x.txt - "$BASH" "$SCRIPT" commit -m "feat: x" -m "$msg" > /dev/null 2>&1 + "${ENTRYPOINT[@]}" commit -m "feat: x" -m "$msg" > /dev/null 2>&1 check "reject '$msg'" 6 $? done @@ -166,13 +167,13 @@ echo x > x.txt && git add x.txt # Linux caps each argv element at 128 KiB. Keep each section below that limit # while the complete message still exceeds 200 KiB and any pipe buffer. big=$(awk 'BEGIN{for(i=0;i<7000;i++) printf "word %d ab. ", i}') -"$BASH" "$SCRIPT" commit -m "feat: x" -m "$big" -m "$big" -m "$big" -m "Generated with Claude Code" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "feat: x" -m "$big" -m "$big" -m "$big" -m "Generated with Claude Code" > /dev/null 2>&1 check "200KB message across bounded arguments rejected (no SIGPIPE bypass)" 6 $? echo "# M3b: legitimate tool prose accepted" fresh_repo echo x > x.txt && git add x.txt -"$BASH" "$SCRIPT" commit -m "chore: regenerate api client" -m "The client is now generated by openapi-generator from the v2 spec." > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "chore: regenerate api client" -m "The client is now generated by openapi-generator from the v2 spec." > /dev/null 2>&1 check "generated-by non-AI tool commits" 0 $? echo "# M4: conflict state detected" @@ -182,16 +183,16 @@ echo side > base.txt && git commit -qam "feat: side" git checkout -q main echo main > base.txt && git commit -qam "feat: main" git merge side -q > /dev/null 2>&1 || true -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "inspect exit 0 in conflict" 0 $? echo "$out" | grep -q "mode: conflict" check "conflict marker present" 0 $? -"$BASH" "$SCRIPT" commit -m "feat: merge" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "feat: merge" > /dev/null 2>&1 check "commit refused, exit 8" 8 $? echo "# L1: dangling -m explains itself" fresh_repo -err=$("$BASH" "$SCRIPT" commit -m 2>&1 >/dev/null) +err=$("${ENTRYPOINT[@]}" commit -m 2>&1 >/dev/null) status=$? check "exit 2" 2 $status echo "$err" | grep -q "needs a value" @@ -200,7 +201,7 @@ check "explanatory error" 0 $? echo "# L3: diff works for untracked files" fresh_repo echo new > brand-new.txt -out=$("$BASH" "$SCRIPT" diff brand-new.txt) +out=$("${ENTRYPOINT[@]}" diff brand-new.txt) check "exit 0" 0 $? echo "$out" | grep -q "brand-new" check "shows content as new-file diff" 0 $? @@ -208,16 +209,16 @@ check "shows content as new-file diff" 0 $? echo "# L5: subject rules apply to first line only" fresh_repo echo x > x.txt && git add x.txt -"$BASH" "$SCRIPT" commit -m "feat: x +"${ENTRYPOINT[@]}" commit -m "feat: x this second line inside msgs[0] may be long and end with a period." > /dev/null 2>&1 check "multi-line msgs[0] with valid first line commits" 0 $? echo "# format guards still hold" fresh_repo echo x > x.txt && git add x.txt -"$BASH" "$SCRIPT" commit -m "updated stuff" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "updated stuff" > /dev/null 2>&1 check "non-conventional subject rejected" 5 $? -"$BASH" "$SCRIPT" commit -m "feat: x." > /dev/null 2>&1 +"${ENTRYPOINT[@]}" commit -m "feat: x." > /dev/null 2>&1 check "trailing period rejected" 5 $? echo diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/pr.test.sh b/plugins/capability/darrow-git/backend/tests/shell/create-pr-pr.test.sh similarity index 75% rename from plugins/capability/darrow-git/skills/create-pr/scripts/pr.test.sh rename to plugins/capability/darrow-git/backend/tests/shell/create-pr-pr.test.sh index fe0a9b18..9675a22e 100644 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/pr.test.sh +++ b/plugins/capability/darrow-git/backend/tests/shell/create-pr-pr.test.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash -# Deterministic tests for pr.sh. Covers the script-enforced invariants so +# Deterministic tests for darrow-create-pr. Covers the script-enforced invariants so # model evals only need to cover judgment. gh is mocked (records pr-create # args under .git/fixture-gh/); the remote is a local bare repo, so push -# behavior is tested for real. Run: bash pr.test.sh +# behavior is tested for real. Run with Bash from any directory. set -uo pipefail -SCRIPT="$(cd "$(dirname "$0")" && pwd)/pr.sh" +backend=$(cd "$(dirname "$0")/../.." && pwd -P) +ENTRYPOINT=(uv run --quiet --frozen --no-dev --project "$backend" darrow-create-pr) BASE_PATH=$PATH FAILURES=0 @@ -120,54 +121,54 @@ ready_repo() { echo "# P1: usage and argument errors" ready_repo -"$BASH" "$SCRIPT" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" > /dev/null 2>&1 check "no command, exit 64" 64 $? -"$BASH" "$SCRIPT" create > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create > /dev/null 2>&1 check "no title, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title "fix: x" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" > /dev/null 2>&1 check "no body, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title > /dev/null 2>&1 check "dangling --title, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b > /dev/null 2>&1 check "dangling -b, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why --base > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why --base > /dev/null 2>&1 check "dangling --base, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why --template > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why --template > /dev/null 2>&1 check "dangling --template, exit 2" 2 $? -"$BASH" "$SCRIPT" inspect --template > /dev/null 2>&1 +"${ENTRYPOINT[@]}" inspect --template > /dev/null 2>&1 check "inspect dangling --template, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why --force > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why --force > /dev/null 2>&1 check "unknown flag, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b " " > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b " " > /dev/null 2>&1 check "whitespace-only -b, exit 2" 2 $? echo "# P2: environment guards (exit 3)" cd "$(mktemp -d)" || exit 1 -"$BASH" "$SCRIPT" inspect > /dev/null 2>&1 +"${ENTRYPOINT[@]}" inspect > /dev/null 2>&1 check "outside work tree, inspect exit 3" 3 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why > /dev/null 2>&1 check "outside work tree, create exit 3" 3 $? UNBORN=$(mktemp -d) cd "$UNBORN" && git init -qb main -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "unborn inspect exit 0" 0 $? echo "$out" | grep -q "mode: empty" check "unborn reports mode empty" 0 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why > /dev/null 2>&1 check "unborn create exit 3" 3 $? fresh_repo git checkout -qb fix/no-remote -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "mode: no-remote" check "no origin reported by inspect" 0 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why > /dev/null 2>&1 check "no origin, create exit 3" 3 $? ready_repo git checkout -q --detach -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "mode: wrong-branch" check "detached reported wrong-branch" 0 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why > /dev/null 2>&1 check "detached create exit 3" 3 $? ready_repo if PATH="/usr/bin:/bin" command -v gh >/dev/null 2>&1; then @@ -175,7 +176,7 @@ if PATH="/usr/bin:/bin" command -v gh >/dev/null 2>&1; then else mkdir -p "$REPO/.git/no-gh-bin" ln -s "$(command -v uv)" "$REPO/.git/no-gh-bin/uv" - PATH="$REPO/.git/no-gh-bin:/usr/bin:/bin" "$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 + PATH="$REPO/.git/no-gh-bin:/usr/bin:/bin" "${ENTRYPOINT[@]}" create --title "fix: x" -b why > /dev/null 2>&1 check "gh missing, create exit 3" 3 $? fi @@ -183,14 +184,14 @@ echo "# P3: default branch refused (exit 9)" fresh_repo add_remote mock_gh -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "mode: wrong-branch" check "on default branch reported wrong-branch" 0 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why > /dev/null 2>&1 check "create on default branch, exit 9" 9 $? git branch -q develop git push -q origin develop -"$BASH" "$SCRIPT" create --title "fix: x" -b why --base develop > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why --base develop > /dev/null 2>&1 check "explicit --base does not bypass, exit 9" 9 $? check "nothing captured" "" "$(ls .git/fixture-gh 2>/dev/null || true)" @@ -199,79 +200,79 @@ ready_repo git checkout -q main echo main > fix.txt && git add fix.txt && git commit -qm "fix: main side" git merge fix/timeout-retry -q > /dev/null 2>&1 || true -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "inspect exit 0 in conflict" 0 $? echo "$out" | grep -q "mode: conflict" check "conflict marker present" 0 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why > /dev/null 2>&1 check "create refused, exit 8" 8 $? mkdir -p sub -out=$(cd sub && "$BASH" "$SCRIPT" inspect) +out=$(cd sub && "${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "mode: conflict" check "conflict detected from subdirectory" 0 $? echo "# P5: title validation (exit 5), nothing pushed" ready_repo -"$BASH" "$SCRIPT" create --title "add retry logic" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "add retry logic" -b why > /dev/null 2>&1 check "non-conventional title, exit 5" 5 $? long="fix: $(printf 'a%.0s' $(seq 1 70))" -"$BASH" "$SCRIPT" create --title "$long" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "$long" -b why > /dev/null 2>&1 check "title >72 chars, exit 5" 5 $? -"$BASH" "$SCRIPT" create --title "fix: retry on timeout." -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout." -b why > /dev/null 2>&1 check "trailing period, exit 5" 5 $? -"$BASH" "$SCRIPT" create --title "Fix: retry on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "Fix: retry on timeout" -b why > /dev/null 2>&1 check "uppercase type, exit 5" 5 $? check "branch not pushed" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" -"$BASH" "$SCRIPT" create --title "fix!: drop retry config flag" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix!: drop retry config flag" -b why > /dev/null 2>&1 check "breaking-change marker accepted" 0 $? rm -rf .git/fixture-gh -"$BASH" "$SCRIPT" create --title "fix(http): retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix(http): retry request on timeout" -b why > /dev/null 2>&1 check "scoped title accepted" 0 $? exact72="fix: $(printf 'a%.0s' $(seq 1 67))" rm -rf .git/fixture-gh -"$BASH" "$SCRIPT" create --title "$exact72" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "$exact72" -b why > /dev/null 2>&1 check "exactly 72 chars accepted" 0 $? echo "# P6: AI attribution rejected (exit 6), nothing pushed" ready_repo -"$BASH" "$SCRIPT" create --title "fix: retry on timeout" -b "Generated with Claude Code" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout" -b "Generated with Claude Code" > /dev/null 2>&1 check "generated-with body, exit 6" 6 $? -"$BASH" "$SCRIPT" create --title "fix: retry on timeout" -b why -b "Co-authored-by: Claude <noreply@anthropic.com>" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout" -b why -b "Co-authored-by: Claude <noreply@anthropic.com>" > /dev/null 2>&1 check "AI co-author body, exit 6" 6 $? -"$BASH" "$SCRIPT" create --title "fix: retry on timeout 🤖" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout 🤖" -b why > /dev/null 2>&1 check "robot emoji title, exit 6" 6 $? -"$BASH" "$SCRIPT" create --title "fix: retry on timeout" -b "This was written by claude" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout" -b "This was written by claude" > /dev/null 2>&1 check "written-by body, exit 6" 6 $? -"$BASH" "$SCRIPT" create --title "fix: retry on timeout" -b "Generated using Claude Code" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout" -b "Generated using Claude Code" > /dev/null 2>&1 check "generated-using body, exit 6" 6 $? -"$BASH" "$SCRIPT" create --title "fix: retry on timeout" -b "Built with Claude Code" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout" -b "Built with Claude Code" > /dev/null 2>&1 check "built-with body, exit 6" 6 $? -"$BASH" "$SCRIPT" create --title "fix: retry on timeout" -b "Co-authored by Claude" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout" -b "Co-authored by Claude" > /dev/null 2>&1 check "co-authored without colon, exit 6" 6 $? -"$BASH" "$SCRIPT" create --title "fix: retry on timeout" -b "Reviewed by an AI assistant before merge... just kidding. Written by an AI" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry on timeout" -b "Reviewed by an AI assistant before merge... just kidding. Written by an AI" > /dev/null 2>&1 check "written by an AI, exit 6" 6 $? check "branch not pushed" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" -"$BASH" "$SCRIPT" create --title "chore: regenerate api client" -b "The client is now generated by openapi-generator from the v2 spec." > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "chore: regenerate api client" -b "The client is now generated by openapi-generator from the v2 spec." > /dev/null 2>&1 check "legitimate generated-by prose accepted" 0 $? echo "# P7: base handling" ready_repo -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "Requests died on flaky links." > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "Requests died on flaky links." > /dev/null 2>&1 check "create ok" 0 $? check "default base is main" main "$(cat .git/fixture-gh/base)" ready_repo git branch -q develop main git push -q origin develop -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why --base develop > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why --base develop > /dev/null 2>&1 check "create ok" 0 $? check "named base used" develop "$(cat .git/fixture-gh/base)" ready_repo -"$BASH" "$SCRIPT" create --title "fix: x" -b why --base no-such > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why --base no-such > /dev/null 2>&1 check "unknown base, exit 2" 2 $? git branch -q local-only main -"$BASH" "$SCRIPT" create --title "fix: x" -b why --base local-only > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why --base local-only > /dev/null 2>&1 check "local-only base not on origin, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title "fix: x" -b why --base fix/timeout-retry > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why --base fix/timeout-retry > /dev/null 2>&1 check "base equals head, exit 2" 2 $? check "nothing pushed on base errors" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" @@ -280,19 +281,19 @@ fresh_repo add_remote mock_gh git checkout -qb fix/empty-branch -"$BASH" "$SCRIPT" create --title "fix: x" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: x" -b why > /dev/null 2>&1 check "no commits ahead, exit 3" 3 $? check "branch not pushed" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/empty-branch)" echo "# P9: existing open PR not duplicated (exit 9)" ready_repo echo "#7 fix: earlier attempt fix/timeout-retry" > .git/fixture-gh-existing -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "mode: exists" check "inspect reports mode exists" 0 $? echo "$out" | grep -q "#7" check "existing PR shown" 0 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "create refused, exit 9" 9 $? check "branch not pushed" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" check "nothing captured" "" "$(ls .git/fixture-gh 2>/dev/null || true)" @@ -300,24 +301,24 @@ check "nothing captured" "" "$(ls .git/fixture-gh 2>/dev/null || true)" echo "# P9b: failed PR check aborts — never treated as 'no duplicates'" ready_repo touch .git/fixture-gh-fail -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "mode: ready" check "inspect still ready" 0 $? echo "$out" | grep -q "could not check for an existing open PR" check "inspect flags the failed check" 0 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "create aborts, exit 4" 4 $? check "nothing pushed" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" check "no PR created" "" "$(ls .git/fixture-gh 2>/dev/null || true)" echo "# P10: push behavior — upstream set, updates pushed, never forced" ready_repo -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "create ok" 0 $? check "upstream set" origin/fix/timeout-retry "$(git rev-parse --abbrev-ref '@{u}')" check "remote has the branch tip" "$(git rev-parse HEAD)" "$(git -C .git/remote.git rev-parse refs/heads/fix/timeout-retry)" echo more > fix.txt && git commit -qam "fix: widen retry window" -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "create ok with existing upstream" 0 $? check "new commit pushed" "$(git rev-parse HEAD)" "$(git -C .git/remote.git rev-parse refs/heads/fix/timeout-retry)" # Diverge: advance the remote branch independently, then commit locally. @@ -329,14 +330,14 @@ echo local-side > local.txt && git add local.txt && git commit -qm "fix: local s git fetch -q origin remote_tip=$(git -C .git/remote.git rev-parse refs/heads/fix/timeout-retry) rm -rf .git/fixture-gh -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "diverged push refused, exit 4" 4 $? check "remote tip unchanged (no force)" "$remote_tip" "$(git -C .git/remote.git rev-parse refs/heads/fix/timeout-retry)" check "no PR created after failed push" "" "$(ls .git/fixture-gh 2>/dev/null || true)" echo "# P10b: push config never widens the push (explicit refspec)" ready_repo -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "first push ok" 0 $? git config push.default matching git checkout -q main @@ -344,7 +345,7 @@ echo advance > main.txt && git add main.txt && git commit -qm "chore: advance lo git checkout -q fix/timeout-retry echo more >> fix.txt && git commit -qam "fix: widen retry window" remote_main=$(git -C .git/remote.git rev-parse refs/heads/main) -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "create ok under push.default=matching" 0 $? check "feature branch pushed" "$(git rev-parse HEAD)" "$(git -C .git/remote.git rev-parse refs/heads/fix/timeout-retry)" check "local main NOT published" "$remote_main" "$(git -C .git/remote.git rev-parse refs/heads/main)" @@ -354,7 +355,7 @@ ready_repo git init -q --bare .git/fork.git git remote add fork "$REPO/.git/fork.git" git push -qu fork fix/timeout-retry -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why) +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why) check "create ok with fork upstream" 0 $? check "origin received the branch" "$(git rev-parse HEAD)" "$(git -C .git/remote.git rev-parse refs/heads/fix/timeout-retry)" echo "$out" | grep -q "note: upstream is fork/fix/timeout-retry" @@ -367,7 +368,7 @@ git push -q origin HEAD:refs/heads/fix/old-name git branch -q --set-upstream-to=origin/fix/old-name old_tip=$(git rev-parse HEAD) echo more >> fix.txt && git commit -qam "fix: widen retry window" -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why) +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why) check "create ok with renamed upstream" 0 $? check "same-name remote branch created" "$(git rev-parse HEAD)" "$(git -C .git/remote.git rev-parse refs/heads/fix/timeout-retry)" check "old upstream name untouched" "$old_tip" "$(git -C .git/remote.git rev-parse refs/heads/fix/old-name)" @@ -376,7 +377,7 @@ check "upstream mismatch noted" 0 $? echo "# P11: success output and captured arguments" ready_repo -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "Requests died on flaky links." -b "Retries twice with backoff.") +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "Requests died on flaky links." -b "Retries twice with backoff.") check "create ok" 0 $? echo "$out" | grep -q '^publication: verified$' check "reports verified publication" 0 $? @@ -389,7 +390,7 @@ check "body sections joined with blank line" "Requests died on flaky links. Retries twice with backoff." "$(cat .git/fixture-gh/body)" check "not draft" "" "$(ls .git/fixture-gh/draft 2>/dev/null || true)" ready_repo -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why --draft) +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why --draft) check "draft create ok" 0 $? if [[ -f .git/fixture-gh/draft ]]; then draft_rc=0; else draft_rc=1; fi check "draft flag passed through" 0 "$draft_rc" @@ -397,7 +398,7 @@ echo "$out" | grep -q '^draft: true$' check "draft stated in report" 0 $? ready_repo : >.git/fixture-gh-create-fail-before -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why 2>&1) +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why 2>&1) check "uncertain create without observed PR refuses" 4 $? echo "$out" | grep -q 'push completed' check "uncertain refusal preserves push effect" 0 $? @@ -405,14 +406,14 @@ echo "$out" | grep -q 'uncertain effect' check "uncertain refusal warns against duplicate" 0 $? ready_repo : >.git/fixture-gh-create-fail-after -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why 2>&1) +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why 2>&1) check "server-accepted create is reconciled" 0 $? echo "$out" | grep -q '^pr-create: observed-after-uncertain-command$' check "reconciled create reports observed effect" 0 $? ready_repo : >.git/fixture-gh-create-fail-after : >.git/fixture-gh-created-wrong-base -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why 2>&1) +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why 2>&1) check "later identity refusal remains a failure" 4 $? echo "$out" | grep -q '^push: completed$' check "later refusal preserves push" 0 $? @@ -423,7 +424,7 @@ check "later refusal preserves observed canonical URL" 0 $? echo "# P12: inspect ready output" ready_repo -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "exit 0" 0 $? echo "$out" | grep -q "mode: ready" check "ready marker" 0 $? @@ -440,7 +441,7 @@ check "diffstat present" 0 $? echo "$out" | grep -q "clean" check "clean tree reported" 0 $? echo dirty >> fix.txt -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q " M fix.txt" check "dirty file listed" 0 $? echo "$out" | grep -q "will NOT be in the PR" @@ -451,7 +452,7 @@ fresh_repo add_remote mock_gh git switch -qc fix/empty-branch -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "exit 0" 0 $? echo "$out" | grep -q "mode: no-commits" check "no-commits marker" 0 $? @@ -467,10 +468,10 @@ git -C .git/remote.git symbolic-ref HEAD refs/heads/develop mock_gh git switch -qc feat/on-develop develop echo f > f.txt && git add f.txt && git commit -qm "feat: on develop" -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "base branch (default): develop" check "remote HEAD wins over local main" 0 $? -"$BASH" "$SCRIPT" create --title "feat: on develop" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "feat: on develop" -b why > /dev/null 2>&1 check "create ok" 0 $? check "PR based on remote default" develop "$(cat .git/fixture-gh/base)" @@ -480,7 +481,7 @@ git remote add origin "$REPO/.git/nonexistent.git" mock_gh git switch -qc fix/offline echo f > f.txt && git add f.txt && git commit -qm "fix: offline" -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check "inspect exit 0" 0 $? echo "$out" | grep -q "mode: ready" check "still ready" 0 $? @@ -491,7 +492,7 @@ echo "# P13: uncommitted changes stay out and stay put" ready_repo echo dirty >> fix.txt echo scratch > notes.txt -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "create ok" 0 $? git status --porcelain | grep -q "^ M fix.txt" check "modified file still dirty" 0 $? @@ -503,7 +504,7 @@ check "no commit created" 2 "$(git rev-list --count HEAD)" echo "# P14: works from a subdirectory" ready_repo mkdir -p sub -out=$(cd sub && "$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why) +out=$(cd sub && "${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why) check "create ok from subdir" 0 $? check "title captured from subdir" "fix: retry request on timeout" "$(cat .git/fixture-gh/title)" @@ -519,24 +520,24 @@ cat > .github/PULL_REQUEST_TEMPLATE.md <<'EOF' ## Testing <!-- How was this verified? --> EOF -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "pr template (.github/PULL_REQUEST_TEMPLATE.md)" check "inspect names the template" 0 $? echo "$out" | grep -q "## What Changed" check "inspect prints template content" 0 $? touch .git/fixture-gh-fail -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "Requests died on flaky links." > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "Requests died on flaky links." > /dev/null 2>&1 check "body ignoring template, exit 7 (before the gh dup check)" 7 $? rm .git/fixture-gh-fail -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Requests died on flaky links." -b "## What Changed Retries twice with backoff." > /dev/null 2>&1 check "missing section, exit 7" 7 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Requests died on flaky links." -b "## What Changed" -b "## Testing Unit tests cover exhaustion." > /dev/null 2>&1 check "empty section, exit 7" 7 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why <!-- Explain the motivation before the mechanics. --> Requests died." -b "## What Changed Retries." -b "## Testing @@ -544,7 +545,7 @@ Tests." > /dev/null 2>&1 check "leftover comment, exit 7" 7 $? check "nothing pushed on template errors" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" check "nothing captured" "" "$(ls .git/fixture-gh 2>/dev/null || true)" -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Requests died on flaky links." -b "## What Changed Retries twice with exponential backoff." -b "## Testing Unit tests cover retry exhaustion.") @@ -552,7 +553,7 @@ check "filled template accepted" 0 $? grep -q "## Testing" .git/fixture-gh/body check "template headings in captured body" 0 $? rm -rf .git/fixture-gh -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why ### Context Flaky links kill requests." -b "## What Changed Retries." -b "## Testing @@ -560,7 +561,7 @@ Tests." > /dev/null 2>&1 check "deeper sub-heading counts as content" 0 $? mkdir -p sub rm -rf .git/fixture-gh -out=$(cd sub && "$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +out=$(cd sub && "${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Flaky links." -b "## What Changed Retries." -b "## Testing Tests.") @@ -582,7 +583,7 @@ fbody='## Summary # also code ``` Words about the change.' -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "$fbody" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "$fbody" > /dev/null 2>&1 check "fenced comment and pseudo-headings accepted" 0 $? echo "# P15c: comment-only template — no headings required, comments still rejected" @@ -591,14 +592,14 @@ mkdir -p .github cat > .github/PULL_REQUEST_TEMPLATE.md <<'EOF' <!-- Describe your change and link the ticket. --> EOF -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "Retries twice, refs DAR-123." > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "Retries twice, refs DAR-123." > /dev/null 2>&1 check "comment-only template, plain body accepted" 0 $? ready_repo mkdir -p .github cat > .github/PULL_REQUEST_TEMPLATE.md <<'EOF' <!-- Describe your change and link the ticket. --> EOF -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "<!-- Describe your change and link the ticket. --> Retries twice." > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "<!-- Describe your change and link the ticket. --> Retries twice." > /dev/null 2>&1 check "copied comment rejected, exit 7" 7 $? echo "# P15d: discovery — lookup order, alternates, multi-template dir" @@ -606,16 +607,16 @@ ready_repo cat > PULL_REQUEST_TEMPLATE.md <<'EOF' ## Root Section EOF -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "pr template (PULL_REQUEST_TEMPLATE.md)" check "root template found" 0 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "root template enforced, exit 7" 7 $? mkdir -p .github cat > .github/pull_request_template.md <<'EOF' ## GH Section EOF -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -qi "pr template (.github/pull_request_template.md)" check ".github wins over root" 0 $? rm -rf .github PULL_REQUEST_TEMPLATE.md @@ -623,48 +624,48 @@ mkdir -p docs cat > docs/PULL_REQUEST_TEMPLATE.md <<'EOF' ## Docs Section EOF -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "pr template (docs/PULL_REQUEST_TEMPLATE.md)" check "docs/ fallback found" 0 $? rm -rf docs mkdir -p .github/PULL_REQUEST_TEMPLATE echo "## A" > .github/PULL_REQUEST_TEMPLATE/feature.md -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "pr template (.github/PULL_REQUEST_TEMPLATE/feature.md)" check "one directory template is selected automatically" 0 $? echo "$out" | grep -q "## A" check "one directory template is printed" 0 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "one directory template is enforced, exit 7" 7 $? echo "## B" > .github/PULL_REQUEST_TEMPLATE/bugfix.md -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "multiple PR templates" check "multi-template note present" 0 $? echo "$out" | grep -q "feature.md" check "template names listed" 0 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "multiple templates require a choice, exit 7" 7 $? check "missing choice does not push" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" check "missing choice does not create a PR" "" "$(ls .git/fixture-gh 2>/dev/null || true)" -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why --template ../feature.md > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why --template ../feature.md > /dev/null 2>&1 check "template selection cannot escape its directory, exit 2" 2 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why --template missing.md > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why --template missing.md > /dev/null 2>&1 check "unknown template selection, exit 7" 7 $? echo "## Outside" > outside.md ln -s ../../outside.md .github/PULL_REQUEST_TEMPLATE/linked.md -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Outside +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Outside Unlisted content." --template linked.md > /dev/null 2>&1 check "unlisted symlink template is refused, exit 7" 7 $? -out=$("$BASH" "$SCRIPT" inspect --template feature.md) +out=$("${ENTRYPOINT[@]}" inspect --template feature.md) check "inspect accepts an exact selected template" 0 $? echo "$out" | grep -q "pr template (.github/PULL_REQUEST_TEMPLATE/feature.md)" check "inspect names the selected template" 0 $? echo "$out" | grep -q "## A" check "inspect prints the selected template" 0 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## B +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## B Wrong template." --template feature.md > /dev/null 2>&1 check "selected template shape is enforced, exit 7" 7 $? -out=$("$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## A +out=$("${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## A Feature behavior is covered." --template feature.md) check "exact selected template accepted" 0 $? check "selected template body captured" "## A @@ -674,7 +675,7 @@ echo "# P15e: truncation note for long templates" ready_repo mkdir -p .github { echo "## Big"; for i in $(seq 1 120); do echo "line $i"; done; } > .github/PULL_REQUEST_TEMPLATE.md -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) echo "$out" | grep -q "template truncated at 100 lines (121 total)" check "truncation noted with full line count" 0 $? TOP=$(git rev-parse --show-toplevel) @@ -690,13 +691,13 @@ cat > .github/PULL_REQUEST_TEMPLATE.md <<'EOF' ## Testing EOF big=$(awk 'BEGIN{for(i=0;i<8000;i++) printf "word %d ab. ", i}') -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Flaky links kill requests. $big" -b "## Testing Unit tests cover exhaustion." > /dev/null 2>&1 check "90KB body with filled template accepted" 0 $? rm -rf .git/fixture-gh -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Flaky links. $big" -b "## Testing Tests. @@ -713,38 +714,38 @@ cat > .github/PULL_REQUEST_TEMPLATE.md <<'EOF' ## Testing EOF -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "plain body" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "plain body" > /dev/null 2>&1 check "up-to-three-space-indented headings are enforced, exit 7" 7 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Flaky links." -b " ## Testing Tests." > /dev/null 2>&1 check "indented heading must be kept verbatim, exit 7" 7 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b " ## Why" -b " ## Testing +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b " ## Why" -b " ## Testing Tests." > /dev/null 2>&1 check "indented peer heading does not fill an empty section, exit 7" 7 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b " ## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b " ## Why Flaky links." -b " ## Testing Tests." > /dev/null 2>&1 check "filled indented template headings accepted" 0 $? ready_repo mkdir -p .github printf '##\tTracking\n' > .github/PULL_REQUEST_TEMPLATE.md -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "no tracking heading" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "no tracking heading" > /dev/null 2>&1 check "tab-after-hashes heading enforced, exit 7" 7 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "$(printf '##\tTracking\nRefs DAR-123.')" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "$(printf '##\tTracking\nRefs DAR-123.')" > /dev/null 2>&1 check "tab heading satisfied verbatim" 0 $? ready_repo mkdir -p .github printf '\357\273\277## Why\n' > .github/PULL_REQUEST_TEMPLATE.md -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "plain body" > /dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "plain body" > /dev/null 2>&1 check "BOM does not hide the first heading, exit 7" 7 $? -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Flaky links." > /dev/null 2>&1 check "BOM template satisfied" 0 $? ready_repo mkdir -p .github printf '## Fix C:\\new path handling\n' > .github/PULL_REQUEST_TEMPLATE.md -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b '## Fix C:\new path handling +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b '## Fix C:\new path handling Escapes normalized.' > /dev/null 2>&1 check "backslash heading satisfiable (no awk -v mangling)" 0 $? @@ -762,7 +763,7 @@ cat > .github/PULL_REQUEST_TEMPLATE.md <<'EOF' # pseudo heading in tilde fence ~~~ EOF -"$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b "## Why +"${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b "## Why Flaky links." > /dev/null 2>&1 check "indented and tilde fences hide pseudo-headings" 0 $? @@ -774,11 +775,11 @@ else mkdir -p .github echo "## Why" > .github/PULL_REQUEST_TEMPLATE.md chmod 000 .github/PULL_REQUEST_TEMPLATE.md - out=$("$BASH" "$SCRIPT" inspect 2>&1) + out=$("${ENTRYPOINT[@]}" inspect 2>&1) check "inspect survives unreadable template" 0 $? echo "$out" | grep -q "exists but is not readable" check "inspect notes the unreadable template" 0 $? - "$BASH" "$SCRIPT" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 + "${ENTRYPOINT[@]}" create --title "fix: retry request on timeout" -b why > /dev/null 2>&1 check "create refuses, exit 3 (no silent GW-P8 bypass)" 3 $? check "nothing pushed" "" "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" chmod 644 .github/PULL_REQUEST_TEMPLATE.md @@ -803,12 +804,12 @@ for tracking in absent present; do git config remote.origin.mirror true else git tag fix/timeout-retry main - out=$("$BASH" "$SCRIPT" inspect) + out=$("${ENTRYPOINT[@]}" inspect) check "$tracking upstream: inspection accepts same-named tag" 0 $? grep -qxF '## cur branch: fix/timeout-retry' <<< "$out" check "$tracking upstream: inspection reports exact branch despite tag" 0 $? fi - create_out=$("$BASH" "$SCRIPT" create --title 'fix: retry request on timeout' -b why 2>&1) + create_out=$("${ENTRYPOINT[@]}" create --title 'fix: retry request on timeout' -b why 2>&1) create_rc=$? if [[ "$create_rc" -ne 0 ]]; then printf '%s\n' "$create_out"; fi check "$tracking upstream, $config: create succeeds" 0 "$create_rc" @@ -825,11 +826,11 @@ ready_repo git branch release main git push -q origin release git push -q origin HEAD:refs/heads/main -out=$("$BASH" "$SCRIPT" inspect) +out=$("${ENTRYPOINT[@]}" inspect) check 'default inspection succeeds' 0 $? grep -q 'mode: no-commits' <<< "$out" check 'already merged feature has no default-base commits' 0 $? -out=$("$BASH" "$SCRIPT" inspect --base release) +out=$("${ENTRYPOINT[@]}" inspect --base release) check 'selected-base inspection succeeds' 0 $? grep -q 'mode: ready' <<< "$out" check 'feature still ahead of release is ready' 0 $? @@ -845,15 +846,15 @@ check 'inspection publishes nothing' '' "$(git -C .git/remote.git for-each-ref r mkdir -p .github/PULL_REQUEST_TEMPLATE printf '## Release\nDescribe the backport.\n' >.github/PULL_REQUEST_TEMPLATE/release.md printf '## Feature\nDescribe the feature.\n' >.github/PULL_REQUEST_TEMPLATE/feature.md -out=$("$BASH" "$SCRIPT" inspect --template release.md --base release) +out=$("${ENTRYPOINT[@]}" inspect --template release.md --base release) check 'template reinspection retains selected base' 0 $? grep -q 'base branch (selected): release' <<< "$out" check 'template context uses release' 0 $? -"$BASH" "$SCRIPT" create --title 'fix: retry request on timeout' -b $'## Release\nBackport retries.' --base release --template release.md >/dev/null 2>&1 +"${ENTRYPOINT[@]}" create --title 'fix: retry request on timeout' -b $'## Release\nBackport retries.' --base release --template release.md >/dev/null 2>&1 check 'creation agrees with selected-base readiness' 0 $? check 'forge receives selected base' release "$(cat .git/fixture-gh/base)" git push -q origin HEAD:refs/heads/release -out=$("$BASH" "$SCRIPT" inspect --base release) +out=$("${ENTRYPOINT[@]}" inspect --base release) check 'selected no-commits inspection succeeds' 0 $? grep -q 'mode: no-commits.*origin/release' <<< "$out" check 'nothing ahead of selected base stops' 0 $? @@ -861,16 +862,16 @@ check 'nothing ahead of selected base stops' 0 $? ready_repo git branch local-only main for bad_base in no-such local-only fix/timeout-retry ''; do - "$BASH" "$SCRIPT" inspect --base "$bad_base" >/dev/null 2>&1 + "${ENTRYPOINT[@]}" inspect --base "$bad_base" >/dev/null 2>&1 check "invalid selected base '$bad_base' refused" 2 $? - "$BASH" "$SCRIPT" create --title 'fix: retry request on timeout' -b why --base "$bad_base" >/dev/null 2>&1 + "${ENTRYPOINT[@]}" create --title 'fix: retry request on timeout' -b why --base "$bad_base" >/dev/null 2>&1 check "creation refuses the same invalid base '$bad_base'" 2 $? done -"$BASH" "$SCRIPT" inspect --base >/dev/null 2>&1 +"${ENTRYPOINT[@]}" inspect --base >/dev/null 2>&1 check 'dangling inspect base refused' 2 $? check 'base refusals publish nothing' '' "$(git -C .git/remote.git for-each-ref refs/heads/fix/timeout-retry)" git switch -q main -out=$("$BASH" "$SCRIPT" inspect --base local-only) +out=$("${ENTRYPOINT[@]}" inspect --base local-only) check 'explicit base cannot bypass default-branch guard' 0 $? grep -q 'mode: wrong-branch' <<< "$out" check 'default branch remains forbidden' 0 $? diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.test.sh b/plugins/capability/darrow-git/backend/tests/shell/create-pr-publication.test.sh similarity index 85% rename from plugins/capability/darrow-git/skills/create-pr/scripts/publication.test.sh rename to plugins/capability/darrow-git/backend/tests/shell/create-pr-publication.test.sh index d604c40d..0c83856a 100644 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.test.sh +++ b/plugins/capability/darrow-git/backend/tests/shell/create-pr-publication.test.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Real Git remote; gh only supplies forge observations. No network effects. set -euo pipefail -SCRIPT=$(cd "$(dirname "$0")" && pwd -P)/pr.sh +backend=$(cd "$(dirname "$0")/../.." && pwd -P) +ENTRYPOINT=(uv run --quiet --frozen --no-dev --project "$backend" darrow-create-pr) test_parent=${TMPDIR:-/tmp} work=$(mktemp -d "${test_parent%/}/pr-publication.XXXXXX") work=$(cd "$work" && pwd -P) @@ -79,7 +80,7 @@ fresh() { } remote_tip() { git ls-remote origin refs/heads/fix/103 | awk '{print $1}'; } refuses() { - if bash "$SCRIPT" "$@" >"$work/output" 2>&1; then + if "${ENTRYPOINT[@]}" "$@" >"$work/output" 2>&1; then echo "FAIL: expected refusal: $*" >&2 exit 1 fi @@ -87,7 +88,7 @@ refuses() { } fresh -if ! bash "$SCRIPT" publish-existing --expected-head "$intended" >"$work/output" 2>&1; then +if ! "${ENTRYPOINT[@]}" publish-existing --expected-head "$intended" >"$work/output" 2>&1; then cat "$work/output" echo 'FAIL: authorized reuse must publish the additional local commit' >&2 exit 1 @@ -100,10 +101,10 @@ grep -Fx "pr-commit: $intended" "$work/output" grep -Fx 'publication: verified' "$work/output" test "$(git rev-list --count HEAD)" = 3 printf 'uncommitted\n' >scratch.txt -bash "$SCRIPT" verify --expected-head "$intended" >"$work/output" +"${ENTRYPOINT[@]}" verify --expected-head "$intended" >"$work/output" grep -F 'scratch.txt' "$work/output" test -f scratch.txt -bash "$SCRIPT" publish-existing --expected-head "$intended" >"$work/output" +"${ENTRYPOINT[@]}" publish-existing --expected-head "$intended" >"$work/output" grep -Fx 'push: none' "$work/output" for mode in unavailable missing stale wrong-base wrong-head draft fork malformed duplicate; do @@ -123,31 +124,31 @@ refuses publish-existing --expected-head "$intended" fresh export FORGE_MODE=draft -bash "$SCRIPT" publish-existing --expected-head "$intended" --draft >"$work/output" +"${ENTRYPOINT[@]}" publish-existing --expected-head "$intended" --draft >"$work/output" grep -Fx 'draft: true' "$work/output" fresh export FORGE_MODE=stale-after-push -if bash "$SCRIPT" publish-existing --expected-head "$intended" >"$work/output" 2>&1; then +if "${ENTRYPOINT[@]}" publish-existing --expected-head "$intended" >"$work/output" 2>&1; then echo 'FAIL: stale forge evidence must not complete publication' >&2; exit 1 fi test "$(remote_tip)" = "$intended" grep -F 'push completed' "$work/output" if grep -q '^publication: verified$' "$work/output"; then exit 1; fi export FORGE_MODE='' -bash "$SCRIPT" verify --expected-head "$intended" >"$work/output" +"${ENTRYPOINT[@]}" verify --expected-head "$intended" >"$work/output" grep -Fx 'push: none' "$work/output" fresh export FORGE_MODE=propagating -bash "$SCRIPT" publish-existing --expected-head "$intended" >"$work/output" +"${ENTRYPOINT[@]}" publish-existing --expected-head "$intended" >"$work/output" grep -Fx 'publication: verified' "$work/output" test "$(remote_tip)" = "$intended" test "$(awk '/^pr list / {n++} END {print n+0}' .git/forge-calls)" = 3 fresh export FORGE_MODE=changed-identity -if bash "$SCRIPT" publish-existing --expected-head "$intended" >"$work/output" 2>&1; then +if "${ENTRYPOINT[@]}" publish-existing --expected-head "$intended" >"$work/output" 2>&1; then echo 'FAIL: changed PR identity must not be retried into success' >&2; exit 1 fi test "$(remote_tip)" = "$intended" @@ -178,7 +179,7 @@ git commit -qm 'chore: local only' git switch -q fix/103 mkdir subdir cd subdir -bash "$SCRIPT" publish-existing --expected-head "$intended" >"$work/output" +"${ENTRYPOINT[@]}" publish-existing --expected-head "$intended" >"$work/output" test "$(git --git-dir="$work/remote-$n.git" rev-list --count main)" = 1 test -z "$(git --git-dir="$work/remote-$n.git" tag --list)" grep -Fx 'publication: verified' "$work/output" @@ -187,6 +188,6 @@ git branch develop main git push -q origin develop git --git-dir="$work/remote-$n.git" symbolic-ref HEAD refs/heads/develop refuses publish-existing --expected-head "$intended" -bash "$SCRIPT" publish-existing --expected-head "$intended" --base main >"$work/output" +"${ENTRYPOINT[@]}" publish-existing --expected-head "$intended" --base main >"$work/output" grep -Fx 'publication: verified' "$work/output" echo 'all publication checks passed' diff --git a/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.test.sh b/plugins/capability/darrow-git/backend/tests/shell/prepare-task-branch-branch.test.sh similarity index 85% rename from plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.test.sh rename to plugins/capability/darrow-git/backend/tests/shell/prepare-task-branch-branch.test.sh index b0b8bf44..4c98fb6b 100755 --- a/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.test.sh +++ b/plugins/capability/darrow-git/backend/tests/shell/prepare-task-branch-branch.test.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -uo pipefail -script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) -script="$script_dir/branch.sh" +backend=$(cd "$(dirname "$0")/../.." && pwd -P) +ENTRYPOINT=(uv run --quiet --frozen --no-dev --project "$backend" darrow-prepare-task-branch) failures=0 check() { @@ -29,7 +29,7 @@ fresh_repo() { printf '%s\n' '# P1: create, current, and reuse are additive' fresh_repo -output=$("$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123) +output=$("${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123) check 'create exits zero' 0 $? printf '%s\n' "$output" | grep -q '^## mode: created$' check 'create mode reported' 0 $? @@ -39,7 +39,7 @@ git add task.txt git commit -qm 'fix: task change' task_tip=$(git rev-parse HEAD) git tag fix/DAR-123-attribution HEAD^ -output=$("$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123) +output=$("${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123) check 'current exits zero' 0 $? printf '%s\n' "$output" | grep -q '^## mode: current$' check 'current mode reported' 0 $? @@ -50,7 +50,7 @@ git switch -q main printf '%s\n' advance >advance.txt git add advance.txt git commit -qm 'feat: advance main' -output=$("$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123) +output=$("${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123) check 'reuse exits zero' 0 $? printf '%s\n' "$output" | grep -q '^## mode: reused$' check 'reuse mode reported' 0 $? @@ -66,7 +66,7 @@ git switch -qc feat/conflicting printf '%s\n' other >base.txt git commit -qam 'feat: conflicting branch' printf '%s\n' dirty >>base.txt -"$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 >/dev/null 2>&1 check 'dirty switch refused' 4 $? check 'original branch remains active' feat/conflicting "$(git branch --show-current)" check 'dirty content survives' dirty "$(tail -n 1 base.txt)" @@ -74,11 +74,11 @@ check 'stash remains empty' '' "$(git stash list)" printf '%s\n' '# P3: exact name and provider token are mandatory' fresh_repo -"$BASH" "$script" prepare fix/request-attribution >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/request-attribution >/dev/null 2>&1 check 'missing token refused' 2 $? -"$BASH" "$script" prepare fix/preserve-DAR-123 --ticket-token DAR-123 >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/preserve-DAR-123 --ticket-token DAR-123 >/dev/null 2>&1 check 'non-leading token refused' 5 $? -"$BASH" "$script" prepare fix/DAR-123-repeat-DAR-123 --ticket-token DAR-123 >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/DAR-123-repeat-DAR-123 --ticket-token DAR-123 >/dev/null 2>&1 check 'repeated token refused' 5 $? check 'validation refusals leave main active' main "$(git branch --show-current)" @@ -91,7 +91,7 @@ git switch -q main printf '%s\n' main >base.txt git commit -qam 'feat: main' git merge side -q >/dev/null 2>&1 || true -"$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 >/dev/null 2>&1 check 'conflict refused' 8 $? check 'no task branch created' '' "$(git branch --list fix/DAR-123-attribution)" @@ -99,7 +99,7 @@ printf '%s\n' '# P5: explicit worktree preparation preserves the caller checkout fresh_repo printf '%s\n' dirty >>base.txt worktree_path="$repo/task-worktree" -output=$("$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree --at "$worktree_path") +output=$("${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree --at "$worktree_path") check 'worktree creation exits zero' 0 $? printf '%s\n' "$output" | grep -q '^## mode: worktree-created$' check 'worktree creation mode reported' 0 $? @@ -113,7 +113,7 @@ fresh_repo git branch fix/DAR-123-attribution task_tip=$(git rev-parse refs/heads/fix/DAR-123-attribution) worktree_path="$repo/reused-worktree" -output=$("$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree --at "$worktree_path") +output=$("${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree --at "$worktree_path") check 'existing branch worktree reuse exits zero' 0 $? printf '%s\n' "$output" | grep -q '^## mode: worktree-reused$' check 'existing branch worktree mode reported' 0 $? @@ -131,7 +131,7 @@ printf '%s\n' other >base.txt git commit -qam 'feat: conflicting side' git switch -q main git -C "$worktree_path" merge conflict-side -q >/dev/null 2>&1 || true -"$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 check 'conflict in an existing task worktree is refused' 8 $? check 'caller stays on main after target-worktree conflict' main "$(git branch --show-current)" check 'target worktree remains conflicted' 1 "$(git -C "$worktree_path" diff --name-only --diff-filter=U | wc -l | tr -d '[:space:]')" @@ -140,7 +140,7 @@ fresh_repo worktree_path="$repo/stale-worktree" git worktree add -q -b fix/DAR-123-attribution "$worktree_path" mv "$worktree_path" "$worktree_path.missing" -"$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 check 'stale registered task worktree is refused' 9 $? check 'caller stays on main after stale-worktree refusal' main "$(git branch --show-current)" @@ -157,13 +157,13 @@ git -C "$worktree_path" add replacement.txt git -C "$worktree_path" commit -qm 'chore: replacement repository' git -C "$worktree_path" switch -qc fix/DAR-123-attribution cd "$caller_repo" || exit 70 -"$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 check 'different repository at registered path is refused' 9 $? check 'caller stays on main after cross-repository refusal' main "$(git branch --show-current)" fresh_repo worktree_path="$repo/visible-worktree" -output=$("$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree --at "$worktree_path") +output=$("${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree --at "$worktree_path") check 'custom in-repository worktree exits zero' 0 $? printf '%s\n' "$output" | grep -q 'not ignored.*git status will list it' check 'visible custom worktree side effect is reported' 0 $? @@ -186,7 +186,7 @@ chmod +x "$fake_bin/git" worktree_path="$repo/race-worktree" PATH="$fake_bin:$PATH" RACE_REAL_GIT="$real_git" \ RACE_BRANCH=fix/DAR-123-attribution \ - "$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 \ + "${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 \ --worktree --at "$worktree_path" >/dev/null 2>&1 check 'concurrent branch makes worktree preparation fail' 4 $? git show-ref -q --verify refs/heads/fix/DAR-123-attribution @@ -210,7 +210,7 @@ chmod +x "$fake_bin/git" exclude=$(git rev-parse --git-path info/exclude) exclude_before=$(cat "$exclude") PATH="$fake_bin:$PATH" FAIL_REAL_GIT="$real_git" \ - "$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 \ + "${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 \ --worktree >/dev/null 2>&1 check 'simulated default worktree preparation fails' 4 $? check 'exclude file is unchanged after refusal' "$exclude_before" "$(cat "$exclude")" @@ -238,7 +238,7 @@ for branch_state in missing existing; do expected_mode=worktree-created fi original_tip=$(git rev-parse HEAD) - output=$("$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree) + output=$("${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree) check "$branch_state branch prepares with absent excludes" 0 $? grep -qxF "## mode: $expected_mode" <<< "$output" check 'correct worktree mode reported' 0 $? @@ -266,7 +266,7 @@ exec "$FAIL_REAL_GIT" "$@" EOF chmod +x "$fake_bin/git" PATH="$fake_bin:$PATH" FAIL_REAL_GIT="$real_git" \ - "$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 + "${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 check 'worktree failure reaches the add operation' 4 $? if [[ ! -e .git/info/exclude ]]; then path_status=0; else path_status=1; fi check 'failed worktree does not create exclude file' 0 "$path_status" @@ -280,7 +280,7 @@ exclude=$(git rev-parse --git-path info/exclude) exclude_before=$(cat "$exclude") chmod 000 "$exclude" if [[ ! -r "$exclude" ]]; then - output=$("$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree 2>&1) + output=$("${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree 2>&1) check 'unreadable exclude refuses' 9 $? grep -q 'exclude file is unreadable' <<< "$output" check 'unreadable configuration is identified' 0 $? @@ -298,7 +298,7 @@ fresh_repo exclude=$(git rev-parse --git-path info/exclude) exclude_before=$(cat "$exclude") chmod 555 .git/info -"$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 check 'writable existing file can be appended in read-only parent' 0 $? chmod 755 .git/info check 'existing exclude rules preserved with appended worktree rule' "$exclude_before"$'\n/.worktrees/' "$(cat "$exclude")" @@ -307,7 +307,7 @@ printf '%s\n' '# P12: broken exclude symlinks are unreadable configuration' fresh_repo --template= mkdir -p .git/info ln -s missing-exclude-target .git/info/exclude -"$BASH" "$script" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 +"${ENTRYPOINT[@]}" prepare fix/DAR-123-attribution --ticket-token DAR-123 --worktree >/dev/null 2>&1 check 'broken exclude symlink refuses' 9 $? if [[ -L .git/info/exclude && ! -e .git/info/missing-exclude-target ]]; then path_status=0; else path_status=1; fi check 'broken exclude link is left intact' 0 "$path_status" diff --git a/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/discovery.test.sh b/plugins/capability/darrow-git/backend/tests/shell/prepare-task-branch-discovery.test.sh similarity index 77% rename from plugins/capability/darrow-git/skills/prepare-task-branch/scripts/discovery.test.sh rename to plugins/capability/darrow-git/backend/tests/shell/prepare-task-branch-discovery.test.sh index ddcb5f86..03625a1b 100644 --- a/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/discovery.test.sh +++ b/plugins/capability/darrow-git/backend/tests/shell/prepare-task-branch-discovery.test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) -script="$script_dir/branch.sh" +backend=$(cd "$(dirname "$0")/../.." && pwd -P) +ENTRYPOINT=(uv run --quiet --frozen --no-dev --project "$backend" darrow-prepare-task-branch) temp_parent=${TMPDIR:-/tmp} case "$temp_parent" in /) temp_template=/task-discovery.XXXXXX ;; @@ -18,7 +18,7 @@ git commit -qm 'chore: init' --allow-empty git branch fix/DAR-123-original-suffix tip=$(git rev-parse HEAD) before=$(git show-ref) -output=$("$BASH" "$script" discover --ticket-token DAR-123) || { +output=$("${ENTRYPOINT[@]}" discover --ticket-token DAR-123) || { printf '%s\n' 'FAIL: complete ticket discovery is unavailable' >&2 exit 1 } @@ -27,13 +27,13 @@ grep -Fx '## matches: 1' <<< "$output" grep -Fx "fix/DAR-123-original-suffix (at $tip)" <<< "$output" test "$(git branch --show-current)" = main test "$(git show-ref)" = "$before" -"$BASH" "$script" prepare fix/DAR-123-original-suffix --ticket-token DAR-123 --from main +"${ENTRYPOINT[@]}" prepare fix/DAR-123-original-suffix --ticket-token DAR-123 --from main test "$(git rev-parse HEAD)" = "$tip" test "$(git branch --show-current)" = fix/DAR-123-original-suffix test "$(git show-ref)" = "$before" git switch -q main status=0 -output=$("$BASH" "$script" prepare feat/DAR-123-different-suffix --ticket-token DAR-123 2>&1) || status=$? +output=$("${ENTRYPOINT[@]}" prepare feat/DAR-123-different-suffix --ticket-token DAR-123 2>&1) || status=$? test "$status" = 9 || { printf 'FAIL: creation with a prior correlated branch must refuse (got %s)\n' "$status" >&2 exit 1 @@ -48,11 +48,11 @@ exclude=$(git rev-parse --git-path info/exclude) exclude_before=$(cksum < "$exclude") printf '%s\n' preserve >untracked.txt dirty=$(git status --porcelain) -output=$("$BASH" "$script" discover --ticket-token DAR-123) +output=$("${ENTRYPOINT[@]}" discover --ticket-token DAR-123) grep -Fx '## matches: 2' <<< "$output" test "$(printf '%s\n' "$output" | sed -n '4p')" = "feat/DAR-123-second (at $tip)" status=0 -output=$("$BASH" "$script" prepare feat/DAR-123-third --ticket-token DAR-123 --worktree 2>&1) || status=$? +output=$("${ENTRYPOINT[@]}" prepare feat/DAR-123-third --ticket-token DAR-123 --worktree 2>&1) || status=$? test "$status" = 9 grep -Fx '## matches: 2' <<< "$output" test "$(git show-ref)" = "$before" @@ -62,7 +62,7 @@ test "$(git status --porcelain)" = "$dirty" test ! -e .worktrees test "$(git branch --show-current)" = main # Explicit selection remains usable even when discovery returned several names. -"$BASH" "$script" prepare feat/DAR-123-second --ticket-token DAR-123 +"${ENTRYPOINT[@]}" prepare feat/DAR-123-second --ticket-token DAR-123 test "$(git show-ref)" = "$before" git switch -q main @@ -81,13 +81,13 @@ git branch custom/issue-84-type git branch fix/issue-84-Upper git update-ref refs/remotes/origin/fix/issue-84-remote HEAD before=$(git show-ref) -output=$("$BASH" "$script" discover --ticket-token issue-84) +output=$("${ENTRYPOINT[@]}" discover --ticket-token issue-84) grep -Fx '## matches: 1' <<< "$output" grep -Fx "fix/issue-84-original (at $tip)" <<< "$output" test "$(git show-ref)" = "$before" -output=$("$BASH" "$script" discover --ticket-token issue-85) +output=$("${ENTRYPOINT[@]}" discover --ticket-token issue-85) grep -Fx '## matches: 0' <<< "$output" -"$BASH" "$script" prepare fix/issue-85-new --ticket-token issue-85 +"${ENTRYPOINT[@]}" prepare fix/issue-85-new --ticket-token issue-85 test "$(git branch --show-current)" = fix/issue-85-new test "$(git rev-parse HEAD)" = "$tip" test "$(git worktree list --porcelain | grep -c '^worktree ')" = 1 @@ -97,7 +97,7 @@ before=$(git show-ref) for bad_token in '' 'issue 84' '-84' 'issue/84' 'a:b' 'a..b' \ '12345678901234567890123456789012345678901234567890123456'; do status=0 - output=$("$BASH" "$script" discover --ticket-token "$bad_token" 2>&1) || status=$? + output=$("${ENTRYPOINT[@]}" discover --ticket-token "$bad_token" 2>&1) || status=$? test "$status" != 0 || { printf 'FAIL: invalid token must refuse discovery: %s\n' "$bad_token" >&2 exit 1 @@ -106,10 +106,10 @@ for bad_token in '' 'issue 84' '-84' 'issue/84' 'a:b' 'a..b' \ done test "$(git show-ref)" = "$before" long_token=1234567890123456789012345678901234567890123456789012345 -output=$("$BASH" "$script" discover --ticket-token "$long_token") +output=$("${ENTRYPOINT[@]}" discover --ticket-token "$long_token") grep -Fx '## matches: 0' <<< "$output" -"$BASH" "$script" prepare "ci/${long_token}-a" --ticket-token "$long_token" -output=$("$BASH" "$script" discover --ticket-token "$long_token") +"${ENTRYPOINT[@]}" prepare "ci/${long_token}-a" --ticket-token "$long_token" +output=$("${ENTRYPOINT[@]}" discover --ticket-token "$long_token") grep -Fx '## matches: 1' <<< "$output" test "$(git rev-parse HEAD)" = "$tip" before=$(git show-ref) @@ -123,7 +123,7 @@ printf '%s\n' '#!/usr/bin/env bash' \ chmod +x .git/fake-bin/git status=0 output=$(PATH="$repo/.git/fake-bin:$PATH" DISCOVERY_REAL_GIT="$real_git" \ - "$BASH" "$script" discover --ticket-token issue-84 2>&1) || status=$? + "${ENTRYPOINT[@]}" discover --ticket-token issue-84 2>&1) || status=$? test "$status" = 3 if grep -F '## matches:' <<< "$output"; then exit 1; fi test "$(git show-ref)" = "$before" diff --git a/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.test.sh b/plugins/capability/darrow-git/backend/tests/shell/publish-pr-evidence-publish.test.sh similarity index 89% rename from plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.test.sh rename to plugins/capability/darrow-git/backend/tests/shell/publish-pr-evidence-publish.test.sh index 1b53308f..708bd234 100644 --- a/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.test.sh +++ b/plugins/capability/darrow-git/backend/tests/shell/publish-pr-evidence-publish.test.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SCRIPT=$(cd "$(dirname "$0")" && pwd -P)/publish.sh +backend=$(cd "$(dirname "$0")/../.." && pwd -P) +ENTRYPOINT=(uv run --quiet --frozen --no-dev --project "$backend" darrow-publish-pr-evidence) test_parent=${TMPDIR:-/tmp}; case "$test_parent" in /) template=/publish-pr-evidence-test.XXXXXX ;; */) template=${test_parent}publish-pr-evidence-test.XXXXXX ;; *) template=$test_parent/publish-pr-evidence-test.XXXXXX ;; esac work=$(mktemp -d "$template"); work=$(cd "$work" && pwd -P) trap '[[ "${KEEP_TEST_FILES:-}" == 1 ]] || rm -rf "$work"' EXIT @@ -65,10 +66,10 @@ fresh() { body="$work/evidence/body-$1.txt"; printf 'Verification passed for candidate %s.\n' "$expected" > "$body" unset NO_ATTACH STRICT_ATTACH DISTRACTING_LIMIT HOST_MODE FORGE_HEAD_MODE COMMENT_MODE } -refuses() { if "$BASH" "$SCRIPT" "$@" >"$work/out" 2>&1; then echo "expected refusal: $*" >&2; exit 1; fi; grep -Eq '^outcome: (refused|partial|ambiguous)$' "$work/out"; } +refuses() { if "${ENTRYPOINT[@]}" "$@" >"$work/out" 2>&1; then echo "expected refusal: $*" >&2; exit 1; fi; grep -Eq '^outcome: (refused|partial|ambiguous)$' "$work/out"; } fresh 1 -if ! "$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" >"$work/out"; then cat "$work/out"; exit 1; fi +if ! "${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" >"$work/out"; then cat "$work/out"; exit 1; fi grep -Fx 'outcome: published' "$work/out" grep -Fx 'attachment-count: 0' "$work/out" grep -Fx 'head-preserved: true' "$work/out"; grep -Fx 'index-preserved: true' "$work/out" @@ -77,7 +78,7 @@ if grep -Fq 'gsub' .git/api-calls; then echo 'comment observation must use one T test -f "$(sed -n 's/^prepared-body: //p' "$work/out")" test "$(tail -c 1 "$(sed -n 's/^prepared-body: //p' "$work/out")" | od -An -tx1 | tr -d '[:space:]')" = 3e test "$(wc -l < .git/comment-calls | tr -d ' ')" -eq 1 -"$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" >"$work/out" +"${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" >"$work/out" grep -Fx 'outcome: existing' "$work/out" test "$(wc -l < .git/comment-calls | tr -d ' ')" -eq 1 cat .git/comments >>.git/comments.copy; cat .git/comments >>.git/comments.copy; mv .git/comments.copy .git/comments @@ -90,7 +91,7 @@ png="$work/evidence/view.png"; jpg="$work/evidence/detail.svg"; video="$work/evi printf '\211PNG\r\n\032\n\000\000\000\015IHDR\000\000\000\001\000\000\000\001\010\006\000\000\000\037\025\304\211\000\000\000\015IDAT\010\327c\370\317\300\360\037\000\005\000\001\377\211\231\075\035\000\000\000\000IEND\256B\140\202' > "$png" printf '<svg xmlns="http://www.w3.org/2000/svg"><circle r="1"/></svg>\n' > "$jpg" printf '\000\000\000\030ftypmp42\000\000\000\000mp42isom\000\000\000\010mdat' > "$video" -"$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" --image "$png" --alt 'Overview screenshot' --video "$video" --explanation 'Recording of the verified flow' --image "$jpg" --alt 'Detailed result' >"$work/out" +"${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" --image "$png" --alt 'Overview screenshot' --video "$video" --explanation 'Recording of the verified flow' --image "$jpg" --alt 'Detailed result' >"$work/out" grep -Fx 'outcome: published' "$work/out"; grep -Fx 'attachment-count: 3' "$work/out" grep -F 'Image 1 alt text: Overview screenshot' "$(sed -n 's/^prepared-body: //p' "$work/out")" grep -F "content identity $(git hash-object "$png")" "$(sed -n 's/^prepared-body: //p' "$work/out")" @@ -135,9 +136,9 @@ unset FORGE_HEAD_MODE fresh 5 p1="$work/evidence/path-one.svg"; p2="$work/evidence/path-two.svg"; printf '<svg xmlns="http://www.w3.org/2000/svg"><text>identity</text></svg>\n' > "$p1"; cp "$p1" "$p2" -"$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" --image "$p1" --alt 'Same presentation' >"$work/one" +"${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" --image "$p1" --alt 'Same presentation' >"$work/one" rm -f .git/comments .git/comment-calls .git/attach-order -"$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" --image "$p2" --alt 'Same presentation' >"$work/two" +"${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" --image "$p2" --alt 'Same presentation' >"$work/two" test "$(sed -n 's/^identity: //p' "$work/one")" = "$(sed -n 's/^identity: //p' "$work/two")" fresh 6 @@ -167,9 +168,9 @@ test ! -f .git/comment-calls fresh 14 printf 'Windows path C:\\tmp\r\nSecond line.\n' > "$body" -"$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" >"$work/out" +"${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" >"$work/out" grep -Fx 'outcome: published' "$work/out" -"$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" >"$work/out" +"${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" >"$work/out" grep -Fx 'outcome: existing' "$work/out" test "$(wc -l < .git/comment-calls | tr -d ' ')" -eq 1 @@ -190,10 +191,10 @@ refuses publish --expected-head "$expected" --body-file "$body" "${many[@]}" mkdir "$work/tmp-parent" fresh 9 -TMPDIR="$work/tmp-parent" "$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" >"$work/out" +TMPDIR="$work/tmp-parent" "${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" >"$work/out" grep -Fx 'outcome: published' "$work/out" fresh 10 -TMPDIR="$work/tmp-parent/" "$BASH" "$SCRIPT" publish --expected-head "$expected" --body-file "$body" >"$work/out" +TMPDIR="$work/tmp-parent/" "${ENTRYPOINT[@]}" publish --expected-head "$expected" --body-file "$body" >"$work/out" grep -Fx 'outcome: published' "$work/out" echo 'all publish-pr-evidence checks passed' diff --git a/plugins/capability/darrow-git/backend/tests/test_cli_media.py b/plugins/capability/darrow-git/backend/tests/test_cli_media.py index 481a08c1..c9a88898 100644 --- a/plugins/capability/darrow-git/backend/tests/test_cli_media.py +++ b/plugins/capability/darrow-git/backend/tests/test_cli_media.py @@ -30,14 +30,27 @@ def test_console_inspect( assert "## mode:" in capsys.readouterr().out +@pytest.mark.parametrize( + "entrypoint,name", + [ + (cli.create_branch, "darrow-create-branch"), + (cli.prepare_branch, "darrow-prepare-task-branch"), + (cli.create_commit, "darrow-create-commit"), + (cli.create_pr, "darrow-create-pr"), + (cli.publish_evidence, "darrow-publish-pr-evidence"), + ], +) def test_cli_refusal( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + entrypoint: Callable[[], None], + name: str, ) -> None: - monkeypatch.setattr("sys.argv", ["darrow-publish-pr-evidence"]) + monkeypatch.setattr("sys.argv", [name]) with pytest.raises(SystemExit) as failure: - cli.publish_evidence() + entrypoint() assert failure.value.code == 64 - assert "usage: publish.sh" in capsys.readouterr().err + assert f"usage: {name} " in capsys.readouterr().err def test_cli_filesystem_boundary(capsys: pytest.CaptureFixture[str]) -> None: diff --git a/plugins/capability/darrow-git/backend/uv.lock b/plugins/capability/darrow-git/backend/uv.lock index daddfde7..ac0fbf40 100644 --- a/plugins/capability/darrow-git/backend/uv.lock +++ b/plugins/capability/darrow-git/backend/uv.lock @@ -86,7 +86,7 @@ toml = [ [[package]] name = "darrow-git" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } [package.dev-dependencies] diff --git a/plugins/capability/darrow-git/skills/create-branch/SKILL.md b/plugins/capability/darrow-git/skills/create-branch/SKILL.md index 12185570..f072b59d 100644 --- a/plugins/capability/darrow-git/skills/create-branch/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-branch/SKILL.md @@ -13,9 +13,7 @@ branch creation, switching, and worktree safety. Use its output as the source of truth. Correct invalid input and retry; relay a safety refusal or Git failure verbatim and stop. Advice inside a Git error does not authorize another action. -The package lives at `<skill-dir>/../../backend` inside this plugin. The existing -`scripts/branch.sh` path remains a POSIX compatibility launcher for the same -entrypoint, arguments, records, refusals, and exit statuses. +The package lives at `<skill-dir>/../../backend` inside this plugin. ## Workflow diff --git a/plugins/capability/darrow-git/skills/create-branch/scripts/branch.sh b/plugins/capability/darrow-git/skills/create-branch/scripts/branch.sh deleted file mode 100644 index 45ba9527..00000000 --- a/plugins/capability/darrow-git/skills/create-branch/scripts/branch.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Compatibility launcher; all mechanics live in the contained Python package. -set -euo pipefail -plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) -exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-create-branch "$@" diff --git a/plugins/capability/darrow-git/skills/create-commit/SKILL.md b/plugins/capability/darrow-git/skills/create-commit/SKILL.md index 200fcf77..011ba728 100644 --- a/plugins/capability/darrow-git/skills/create-commit/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-commit/SKILL.md @@ -20,9 +20,7 @@ state inspection, staging, message validation, hook execution, and the final commit; execute it without reading or reimplementing it. Treat its refusals as authoritative. -The package lives at `<skill-dir>/../../backend` inside this plugin. The existing -`scripts/commit.sh` path remains a POSIX compatibility launcher for the same -entrypoint, arguments, records, refusals, and exit statuses. +The package lives at `<skill-dir>/../../backend` inside this plugin. ## Working model diff --git a/plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh b/plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh deleted file mode 100755 index 53058518..00000000 --- a/plugins/capability/darrow-git/skills/create-commit/scripts/commit.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Compatibility launcher; all mechanics live in the contained Python package. -set -euo pipefail -plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) -exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-create-commit "$@" diff --git a/plugins/capability/darrow-git/skills/create-pr/SKILL.md b/plugins/capability/darrow-git/skills/create-pr/SKILL.md index 3a78f69d..7ee0ed66 100644 --- a/plugins/capability/darrow-git/skills/create-pr/SKILL.md +++ b/plugins/capability/darrow-git/skills/create-pr/SKILL.md @@ -13,9 +13,7 @@ inspection, duplicate detection, validation, non-force pushing, and PR creation; execute it without reading or reimplementing it. Treat its refusals as authoritative. -The package lives at `<skill-dir>/../../backend` inside this plugin. The existing -`scripts/pr.sh` path remains a POSIX compatibility launcher for the same -entrypoint, arguments, records, refusals, and exit statuses. +The package lives at `<skill-dir>/../../backend` inside this plugin. ## Working model diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/pr.sh b/plugins/capability/darrow-git/skills/create-pr/scripts/pr.sh deleted file mode 100755 index f840b9a4..00000000 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/pr.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Compatibility launcher; all mechanics live in the contained Python package. -set -euo pipefail -plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) -exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-create-pr "$@" diff --git a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh b/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh deleted file mode 100644 index f840b9a4..00000000 --- a/plugins/capability/darrow-git/skills/create-pr/scripts/publication.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Compatibility launcher; all mechanics live in the contained Python package. -set -euo pipefail -plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) -exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-create-pr "$@" diff --git a/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md b/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md index 29c87a39..25123ace 100644 --- a/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md +++ b/plugins/capability/darrow-git/skills/prepare-task-branch/SKILL.md @@ -12,9 +12,7 @@ file. The script owns repository inspection, name and ticket-token validation, complete token discovery, additive creation, exact reuse, switching, worktree allocation, and refusal safety. Use its output as the source of truth. -The package lives at `<skill-dir>/../../backend` inside this plugin. The existing -`scripts/branch.sh` path remains a POSIX compatibility launcher for the same -entrypoint, arguments, records, refusals, and exit statuses. +The package lives at `<skill-dir>/../../backend` inside this plugin. ## 1. Bind discovery or preparation input diff --git a/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.sh b/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.sh deleted file mode 100755 index ebf7ce0a..00000000 --- a/plugins/capability/darrow-git/skills/prepare-task-branch/scripts/branch.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Compatibility launcher; all mechanics live in the contained Python package. -set -euo pipefail -plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) -exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-prepare-task-branch "$@" diff --git a/plugins/capability/darrow-git/skills/publish-pr-evidence/SKILL.md b/plugins/capability/darrow-git/skills/publish-pr-evidence/SKILL.md index 7d5a0092..6371b81e 100644 --- a/plugins/capability/darrow-git/skills/publish-pr-evidence/SKILL.md +++ b/plugins/capability/darrow-git/skills/publish-pr-evidence/SKILL.md @@ -12,9 +12,7 @@ identity, attachment validation, deterministic identity, reconciliation, the single comment invocation, and final observation. Do not reproduce those mechanics with raw `gh` commands. -The package lives at `<skill-dir>/../../backend` inside this plugin. The existing -`scripts/publish.sh` path remains a POSIX compatibility launcher for the same -entrypoint, arguments, records, refusals, and exit statuses. +The package lives at `<skill-dir>/../../backend` inside this plugin. ## Inputs diff --git a/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.sh b/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.sh deleted file mode 100644 index 3366be79..00000000 --- a/plugins/capability/darrow-git/skills/publish-pr-evidence/scripts/publish.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Compatibility launcher; all mechanics live in the contained Python package. -set -euo pipefail -plugin_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P) -exec uv run --quiet --frozen --no-dev --project "$plugin_root/backend" darrow-publish-pr-evidence "$@" diff --git a/plugins/task-recipe/darrow-ticket-to-pr/.claude-plugin/plugin.json b/plugins/task-recipe/darrow-ticket-to-pr/.claude-plugin/plugin.json index e61b9cb1..40bb2a33 100644 --- a/plugins/task-recipe/darrow-ticket-to-pr/.claude-plugin/plugin.json +++ b/plugins/task-recipe/darrow-ticket-to-pr/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "darrow-ticket-to-pr", "description": "Delegate one explicit ticket-to-PR delivery, including required same-PR reviewer evidence", - "version": "0.4.0", + "version": "0.4.1", "license": "BUSL-1.1", "author": { "name": "Björn Rochel", diff --git a/plugins/task-recipe/darrow-ticket-to-pr/.codex-plugin/plugin.json b/plugins/task-recipe/darrow-ticket-to-pr/.codex-plugin/plugin.json index 68fd2c97..ad56013a 100644 --- a/plugins/task-recipe/darrow-ticket-to-pr/.codex-plugin/plugin.json +++ b/plugins/task-recipe/darrow-ticket-to-pr/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-ticket-to-pr", - "version": "0.4.0", + "version": "0.4.1", "description": "Delegate one explicit ticket-to-PR delivery, including required same-PR reviewer evidence", "skills": "./skills/", "interface": { diff --git a/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/composition-bash b/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/composition-bash deleted file mode 100644 index e550d32b..00000000 --- a/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/composition-bash +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -# Fixture-only observation of the real bundled publication entrypoint. -case "${1-}" in - */create-pr/scripts/pr.sh) - if [[ "${2-}" == publish-existing || "${2-}" == verify ]]; then - intended='' - previous='' - for argument in "$@"; do - if [[ "$previous" == --expected-head ]]; then intended=$argument; fi - previous=$argument - done - /bin/bash "$@" - result=$? - if [[ "$result" == 0 ]]; then - dir=$(git rev-parse --absolute-git-dir) || exit 1 - printf '%s\n' "$intended" >>"$dir/builtin-publications" - fi - exit "$result" - fi - ;; -esac -exec /bin/bash "$@" diff --git a/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/composition-uv b/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/composition-uv new file mode 100644 index 00000000..3418785d --- /dev/null +++ b/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/composition-uv @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Fixture-only observation of the real bundled publication entrypoint. +set -uo pipefail +bin=$(cd "$(dirname "$0")" && pwd -P) +IFS= read -r real_uv <"$bin/uv-command" +publication=false +intended='' +previous='' +for argument in "$@"; do + if [[ "$previous" == darrow-create-pr && ( "$argument" == publish-existing || "$argument" == verify ) ]]; then + publication=true + fi + if [[ "$previous" == --expected-head ]]; then intended=$argument; fi + previous=$argument +done +"$real_uv" "$@" +result=$? +if [[ "$result" == 0 && "$publication" == true ]]; then + dir=$(git rev-parse --absolute-git-dir) || exit 1 + printf '%s\n' "$intended" >>"$dir/builtin-publications" +fi +exit "$result" diff --git a/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/setup-composition.sh b/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/setup-composition.sh index 5c4cc3cd..88414f3f 100644 --- a/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/setup-composition.sh +++ b/plugins/task-recipe/darrow-ticket-to-pr/skills/ticket-to-pr/evals/fixtures/setup-composition.sh @@ -20,8 +20,9 @@ printf 'Additional local delivery notes.\n' >>NOTES.md git commit -qam 'docs: clarify timeout intent' git rev-parse HEAD >.git/original-local-head if [[ "$variant" == builtin ]]; then - cp "$fixtures/composition-bash" .git/fixture-bin/bash - chmod +x .git/fixture-bin/bash + command -v uv >.git/fixture-bin/uv-command + cp "$fixtures/composition-uv" .git/fixture-bin/uv + chmod +x .git/fixture-bin/uv fi if [[ "$variant" == replacement ]]; then for host in .agents .claude; do From 88823327c488ae30d2202aeb14926dc5e297ca1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Rochel?= <bjoern@bjro.de> Date: Fri, 18 Sep 2026 09:06:10 +0200 Subject: [PATCH 10/10] refactor(git): simplify argument parsing and command dispatch --- docs/specs/git-workflow.md | 4 +- .../darrow-git/.claude-plugin/plugin.json | 2 +- .../darrow-git/.codex-plugin/plugin.json | 2 +- .../darrow-git/backend/pyproject.toml | 2 +- .../backend/src/darrow_git/arguments.py | 10 +++++ .../backend/src/darrow_git/branch_options.py | 24 +++++------ .../darrow-git/backend/src/darrow_git/cli.py | 26 ++++++++++-- .../backend/src/darrow_git/commit.py | 20 +--------- .../backend/src/darrow_git/commit_options.py | 40 +++++++++---------- .../src/darrow_git/evidence_options.py | 34 ++++++++-------- .../src/darrow_git/evidence_preflight.py | 29 +++++--------- .../backend/src/darrow_git/pr_options.py | 22 +++++----- .../backend/src/darrow_git/pr_repository.py | 12 +----- .../backend/src/darrow_git/process.py | 7 ++++ .../backend/src/darrow_git/publication.py | 27 +++++-------- .../backend/src/darrow_git/repository.py | 4 ++ .../backend/tests/test_argument_contracts.py | 4 +- .../backend/tests/test_cli_media.py | 40 +++++++++++++++++++ .../backend/tests/test_commit_recovery.py | 20 +++++----- .../backend/tests/test_native_workflows.py | 8 ++-- plugins/capability/darrow-git/backend/uv.lock | 2 +- 21 files changed, 188 insertions(+), 151 deletions(-) create mode 100644 plugins/capability/darrow-git/backend/src/darrow_git/arguments.py diff --git a/docs/specs/git-workflow.md b/docs/specs/git-workflow.md index 83408d06..a5ad60c1 100644 --- a/docs/specs/git-workflow.md +++ b/docs/specs/git-workflow.md @@ -30,7 +30,9 @@ capability to allocate an additional linked worktree. remain the provider boundaries. Frozen UV console commands are the runtime interface on every platform; legacy shell-script paths are not retained. Command arguments, stable records, refusal statuses, and mutation safeguards - remain intact. No workflow requires a Bash launcher. + remain intact. Internal refactoring preserves argument consumption order, + repeated-option behavior, diagnostic text, and stdout/stderr routing as well + as exit codes and repository effects. No workflow requires a Bash launcher. - **GW-M2 — Literal process and filesystem boundaries.** Provider commands use argument vectors, never interpolated shell programs. Paths and temporary artifacts use native filesystem APIs. Hook remediation retains its diagnostic diff --git a/plugins/capability/darrow-git/.claude-plugin/plugin.json b/plugins/capability/darrow-git/.claude-plugin/plugin.json index 3148b4b6..790aefb4 100644 --- a/plugins/capability/darrow-git/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-git/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "darrow-git", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", - "version": "0.8.0", + "version": "0.8.1", "hooks": "./.claude-plugin/hooks.json", "license": "BUSL-1.1" } diff --git a/plugins/capability/darrow-git/.codex-plugin/plugin.json b/plugins/capability/darrow-git/.codex-plugin/plugin.json index 1fcdd8a8..53fb91ad 100644 --- a/plugins/capability/darrow-git/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-git/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-git", - "version": "0.8.0", + "version": "0.8.1", "description": "Intent-triggered Git skills for branches, commits, PRs, and candidate-bound PR evidence", "author": { "name": "Björn Rochel" diff --git a/plugins/capability/darrow-git/backend/pyproject.toml b/plugins/capability/darrow-git/backend/pyproject.toml index 502795e4..60d551c5 100644 --- a/plugins/capability/darrow-git/backend/pyproject.toml +++ b/plugins/capability/darrow-git/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "darrow-git" -version = "0.8.0" +version = "0.8.1" description = "Contained Git and GitHub mechanics for Darrow" requires-python = ">=3.10,<3.14" dependencies = [] diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/arguments.py b/plugins/capability/darrow-git/backend/src/darrow_git/arguments.py new file mode 100644 index 00000000..4b01acd2 --- /dev/null +++ b/plugins/capability/darrow-git/backend/src/darrow_git/arguments.py @@ -0,0 +1,10 @@ +"""Consume literal option values without interpreting their contents.""" + +from collections import deque + +from .process import require + + +def take_value(args: deque[str], message: str, code: int = 2) -> str: + require(args, message, code) + return args.popleft() diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py index 45f5ef5a..00a0dbd4 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/branch_options.py @@ -1,7 +1,9 @@ """Parse branch command arguments without changing refusals.""" +from collections import deque from dataclasses import dataclass +from .arguments import take_value from .process import RefusalError, require @@ -14,15 +16,13 @@ class BranchOptions: at: str | None = None -def option_value(args: list[str], index: int, task: bool) -> str: - flag = args[index] +def option_value(args: deque[str], flag: str, task: bool) -> str: messages = { "--from": "--from needs a value", "--ticket-token": "--ticket-token needs a non-empty opaque value", "--at": "--at needs a non-empty path" if task else "--at needs a value", } - require(index + 1 < len(args), messages[flag], 2) - value = args[index + 1] + value = take_value(args, messages[flag]) if flag == "--ticket-token" or (flag == "--at" and task): require(value, messages[flag], 2) return value @@ -30,9 +30,9 @@ def option_value(args: list[str], index: int, task: bool) -> str: def parse(args: list[str], *, task: bool) -> BranchOptions: options = BranchOptions() - index = 0 - while index < len(args): - index += consume(options, args, index, task) + remaining = deque(args) + while remaining: + consume(options, remaining.popleft(), remaining, task) require(options.name, "no branch name given", 2) if task: require( @@ -45,17 +45,15 @@ def parse(args: list[str], *, task: bool) -> BranchOptions: return options -def consume(options: BranchOptions, args: list[str], index: int, task: bool) -> int: - flag = args[index] +def consume(options: BranchOptions, flag: str, args: deque[str], task: bool) -> None: fields = {"--from": "base", "--ticket-token": "token", "--at": "at"} if flag in fields: - setattr(options, fields[flag], option_value(args, index, task)) - return 2 + setattr(options, fields[flag], option_value(args, flag, task)) + return if flag == "--worktree": options.worktree = True - return 1 + return if flag.startswith("-"): raise RefusalError(f"error: unknown flag: {flag}", 2) require(not options.name, "exactly one branch name allowed", 2) options.name = flag - return 1 diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/cli.py b/plugins/capability/darrow-git/backend/src/darrow_git/cli.py index 68d6fc97..5879d965 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/cli.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/cli.py @@ -4,6 +4,8 @@ import sys from collections.abc import Callable +from . import commit, remediation +from .commit_options import parse as parse_commit from .process import RefusalError @@ -35,10 +37,28 @@ def prepare_branch() -> None: execute(lambda args: run(args, task=True)) -def create_commit() -> None: - from .commit import run +def run_commit(args: list[str]) -> None: + operations = { + "commit": commit.commit, + "retry": commit.retry, + "remediate": remediation.remediate, + } + operation = args[0] if args else "" + if operation == "inspect": + commit.inspect() + elif operation == "diff": + commit.diff(args[1:]) + elif operation in operations: + operations[operation](parse_commit(args[1:], operation)) + else: + raise RefusalError( + "usage: darrow-create-commit inspect | diff <path>... | commit [-m <msg>]... [<path>]... | retry --after-hook-failure --refresh-staged <path>... -m <msg> | remediate --after-hook-failure --command <command> --refresh-staged <path>... -m <msg>", + 64, + ) - execute(run) + +def create_commit() -> None: + execute(run_commit) def create_pr() -> None: diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/commit.py b/plugins/capability/darrow-git/backend/src/darrow_git/commit.py index e63508bf..20ba1e7c 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/commit.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/commit.py @@ -3,7 +3,7 @@ import os from pathlib import Path -from .commit_options import CommitOptions, parse +from .commit_options import CommitOptions from .messages import validate_attribution, validate_subject from .process import RefusalError, decode, emit, git, invoke, probe, require, succeeds @@ -146,21 +146,3 @@ def diff(paths: list[str]) -> None: ), 300, ) - - -def run(args: list[str]) -> None: - from .remediation import remediate - - operations = {"commit": commit, "retry": retry, "remediate": remediate} - operation = args[0] if args else "" - if operation == "inspect": - inspect() - elif operation == "diff": - diff(args[1:]) - elif operation in operations: - operations[operation](parse(args[1:], operation)) - else: - raise RefusalError( - "usage: darrow-create-commit inspect | diff <path>... | commit [-m <msg>]... [<path>]... | retry --after-hook-failure --refresh-staged <path>... -m <msg> | remediate --after-hook-failure --command <command> --refresh-staged <path>... -m <msg>", - 64, - ) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/commit_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/commit_options.py index 8d774762..7c6bf981 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/commit_options.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/commit_options.py @@ -1,7 +1,9 @@ """Commit, retry, and remediation argument contracts.""" +from collections import deque from dataclasses import dataclass, field +from .arguments import take_value from .process import RefusalError, require @@ -15,9 +17,9 @@ class CommitOptions: def parse(args: list[str], operation: str) -> CommitOptions: options = CommitOptions() - index = 0 - while index < len(args): - index = consume(options, args, index, operation) + remaining = deque(args) + while remaining: + consume(options, remaining.popleft(), remaining, operation) if operation == "commit": require(options.messages, "no -m message given", 2) else: @@ -40,37 +42,35 @@ def validate_retry(options: CommitOptions, operation: str) -> None: ) -def consume(options: CommitOptions, args: list[str], index: int, operation: str) -> int: - flag = args[index] +def consume( + options: CommitOptions, flag: str, args: deque[str], operation: str +) -> None: if flag == "-m" or (flag == "--command" and operation == "remediate"): - require(index + 1 < len(args), f"{flag} needs a value", 2) + value = take_value(args, f"{flag} needs a value") if flag == "-m": - options.messages.append(args[index + 1]) + options.messages.append(value) else: - options.command = args[index + 1] - return index + 2 + options.command = value + return if operation == "commit": options.files.append(flag) - return index + 1 - return consume_retry(options, args, index, operation) + return + consume_retry(options, flag, args, operation) def consume_retry( - options: CommitOptions, args: list[str], index: int, operation: str -) -> int: - flag = args[index] + options: CommitOptions, flag: str, args: deque[str], operation: str +) -> None: if flag == "--after-hook-failure": options.after_failure = True - return index + 1 + return if flag == "--refresh-staged": - index += 1 stops = {"-m", "--after-hook-failure", "--refresh-staged"} if operation == "remediate": stops.add("--command") - while index < len(args) and args[index] not in stops: - options.files.append(args[index]) - index += 1 - return index + while args and args[0] not in stops: + options.files.append(args.popleft()) + return allowed = ( "--after-hook-failure, " + ("--command, " if operation == "remediate" else "") diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_options.py index a2ac689b..4065f8c5 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_options.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_options.py @@ -1,8 +1,10 @@ """Evidence input order and presentation metadata.""" +from collections import deque from dataclasses import dataclass, field from pathlib import Path +from .arguments import take_value from .process import RefusalError, require from .publication import FULL_COMMIT @@ -21,48 +23,48 @@ class EvidenceOptions: attachments: list[AttachmentInput] = field(default_factory=list) -def attachment(args: list[str], index: int) -> AttachmentInput: - flag = args[index] +def attachment(args: deque[str], flag: str) -> AttachmentInput: kind = flag.removeprefix("--") require( - len(args) >= index + 4 and args[index + 1], + len(args) >= 3 and args[0], f"{flag} needs a path and presentation text", ) + path = Path(args.popleft()) presentation_flag = "--alt" if kind == "image" else "--explanation" require( - args[index + 2] == presentation_flag, + args.popleft() == presentation_flag, f"{flag} must be followed by {presentation_flag}", ) - text = args[index + 3] + text = args.popleft() require(text.strip(), f"{kind} presentation text must be meaningful") require( not any(char in text for char in "\n\r\t"), f"{kind} presentation text must be one line", ) - return AttachmentInput(Path(args[index + 1]), kind, text) + return AttachmentInput(path, kind, text) -def consume(options: EvidenceOptions, args: list[str], index: int) -> int: - flag = args[index] +def consume(options: EvidenceOptions, flag: str, args: deque[str]) -> None: if flag in {"--image", "--video"}: - options.attachments.append(attachment(args, index)) - return index + 4 + options.attachments.append(attachment(args, flag)) + return if flag in {"--expected-head", "--body-file"}: - require(len(args) > index + 1 and args[index + 1], f"{flag} needs a value") + value = take_value(args, f"{flag} needs a value", 4) + require(value, f"{flag} needs a value") setattr( options, "expected" if flag == "--expected-head" else "body", - args[index + 1], + value, ) - return index + 2 + return raise RefusalError(f"error: unknown argument: {flag}") def parse(args: list[str]) -> EvidenceOptions: options = EvidenceOptions() - index = 0 - while index < len(args): - index = consume(options, args, index) + remaining = deque(args) + while remaining: + consume(options, remaining.popleft(), remaining) require( FULL_COMMIT.fullmatch(options.expected), "--expected-head requires a full commit ID", diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_preflight.py b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_preflight.py index df3aa41a..b2d201d3 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/evidence_preflight.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/evidence_preflight.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from pathlib import Path -from .process import RefusalError, command, git, require +from .process import checked_command, git, require from .publication import REPOSITORY from .repository import current_branch, require_repository @@ -26,13 +26,6 @@ class PullRequest: head: str -def checked(message: str, *args: str) -> str: - try: - return command(*args) - except RefusalError as error: - raise RefusalError(f"error: {message}") from error - - def candidate(expected: str) -> Candidate: require_repository() top = Path(git("rev-parse", "--show-toplevel")) @@ -41,8 +34,8 @@ def candidate(expected: str) -> Candidate: ) branch = current_branch() require(branch, "a feature branch is required") - row = checked( - "cannot identify the repository", + row = checked_command( + "error: cannot identify the repository", "gh", "repo", "view", @@ -62,8 +55,8 @@ def candidate(expected: str) -> Candidate: def observe(candidate: Candidate) -> PullRequest: - rows = checked( - "cannot observe open pull requests", + rows = checked_command( + "error: cannot observe open pull requests", "gh", "pr", "list", @@ -116,8 +109,8 @@ def attachment_help_active(line: str, previous: bool) -> bool: def cli_support() -> None: - help_text = checked( - "cannot inspect gh pr comment support", "gh", "pr", "comment", "--help" + help_text = checked_command( + "error: cannot inspect gh pr comment support", "gh", "pr", "comment", "--help" ) require( "--attach" in help_text, @@ -127,8 +120,8 @@ def cli_support() -> None: require( limit >= 50, f"installed gh advertises a stricter attachment limit: {limit}" ) - checked( - "active GitHub host does not confirm attachment publication support", + checked_command( + "error: active GitHub host does not confirm attachment publication support", "gh", "api", "meta", @@ -136,8 +129,8 @@ def cli_support() -> None: def comments(candidate: Candidate, pr: PullRequest) -> str: - return checked( - "cannot reconcile top-level PR comments", + return checked_command( + "error: cannot reconcile top-level PR comments", "gh", "api", f"repos/{candidate.repository}/issues/{pr.number}/comments", diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/pr_options.py b/plugins/capability/darrow-git/backend/src/darrow_git/pr_options.py index 4e42f402..0a6f5491 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/pr_options.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/pr_options.py @@ -1,7 +1,9 @@ """Public PR arguments shared by inspection and creation.""" +from collections import deque from dataclasses import dataclass, field +from .arguments import take_value from .process import RefusalError, require from .templates import validate_choice @@ -17,9 +19,9 @@ class PrOptions: def parse(args: list[str], *, creating: bool) -> PrOptions: options = PrOptions() - index = 0 - while index < len(args): - index += consume(options, args, index, creating) + remaining = deque(args) + while remaining: + consume(options, remaining.popleft(), remaining, creating) if creating: require(options.title, "--title required", 2) require(options.bodies, "at least one -b body section required", 2) @@ -27,31 +29,27 @@ def parse(args: list[str], *, creating: bool) -> PrOptions: return options -def consume(options: PrOptions, args: list[str], index: int, creating: bool) -> int: - flag = args[index] +def consume(options: PrOptions, flag: str, args: deque[str], creating: bool) -> None: if creating and flag == "--draft": options.draft = True - return 1 + return flags = {"--base": "base", "--template": "template"} if creating: flags.update({"--title": "title", "-b": "bodies"}) if flag not in flags: raise RefusalError(f"error: unknown argument: {flag}", 2) - value = read_value(args, index) + value = read_value(args, flag) if flag == "-b": options.bodies.append(value) else: setattr(options, flags[flag], value) - return 2 -def read_value(args: list[str], index: int) -> str: - flag = args[index] +def read_value(args: deque[str], flag: str) -> str: description = {"--base": "a non-empty value", "--template": "a filename"}.get( flag, "a value" ) - require(index + 1 < len(args), f"{flag} needs {description}", 2) - value = args[index + 1] + value = take_value(args, f"{flag} needs {description}") if flag == "--base": require(value, "--base needs a non-empty value", 2) if flag == "-b": diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/pr_repository.py b/plugins/capability/darrow-git/backend/src/darrow_git/pr_repository.py index c0fe0e1d..d55979cd 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/pr_repository.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/pr_repository.py @@ -1,6 +1,7 @@ """PR base selection and committed comparison context.""" from .process import probe, require, succeeds +from .repository import guessed_default def default_branch() -> tuple[str, str]: @@ -14,17 +15,6 @@ def default_branch() -> tuple[str, str]: return guessed_default(), "guess" -def guessed_default() -> str: - return next( - ( - name - for name in ("main", "master") - if succeeds("show-ref", "-q", "--verify", f"refs/heads/{name}") - ), - "(none)", - ) - - def compare_ref(base: str) -> str: return ( f"origin/{base}" diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/process.py b/plugins/capability/darrow-git/backend/src/darrow_git/process.py index 89f4e7f8..15357126 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/process.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/process.py @@ -49,6 +49,13 @@ def command(*args: str, cwd: Path | None = None, code: int = 4) -> str: return decode(result.stdout) +def checked_command(message: str, *args: str) -> str: + try: + return command(*args) + except RefusalError as error: + raise RefusalError(message) from error + + def git(*args: str, cwd: Path | None = None) -> str: return command("git", *args, cwd=cwd) diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/publication.py b/plugins/capability/darrow-git/backend/src/darrow_git/publication.py index f13d2e02..8cf5788e 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/publication.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/publication.py @@ -3,9 +3,10 @@ import re import shutil import time +from collections import deque from dataclasses import dataclass -from .process import RefusalError, command, emit, git, require +from .process import RefusalError, checked_command, emit, git, require from .repository import current_branch, in_progress FULL_COMMIT = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") @@ -56,32 +57,22 @@ def fail(self, message: str) -> RefusalError: def parse(args: list[str]) -> Publication: state = Publication() - index = 0 - while index < len(args): - flag = args[index] + remaining = deque(args) + while remaining: + flag = remaining.popleft() if flag == "--draft": state.draft = "true" - index += 1 else: - index = parse_value(args, index, state) + parse_value(remaining, flag, state) return state -def parse_value(args: list[str], index: int, state: Publication) -> int: - flag = args[index] +def parse_value(args: deque[str], flag: str, state: Publication) -> None: if flag not in {"--expected-head", "--base"}: raise state.fail(f"unknown argument: {flag}") - if index + 1 >= len(args) or not args[index + 1]: + if not args or not args[0]: raise state.fail(f"{flag} needs a value") - setattr(state, "expected" if flag == "--expected-head" else "base", args[index + 1]) - return index + 2 - - -def checked_command(message: str, *args: str) -> str: - try: - return command(*args) - except RefusalError as error: - raise RefusalError(message) from error + setattr(state, "expected" if flag == "--expected-head" else "base", args.popleft()) def preflight(state: Publication) -> None: diff --git a/plugins/capability/darrow-git/backend/src/darrow_git/repository.py b/plugins/capability/darrow-git/backend/src/darrow_git/repository.py index bf2ab046..e8e2f83b 100644 --- a/plugins/capability/darrow-git/backend/src/darrow_git/repository.py +++ b/plugins/capability/darrow-git/backend/src/darrow_git/repository.py @@ -45,6 +45,10 @@ def local_default() -> str: remote = probe("symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD") if remote: return remote.removeprefix("origin/") + return guessed_default() + + +def guessed_default() -> str: return next( ( name diff --git a/plugins/capability/darrow-git/backend/tests/test_argument_contracts.py b/plugins/capability/darrow-git/backend/tests/test_argument_contracts.py index d0b179bc..87da679a 100644 --- a/plugins/capability/darrow-git/backend/tests/test_argument_contracts.py +++ b/plugins/capability/darrow-git/backend/tests/test_argument_contracts.py @@ -4,7 +4,7 @@ import pytest -from darrow_git import branch, commit, evidence_options, pr, publication +from darrow_git import branch, cli, evidence_options, pr, publication from darrow_git.process import RefusalError, git @@ -95,7 +95,7 @@ def test_branch_invalid( def test_commit_invalid(repository: Path, args: list[str], code: int) -> None: before = git("write-tree"), git("rev-parse", "HEAD") with pytest.raises(RefusalError) as failure: - commit.run(args) + cli.run_commit(args) assert failure.value.code == code assert (git("write-tree"), git("rev-parse", "HEAD")) == before diff --git a/plugins/capability/darrow-git/backend/tests/test_cli_media.py b/plugins/capability/darrow-git/backend/tests/test_cli_media.py index c9a88898..3c55831c 100644 --- a/plugins/capability/darrow-git/backend/tests/test_cli_media.py +++ b/plugins/capability/darrow-git/backend/tests/test_cli_media.py @@ -63,6 +63,46 @@ def inaccessible(args: list[str]) -> None: assert "cannot read requested file" in capsys.readouterr().err +@pytest.mark.parametrize( + "entrypoint,args,code,message", + [ + (cli.create_branch, ["create", "--from"], 2, "--from needs a value"), + (cli.prepare_branch, ["prepare", "--at", ""], 2, "--at needs a non-empty path"), + ( + cli.create_commit, + ["retry", "--command", "x"], + 2, + "retry accepts only --after-hook-failure, --refresh-staged, and -m", + ), + (cli.create_commit, ["remediate", "--command"], 2, "--command needs a value"), + (cli.create_pr, ["inspect", "--base", ""], 2, "--base needs a non-empty value"), + (cli.create_pr, ["create", "-b", " "], 2, "-b section is empty"), + ( + cli.create_pr, + ["create", "--title", "--draft"], + 2, + "at least one -b body section required", + ), + ], +) +def test_cli_argument_diagnostics( + repository: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + entrypoint: Callable[[], None], + args: list[str], + code: int, + message: str, +) -> None: + monkeypatch.setattr("sys.argv", ["darrow-command", *args]) + with pytest.raises(SystemExit) as failure: + entrypoint() + assert failure.value.code == code + output = capsys.readouterr() + assert output.out == "" + assert output.err == f"error: {message}\n" + + def test_provider_failures(tmp_path: Path) -> None: with pytest.raises(RefusalError, match="cannot execute"): invoke([str(tmp_path / "missing-program")]) diff --git a/plugins/capability/darrow-git/backend/tests/test_commit_recovery.py b/plugins/capability/darrow-git/backend/tests/test_commit_recovery.py index de8e664f..7d07d18b 100644 --- a/plugins/capability/darrow-git/backend/tests/test_commit_recovery.py +++ b/plugins/capability/darrow-git/backend/tests/test_commit_recovery.py @@ -9,7 +9,7 @@ import pytest -from darrow_git import commit, remediation +from darrow_git import cli, commit, remediation from darrow_git.process import RefusalError, git, invoke @@ -62,7 +62,7 @@ def failed( monkeypatch.setattr(commit, "invoke", failed) with pytest.raises(RefusalError, match="hook failed") as error: - commit.run(["commit", "-m", "fix: attempt change"]) + cli.run_commit(["commit", "-m", "fix: attempt change"]) assert error.value.code == 4 assert git("rev-parse", "HEAD") == before_head assert git("write-tree") == before_tree @@ -100,10 +100,10 @@ def test_remediation_effects( commit.save_failure("run: " + command, before_head, before_tree) if code: with pytest.raises(RefusalError, match=message) as error: - commit.run(remediate_args(command)) + cli.run_commit(remediate_args(command)) assert error.value.code == code else: - commit.run(remediate_args(command)) + cli.run_commit(remediate_args(command)) assert git("show", "HEAD:tracked.txt") == "corrected" assert (repository / "outside.txt").read_text() == "outside" if code == 7: @@ -114,7 +114,7 @@ def test_remediation_effects( def test_remediation_refusals(repository: Path) -> None: before_head, before_tree = stage(repository) with pytest.raises(RefusalError, match="no readable"): - commit.run(remediate_args("unknown")) + cli.run_commit(remediate_args("unknown")) commit.failure_path().write_text("broken") with pytest.raises(RefusalError, match="invalid"): remediation.read_state() @@ -126,10 +126,10 @@ def test_remediation_refusals(repository: Path) -> None: remediation.read_state() commit.save_failure("run: known", before_head, before_tree) with pytest.raises(RefusalError, match="not present"): - commit.run(remediate_args("unknown")) + cli.run_commit(remediate_args("unknown")) git("add", "outside.txt") with pytest.raises(RefusalError, match="state changed"): - commit.run(remediate_args("known")) + cli.run_commit(remediate_args("known")) def test_unstaged_inspect_and_diff( @@ -137,10 +137,10 @@ def test_unstaged_inspect_and_diff( ) -> None: (repository / "tracked.txt").write_text("different") (repository / "new.txt").write_text("new content") - commit.run(["inspect"]) + cli.run_commit(["inspect"]) assert "mode: unstaged" in capsys.readouterr().out - commit.run(["diff", "tracked.txt", "new.txt"]) + cli.run_commit(["diff", "tracked.txt", "new.txt"]) assert "new content" in capsys.readouterr().out - commit.run(["commit", "-m", "feat: explicit paths", "new.txt"]) + cli.run_commit(["commit", "-m", "feat: explicit paths", "new.txt"]) assert git("show", "HEAD:new.txt") == "new content" assert git("diff", "--name-only") == "tracked.txt" diff --git a/plugins/capability/darrow-git/backend/tests/test_native_workflows.py b/plugins/capability/darrow-git/backend/tests/test_native_workflows.py index ca4e6a46..c509fdee 100644 --- a/plugins/capability/darrow-git/backend/tests/test_native_workflows.py +++ b/plugins/capability/darrow-git/backend/tests/test_native_workflows.py @@ -4,7 +4,7 @@ import pytest -from darrow_git import branch, commit, evidence, pr +from darrow_git import branch, cli, evidence, pr from darrow_git.process import RefusalError, git from forge import Forge @@ -53,14 +53,14 @@ def test_commit_scope_and_retry( (repository / "one.txt").write_text("staged") (repository / "two.txt").write_text("outside") git("add", "one.txt") - commit.run(["inspect"]) + cli.run_commit(["inspect"]) assert "mode: staged" in capsys.readouterr().out before = git("write-tree") with pytest.raises(RefusalError, match="a staged set exists"): - commit.run(["commit", "-m", "feat: scope", "two.txt"]) + cli.run_commit(["commit", "-m", "feat: scope", "two.txt"]) assert git("write-tree") == before (repository / "one.txt").write_text("corrected") - commit.run( + cli.run_commit( [ "retry", "--after-hook-failure", diff --git a/plugins/capability/darrow-git/backend/uv.lock b/plugins/capability/darrow-git/backend/uv.lock index ac0fbf40..efe545c3 100644 --- a/plugins/capability/darrow-git/backend/uv.lock +++ b/plugins/capability/darrow-git/backend/uv.lock @@ -86,7 +86,7 @@ toml = [ [[package]] name = "darrow-git" -version = "0.8.0" +version = "0.8.1" source = { editable = "." } [package.dev-dependencies]