diff --git a/.githooks/pre-push b/.githooks/pre-push index c146493a..e7f0f49b 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -54,6 +54,26 @@ def _git(*args: str) -> subprocess.CompletedProcess: ) +def _drop_repo_env() -> None: + """Remove the variables that bind a git process to THIS repository. + + git exports GIT_DIR to hooks (in a linked worktree it is always set), and + every layer below inherits os.environ. The review lenses are agents that + may run the test suite; a test that does `git init` / `add -A` / `commit` + in a temp dir then writes to the pushing repo instead. On 2026-10-29 one + push left 5 fixture commits on the branch (the tree reduced to 8 files) and + `[user] name = t` in the shared .git/config. The hook has already chdir'd + to the worktree root, so git rediscovers the same repo without them. + `--local-env-vars` is git's own list: what it clears for a submodule. + """ + names = _git("rev-parse", "--local-env-vars").stdout.split() or [ + "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", "GIT_PREFIX", + ] + for name in names: + os.environ.pop(name, None) + + def _source_local_env(repo_root: Path) -> None: """Parse simple KEY=VALUE lines from $MINI_ORK_HOME/config/local.sh. @@ -176,6 +196,8 @@ def main() -> int: except OSError: return 0 + _drop_repo_env() + # Per-developer local env preferences (non-secret). _source_local_env(REPO_ROOT) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af9650e4..67a01d00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,18 +58,24 @@ jobs: python-tests: name: python tests (${{ matrix.python-version }} / ${{ matrix.shard }}) runs-on: ubuntu-latest - # The unit shards run 22-33 min and drift with every added test file (the - # odd/even split reshuffles); 35 cancelled a passing 3.12/unit-a at 35.4 - # min on 2026-10-28 (a re-run took 28.6). Headroom, not a ceiling to aim at. - timeout-minutes: 50 + # Each shard runs its tests on every runner core (pytest-xdist, below). + # Serially the unit shards took 23-39 min and 50 was the timeout; a shard + # that now needs more than 30 is a hang or a regression worth seeing. + timeout-minutes: 30 strategy: fail-fast: false matrix: python-version: ["3.11", "3.12"] - # Sharding: the suite is ~2,090 tests dominated by bash<->python - # parity subprocess costs (~14-17 min as one job). Split the big - # tests/unit tier odd/even by file (interleave balances the slow - # clusters) and run everything else as a third shard. + # Sharding: tests/unit is ~14,000 tests whose time is spread thin — + # subprocess and git work in hundreds of files, the slowest file ~4% + # of the total — so parallelism, not trimming single tests, is what + # moves it. Split it odd/even by file and run everything else as a + # third shard; within a shard, xdist uses all cores (`-n auto`). Both + # tiers pass on 8 workers (unit: 14,000 passed, 0 failed; rest: 734 + # passed, its one local failure reproduces serially; 2026-10-29), so + # tests do not share state across processes. + # `find`, not `ls tests/unit/*.py`: the glob never reached + # tests/unit/acp/, so its 28 tests ran in no shard at all. shard: [unit-a, unit-b, rest] steps: - uses: actions/checkout@v7 @@ -90,7 +96,7 @@ jobs: # tests/unit/test_acp_agent_py.py imports at module scope; without # the extra the whole shard dies at collection, not on an assertion. pip install -e ".[yaml,acp]" - pip install pytest fastapi uvicorn httpx2 + pip install pytest "pytest-xdist>=3.5" fastapi uvicorn httpx2 # The web smoke suite points the FastAPI route handlers at a real # state.db; init bootstraps the full schema so those tests run @@ -100,15 +106,15 @@ jobs: - name: Run pytest (unit shard A) if: matrix.shard == 'unit-a' - run: ls tests/unit/*.py | awk 'NR % 2 == 1' | xargs python -m pytest -q -p no:cacheprovider + run: find tests/unit -name 'test_*.py' | sort | awk 'NR % 2 == 1' | xargs python -m pytest -q -p no:cacheprovider -n auto - name: Run pytest (unit shard B) if: matrix.shard == 'unit-b' - run: ls tests/unit/*.py | awk 'NR % 2 == 0' | xargs python -m pytest -q -p no:cacheprovider + run: find tests/unit -name 'test_*.py' | sort | awk 'NR % 2 == 0' | xargs python -m pytest -q -p no:cacheprovider -n auto - name: Run pytest (root + non-unit layers) if: matrix.shard == 'rest' - run: python -m pytest tests/ -q -p no:cacheprovider --ignore=tests/unit + run: python -m pytest tests/ -q -p no:cacheprovider -n auto --ignore=tests/unit # The optional `runtime` extra pins `verifiers>=0.2.0,<0.5`, and that ceiling moves # by dependabot PR (it went <0.3 -> <0.5 on 2026-10-27 with no check on the API).