witan-code 0.19.1__tar.gz → 0.20.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {witan_code-0.19.1 → witan_code-0.20.1}/.gitignore +4 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/CHANGELOG.md +69 -1
- {witan_code-0.19.1 → witan_code-0.20.1}/PKG-INFO +2 -2
- witan_code-0.20.1/changelog.d/README.md +13 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/pyproject.toml +11 -3
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_branches.py +77 -7
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_context.py +6 -3
- witan_code-0.20.1/tests/test_hooks.py +368 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_reaper.py +48 -1
- witan_code-0.20.1/tests/test_refusal_audit.py +48 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_store.py +62 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/cli.py +13 -10
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/context.py +85 -18
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/graph.py +194 -32
- witan_code-0.20.1/witan_code/hooks.py +234 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/store.py +18 -1
- witan_code-0.19.1/tests/test_hooks.py +0 -188
- witan_code-0.19.1/witan_code/hooks.py +0 -116
- {witan_code-0.19.1 → witan_code-0.20.1}/README.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/config/claude.json +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/config/copilot.json +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/config/pi.json +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/conftest.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/docs/BRANCH_INDEXING.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/docs/CLI_REFERENCE.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/docs/EDGE_PRECISION_TIERS.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/docs/PACKAGE_MAP.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/docs/STAGE2_STITCHING.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/docs/SYMBOL_FORMAT.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/docs/SYMBOL_TABLE.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/docs/USER_GUIDE.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/install.sh +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/__init__.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/conftest.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_batched_deletes.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_bridge.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_chunked_load.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_cli_dispatch.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_cli_hooks.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_cli_output_format.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_cli_selected_target.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_config.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_config_targets.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_crossrepo.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_edges.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_elicit.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_enrichment.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_github_app.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_graph.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_identity.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_index_failure_diagnostics.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_index_reports_its_store.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_index_requires_a_repo_key.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_indexer.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_indexer_scope.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_ingest.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_languages.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_maintenance.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_package_map.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_rebuild_honours_the_repo_key.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_remote_proxy.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_remote_store.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_repo.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_server.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_setup.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_stitch.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_symbol_table.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_tasks.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_tool_schema.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_views.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_visualize.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/__init__.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/__main__.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/bridge.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/bridge_extractors.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/config.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/edges.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/elicit.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/extensions/pi/codegraph.ts +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/github_app.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/identity.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/indexer.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/ingest.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/maintenance.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/output.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/package_map.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries/bridge.gq +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries/code_mutations.gq +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries/code_read.gq +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries/delete.gq +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/bash.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/hcl.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/python.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/sql.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/typescript.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/yaml.scm +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/reaper.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/remote/__init__.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/remote/oidc.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/remote/proxy.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/remote/store.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/repo.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/schema/bridge-schema.pg +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/schema/code-schema.pg +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/selected_target.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/server.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/setup.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/skills/witan-code/SKILL.md +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/stitch.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/views.py +0 -0
- {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/visualize.py +0 -0
|
@@ -28,6 +28,10 @@ build/
|
|
|
28
28
|
_bin/
|
|
29
29
|
# omnigraph cluster state (populated by `omnigraph cluster import`)
|
|
30
30
|
__cluster/
|
|
31
|
+
# The witan UI bundle, written by `npm run build` in mcp/servers/witan/ui/.
|
|
32
|
+
# A build output, so not committed — hatch's `artifacts` setting in that
|
|
33
|
+
# package's pyproject.toml is what gets it into the sdist and the wheel anyway.
|
|
34
|
+
mcp/servers/witan/witan/ui_dist/
|
|
31
35
|
|
|
32
36
|
# MCP server logs / caches
|
|
33
37
|
.mcp-cache/
|
|
@@ -6,7 +6,75 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/) (pre-1.0:
|
|
7
7
|
a MINOR bump may include breaking changes).
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
<!-- scriv-insert-here -->
|
|
10
|
+
|
|
11
|
+
## [0.20.1] - 2026-09-22
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Parallel agents in one worktree no longer race each other's code-graph
|
|
16
|
+
writes.** The PostToolUse `reindex-hook` indexed each edited file in the
|
|
17
|
+
foreground, one process per Edit/Write, so a session fanning out subagents
|
|
18
|
+
in one worktree put that many uncoordinated writers on one branch view.
|
|
19
|
+
Against a deployed graph they lost each other's optimistic-concurrency races
|
|
20
|
+
until the retry budget ran out (Sentry WITAN-12). Both hooks now put their
|
|
21
|
+
target on a per-checkout queue (SessionStart queues the project dir,
|
|
22
|
+
PostToolUse the edited file) and a single detached drainer applies it,
|
|
23
|
+
indexing each queued target once however many times it was queued.
|
|
24
|
+
|
|
25
|
+
The index now lands a few seconds after the edit instead of before the hook
|
|
26
|
+
returns. Outside a git checkout the hook still indexes in the foreground.
|
|
27
|
+
|
|
28
|
+
The per-checkout state is keyed on the git toplevel rather than
|
|
29
|
+
`CLAUDE_PROJECT_DIR`, so a session started in a monorepo subdirectory shares
|
|
30
|
+
its edits' lock. A SessionStart refresh that finds an indexer already
|
|
31
|
+
running is queued rather than dropped.
|
|
32
|
+
|
|
33
|
+
The lock is now a kernel `flock` handed to the drainer, so a killed indexer
|
|
34
|
+
no longer leaves the checkout locked until someone removes the lock by hand.
|
|
35
|
+
The lock, the queue and the last drainer's log live in a private 0700
|
|
36
|
+
`$TMPDIR/witan-code-<uid>/` rather than loose in a shared `/tmp`.
|
|
37
|
+
|
|
38
|
+
## [0.20.0] - 2026-09-21
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- **Branch work goes through omnigraph 0.11 GQ statements, not the branch
|
|
43
|
+
CLI.** `list_branches`, `ensure_branch` and `delete_branch` now issue
|
|
44
|
+
`branch list` / `branch create` / `branch delete` on the same query and
|
|
45
|
+
mutate routes as every other read and write, so they share its transport,
|
|
46
|
+
retry policy and error classification. Against the cluster that removes a
|
|
47
|
+
subprocess per call, including on a branched client, which the named-query
|
|
48
|
+
path still cannot route over HTTP. Cedar still scopes these as branch
|
|
49
|
+
actions rather than as the route's `change`, so no actor gains authority it
|
|
50
|
+
did not have (verified against a 0.11.0 server running the code-graph
|
|
51
|
+
bundle).
|
|
52
|
+
|
|
53
|
+
Three behaviours of 0.11 this depends on, all measured against the 0.11.0
|
|
54
|
+
binary and server:
|
|
55
|
+
|
|
56
|
+
- A branch name must be QUOTED in the statement; an unquoted `act-x/v` is a
|
|
57
|
+
parse error. A `"` inside the quotes, on the other hand, PARSES fine (GQ
|
|
58
|
+
honours `\"`) and is then refused by the storage layer, so the guard is
|
|
59
|
+
against a statement that means something other than what was asked, not
|
|
60
|
+
against a syntax error. Names are checked against omnigraph's own rule,
|
|
61
|
+
which is Unicode `Alphabetic` or numeric plus `.`, `-`, `_` per
|
|
62
|
+
`/`-separated segment. That admits `café`, `act-x/дом`, and the combining
|
|
63
|
+
vowel signs of Indic, Hebrew and Arabic scripts (`कि`, `אָ`) that Python's
|
|
64
|
+
`\w` and `str.isalnum` both reject. A narrower charset would have made the
|
|
65
|
+
reaper refuse to delete views omnigraph created quite happily.
|
|
66
|
+
- A duplicate `branch create` answers HTTP 409, which `classify_status`
|
|
67
|
+
reads as retryable by status. So does the transient write-authority
|
|
68
|
+
precondition, and the two are indistinguishable. `ensure_branch` surfaces
|
|
69
|
+
conflicts and does its own re-list-and-retry, which resolves the first
|
|
70
|
+
without giving up the retry the second needs. That retry carries its own
|
|
71
|
+
backoff, because the engine's does not run under `surface_conflict`.
|
|
72
|
+
- `branch list` returns a `rows` envelope of `{"name": ...}`, not the old
|
|
73
|
+
`{"branches": [...]}`.
|
|
74
|
+
|
|
75
|
+
- **Require `witan-core>=0.38`**, for `OmnigraphClient.statement`. The failure
|
|
76
|
+
without it is a runtime `AttributeError` on the first `list_branches()`, not
|
|
77
|
+
an import error, so `just check-core-floor` cannot catch it.
|
|
10
78
|
|
|
11
79
|
## [0.19.1] - 2026-09-18
|
|
12
80
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: witan-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.20.1
|
|
4
4
|
Summary: witan-code — tree-sitter code graph + cross-repo bridge (mounts under `witan code`)
|
|
5
5
|
Project-URL: Homepage, https://github.com/mitodl/agent-kit/tree/main/mcp/servers/witan-code
|
|
6
6
|
Project-URL: Repository, https://github.com/mitodl/agent-kit
|
|
@@ -26,7 +26,7 @@ Requires-Dist: tree-sitter-sql<0.4,>=0.3
|
|
|
26
26
|
Requires-Dist: tree-sitter-typescript<0.24,>=0.23
|
|
27
27
|
Requires-Dist: tree-sitter-yaml<0.8,>=0.7
|
|
28
28
|
Requires-Dist: tree-sitter<0.26,>=0.25
|
|
29
|
-
Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.
|
|
29
|
+
Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.38
|
|
30
30
|
Provides-Extra: tasks
|
|
31
31
|
Requires-Dist: fastmcp-tasks<5,>=4; extra == 'tasks'
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog fragments
|
|
2
|
+
|
|
3
|
+
Each user-visible change gets its own fragment file here instead of an edit
|
|
4
|
+
to `../CHANGELOG.md` — parallel branches editing the same lines in one shared
|
|
5
|
+
file is what caused the version/changelog drift `bin/check_versions.py`
|
|
6
|
+
exists to catch.
|
|
7
|
+
|
|
8
|
+
Add one with `just changelog witan-code` (or `uvx scriv@1.8.0 create` from
|
|
9
|
+
this directory's parent), fill in whichever category section(s) apply, and
|
|
10
|
+
commit the file alongside your change. `just bump witan-code <part>` folds
|
|
11
|
+
every pending fragment into `../CHANGELOG.md` under the new version heading
|
|
12
|
+
and deletes them — it refuses to run if this directory holds nothing but this
|
|
13
|
+
README.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "witan-code"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.20.1"
|
|
4
4
|
description = "witan-code — tree-sitter code graph + cross-repo bridge (mounts under `witan code`)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "BSD-3-Clause"
|
|
@@ -142,7 +142,7 @@ dependencies = [
|
|
|
142
142
|
# parses the keyed `code-schema.pg`, and it restores the null fields 0.11
|
|
143
143
|
# drops from query rows. An older witan-core imports fine and then fails
|
|
144
144
|
# on the first schema init.
|
|
145
|
-
"witan-core[cli,remote,observability,sentry]>=0.
|
|
145
|
+
"witan-core[cli,remote,observability,sentry]>=0.38,<1",
|
|
146
146
|
"fastmcp>=4,<5",
|
|
147
147
|
"cyclopts>=4,<5",
|
|
148
148
|
# [targets.<name>] override models (config.py) — also transitively pulled
|
|
@@ -225,7 +225,7 @@ testpaths = ["tests"]
|
|
|
225
225
|
packages = ["witan_code"]
|
|
226
226
|
|
|
227
227
|
[tool.bumpversion]
|
|
228
|
-
current_version = "0.
|
|
228
|
+
current_version = "0.20.1"
|
|
229
229
|
allow_dirty = true
|
|
230
230
|
|
|
231
231
|
[[tool.bumpversion.files]]
|
|
@@ -233,6 +233,14 @@ filename = "pyproject.toml"
|
|
|
233
233
|
search = 'version = "{current_version}"'
|
|
234
234
|
replace = 'version = "{new_version}"'
|
|
235
235
|
|
|
236
|
+
[tool.scriv]
|
|
237
|
+
format = "md"
|
|
238
|
+
fragment_directory = "changelog.d"
|
|
239
|
+
categories = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Performance", "Security", "Notes"]
|
|
240
|
+
md_header_level = "2"
|
|
241
|
+
md_html_anchors = false
|
|
242
|
+
entry_title_template = "[{{ version }}] - {{ date.strftime('%Y-%m-%d') }}"
|
|
243
|
+
|
|
236
244
|
# witan_code/queries/, witan_code/schema/, witan_code/queries_ts/,
|
|
237
245
|
# witan_code/skills/, and witan_code/extensions/ are all discovered
|
|
238
246
|
# automatically via packages = ["witan_code"] — including for a
|
|
@@ -6,6 +6,8 @@ import subprocess
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
+
from witan_core.omnigraph import OmnigraphConflict
|
|
10
|
+
|
|
9
11
|
from witan_code import repo as repo_module
|
|
10
12
|
|
|
11
13
|
from .conftest import SAMPLE, requires_stack
|
|
@@ -194,18 +196,36 @@ def test_a_view_opened_by_two_callers_at_once_is_not_an_error(tmp_path, monkeypa
|
|
|
194
196
|
|
|
195
197
|
@requires_stack
|
|
196
198
|
@pytest.mark.parametrize(
|
|
197
|
-
("
|
|
199
|
+
("exc", "listed"),
|
|
198
200
|
[
|
|
199
|
-
(
|
|
201
|
+
(
|
|
202
|
+
RuntimeError("omnigraph mutate failed (exit 1):\npermission denied"),
|
|
203
|
+
["main"],
|
|
204
|
+
),
|
|
200
205
|
# The message alone is not trusted: the branch has to actually be there.
|
|
201
206
|
(
|
|
202
|
-
|
|
207
|
+
RuntimeError(
|
|
208
|
+
"omnigraph mutate failed (exit 1):\n"
|
|
209
|
+
"branch 'feature_race' already exists"
|
|
210
|
+
),
|
|
211
|
+
["main"],
|
|
212
|
+
),
|
|
213
|
+
# What the cluster raises for the same race. 0.11 answers a duplicate
|
|
214
|
+
# create with HTTP 409, `classify_status` reads a 409 as retryable, and
|
|
215
|
+
# `ensure_branch`'s `surface_conflict=True` is what turns that into this
|
|
216
|
+
# rather than a retry budget spent re-racing. `OmnigraphConflict` is a
|
|
217
|
+
# `RuntimeError`, so the recovery clause sees both shapes the same way —
|
|
218
|
+
# and still re-lists before deciding.
|
|
219
|
+
(
|
|
220
|
+
OmnigraphConflict(
|
|
221
|
+
"branch 'feature_race' already exists (HTTP 409, conflict)"
|
|
222
|
+
),
|
|
203
223
|
["main"],
|
|
204
224
|
),
|
|
205
225
|
],
|
|
206
226
|
)
|
|
207
227
|
def test_a_create_failure_that_is_not_the_race_still_raises(
|
|
208
|
-
tmp_path, monkeypatch,
|
|
228
|
+
tmp_path, monkeypatch, exc, listed
|
|
209
229
|
):
|
|
210
230
|
from witan_code import config as cfg_module
|
|
211
231
|
from witan_code.graph import OmnigraphClient
|
|
@@ -217,12 +237,62 @@ def test_a_create_failure_that_is_not_the_race_still_raises(
|
|
|
217
237
|
monkeypatch.setattr(client, "list_branches", lambda: listed)
|
|
218
238
|
|
|
219
239
|
def fail(*_args, **_kwargs):
|
|
220
|
-
raise
|
|
240
|
+
raise exc
|
|
221
241
|
|
|
222
|
-
monkeypatch.setattr(client, "
|
|
242
|
+
monkeypatch.setattr(client, "statement", fail)
|
|
223
243
|
with pytest.raises(RuntimeError) as excinfo:
|
|
224
244
|
client.ensure_branch()
|
|
225
|
-
assert
|
|
245
|
+
assert excinfo.value is exc
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@requires_stack
|
|
249
|
+
def test_a_conflicting_create_is_retried_a_bounded_number_of_spaced_times(
|
|
250
|
+
tmp_path, monkeypatch
|
|
251
|
+
):
|
|
252
|
+
"""The transient half of the 409, which the already-exists tests cannot see.
|
|
253
|
+
|
|
254
|
+
0.11 answers the write-authority precondition with a 409 as well, and
|
|
255
|
+
`classify_status` keys on the status, so `ensure_branch` cannot tell it
|
|
256
|
+
from a duplicate create. It retries for that one, and the retry has to be
|
|
257
|
+
both BOUNDED (a permanently conflicted graph must fail, not hang) and
|
|
258
|
+
SPACED (`_retry_loop` raises on the first retryable under
|
|
259
|
+
`surface_conflict`, before its own `time.sleep`, so three attempts inside
|
|
260
|
+
one listing round trip would be no wait at all for a writer mid-commit).
|
|
261
|
+
|
|
262
|
+
Neither property is visible in the other tests: they stub `list_branches`
|
|
263
|
+
to a fixed value and never count.
|
|
264
|
+
"""
|
|
265
|
+
from witan_code import config as cfg_module
|
|
266
|
+
from witan_code import graph as graph_module
|
|
267
|
+
from witan_code.graph import OmnigraphClient
|
|
268
|
+
|
|
269
|
+
monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
|
|
270
|
+
client = OmnigraphClient(
|
|
271
|
+
str(tmp_path / "store"), cfg_module.load().queries_dir, branch="feature_race"
|
|
272
|
+
)
|
|
273
|
+
monkeypatch.setattr(client, "list_branches", lambda: ["main"])
|
|
274
|
+
slept: list[float] = []
|
|
275
|
+
monkeypatch.setattr(graph_module.time, "sleep", slept.append)
|
|
276
|
+
|
|
277
|
+
attempts = []
|
|
278
|
+
|
|
279
|
+
def always_conflicts(*_args, **_kwargs):
|
|
280
|
+
attempts.append(None)
|
|
281
|
+
raise OmnigraphConflict(
|
|
282
|
+
"write authority 'graph_head:main' changed during preparation "
|
|
283
|
+
"(HTTP 409, conflict)"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
monkeypatch.setattr(client, "statement", always_conflicts)
|
|
287
|
+
|
|
288
|
+
with pytest.raises(OmnigraphConflict):
|
|
289
|
+
client.ensure_branch()
|
|
290
|
+
|
|
291
|
+
assert len(attempts) == graph_module._ENSURE_BRANCH_ATTEMPTS
|
|
292
|
+
assert slept == [
|
|
293
|
+
graph_module._ENSURE_BRANCH_BACKOFF_SECONDS * n
|
|
294
|
+
for n in range(1, graph_module._ENSURE_BRANCH_ATTEMPTS)
|
|
295
|
+
], "retries must be spaced, not merely counted"
|
|
226
296
|
|
|
227
297
|
|
|
228
298
|
@requires_stack
|
|
@@ -8,6 +8,7 @@ check, store-exists branching), not the underlying query.
|
|
|
8
8
|
import os
|
|
9
9
|
import shutil
|
|
10
10
|
import subprocess
|
|
11
|
+
import tempfile
|
|
11
12
|
import time
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
@@ -17,9 +18,11 @@ from witan_code import context
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def _lock(tmp_path, monkeypatch, project_dir):
|
|
21
|
+
"""The busy marker `indexing_in_progress` reads; create it to fake a run."""
|
|
20
22
|
monkeypatch.setenv("TMPDIR", str(tmp_path))
|
|
23
|
+
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
|
|
21
24
|
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(project_dir))
|
|
22
|
-
return context.
|
|
25
|
+
return context._busy_path(project_dir)
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
def _stub_store_stats(monkeypatch, health=None):
|
|
@@ -313,7 +316,7 @@ def test_the_bridge_verdict_is_cached_across_processes(tmp_path, monkeypatch):
|
|
|
313
316
|
|
|
314
317
|
|
|
315
318
|
def test_lock_path_does_not_collide_on_sanitization(tmp_path, monkeypatch):
|
|
316
|
-
monkeypatch.
|
|
319
|
+
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
|
|
317
320
|
|
|
318
321
|
a = context._lock_path(Path("/tmp/a/b"))
|
|
319
322
|
b = context._lock_path(Path("/tmp/a_b"))
|
|
@@ -324,7 +327,7 @@ def test_lock_path_does_not_collide_on_sanitization(tmp_path, monkeypatch):
|
|
|
324
327
|
def test_lock_path_name_is_bounded_regardless_of_project_dir_length(
|
|
325
328
|
tmp_path, monkeypatch
|
|
326
329
|
):
|
|
327
|
-
monkeypatch.
|
|
330
|
+
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
|
|
328
331
|
long_dir = Path("/" + ("deeply-nested-directory-" * 20) + "/checkout")
|
|
329
332
|
|
|
330
333
|
lock = context._lock_path(long_dir)
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Unit tests for the SessionStart/PostToolUse hook logic in hooks.py.
|
|
2
|
+
|
|
3
|
+
``indexer.index_path`` and process spawning are monkeypatched throughout:
|
|
4
|
+
these tests exercise hooks.py's own orchestration (git-repo check, queueing,
|
|
5
|
+
the lock handoff, stdin-JSON parsing, path resolution), not the real indexer
|
|
6
|
+
or a real detached drainer. The lock itself is real: a kernel flock.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from witan_code import context, hooks
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def _state(tmp_path, monkeypatch):
|
|
21
|
+
tmp = tmp_path / "tmp"
|
|
22
|
+
tmp.mkdir()
|
|
23
|
+
monkeypatch.setenv("TMPDIR", str(tmp))
|
|
24
|
+
monkeypatch.setattr(tempfile, "tempdir", str(tmp))
|
|
25
|
+
return tmp
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def _repo(tmp_path, monkeypatch, _state):
|
|
30
|
+
repo = tmp_path / "repo"
|
|
31
|
+
repo.mkdir()
|
|
32
|
+
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
|
33
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(repo))
|
|
34
|
+
return hooks.repo_module.root(repo)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def _spawned(monkeypatch):
|
|
39
|
+
calls = []
|
|
40
|
+
monkeypatch.setattr(
|
|
41
|
+
hooks, "popen_detached", lambda argv, **kw: calls.append((argv, kw))
|
|
42
|
+
)
|
|
43
|
+
return calls
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def _indexed(monkeypatch):
|
|
48
|
+
calls = []
|
|
49
|
+
monkeypatch.setattr(hooks.indexer, "index_path", lambda p, force: calls.append(p))
|
|
50
|
+
return calls
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _payload(path) -> str:
|
|
54
|
+
return f'{{"tool_input": {{"file_path": "{path}"}}}}'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── session_init ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_session_init_noop_outside_git_repo(tmp_path, monkeypatch, _state, _spawned):
|
|
61
|
+
non_repo = tmp_path / "not-a-repo"
|
|
62
|
+
non_repo.mkdir()
|
|
63
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(non_repo))
|
|
64
|
+
|
|
65
|
+
hooks.session_init()
|
|
66
|
+
|
|
67
|
+
assert _spawned == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_session_init_queues_the_project_and_hands_the_lock_to_a_drainer(
|
|
71
|
+
_repo, _spawned
|
|
72
|
+
):
|
|
73
|
+
hooks.session_init()
|
|
74
|
+
|
|
75
|
+
assert len(_spawned) == 1
|
|
76
|
+
argv, kwargs = _spawned[0]
|
|
77
|
+
assert argv[1:5] == ["-m", "witan_code", "_drain-pending", str(_repo)]
|
|
78
|
+
assert kwargs["pass_fds"] == (int(argv[5]),)
|
|
79
|
+
assert hooks._take_pending(_repo) == [_repo]
|
|
80
|
+
# Busy from the moment the lock is taken, before the drainer is even up.
|
|
81
|
+
assert context.indexing_in_progress() is True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_session_init_in_a_subdirectory_shares_the_toplevel_s_lock(
|
|
85
|
+
_repo, monkeypatch, _spawned
|
|
86
|
+
):
|
|
87
|
+
"""A session started in a monorepo subdirectory must not get a lock of its
|
|
88
|
+
own: its full index and the edits' drainer write the same branch view."""
|
|
89
|
+
sub = _repo / "services" / "api"
|
|
90
|
+
sub.mkdir(parents=True)
|
|
91
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(sub))
|
|
92
|
+
held = hooks._try_lock(_repo)
|
|
93
|
+
|
|
94
|
+
hooks.session_init()
|
|
95
|
+
|
|
96
|
+
os.close(held)
|
|
97
|
+
assert _spawned == []
|
|
98
|
+
assert hooks._take_pending(_repo) == [sub] # queued, not dropped
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_session_init_survives_a_failed_spawn(_repo, monkeypatch):
|
|
102
|
+
def _boom(*a, **k):
|
|
103
|
+
raise OSError("cannot spawn")
|
|
104
|
+
|
|
105
|
+
monkeypatch.setattr(hooks, "popen_detached", _boom)
|
|
106
|
+
|
|
107
|
+
hooks.session_init()
|
|
108
|
+
|
|
109
|
+
fd = hooks._try_lock(_repo)
|
|
110
|
+
assert fd is not None # released for the next hook
|
|
111
|
+
os.close(fd)
|
|
112
|
+
assert hooks._has_pending(_repo) # and the refresh is still queued
|
|
113
|
+
assert context.indexing_in_progress() is False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── reindex_hook outside a repo ───────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_reindex_hook_noop_on_empty_payload(_indexed):
|
|
120
|
+
hooks.reindex_hook("")
|
|
121
|
+
assert _indexed == []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_reindex_hook_noop_on_malformed_json(_indexed):
|
|
125
|
+
hooks.reindex_hook("not json")
|
|
126
|
+
assert _indexed == []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_reindex_hook_noop_without_tool_input(_indexed):
|
|
130
|
+
hooks.reindex_hook('{"tool_name": "Edit"}')
|
|
131
|
+
assert _indexed == []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_reindex_hook_noop_when_file_missing(tmp_path, _indexed):
|
|
135
|
+
hooks.reindex_hook(_payload(tmp_path / "does-not-exist.py"))
|
|
136
|
+
assert _indexed == []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_reindex_hook_outside_a_repo_indexes_in_the_foreground(tmp_path, _indexed):
|
|
140
|
+
target = tmp_path / "a.py"
|
|
141
|
+
target.write_text("def f(): pass")
|
|
142
|
+
|
|
143
|
+
hooks.reindex_hook(_payload(target))
|
|
144
|
+
|
|
145
|
+
assert _indexed == [target]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_reindex_hook_resolves_relative_path_against_cwd(
|
|
149
|
+
tmp_path, monkeypatch, _indexed
|
|
150
|
+
):
|
|
151
|
+
monkeypatch.chdir(tmp_path)
|
|
152
|
+
target = tmp_path / "b.py"
|
|
153
|
+
target.write_text("def g(): pass")
|
|
154
|
+
|
|
155
|
+
hooks.reindex_hook('{"tool_input": {"path": "b.py"}}')
|
|
156
|
+
|
|
157
|
+
assert _indexed == [target]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_reindex_hook_swallows_index_failure(tmp_path, monkeypatch):
|
|
161
|
+
target = tmp_path / "c.py"
|
|
162
|
+
target.write_text("def h(): pass")
|
|
163
|
+
|
|
164
|
+
def _boom(p, force):
|
|
165
|
+
raise RuntimeError("parse error")
|
|
166
|
+
|
|
167
|
+
monkeypatch.setattr(hooks.indexer, "index_path", _boom)
|
|
168
|
+
|
|
169
|
+
hooks.reindex_hook(
|
|
170
|
+
f'{{"tool_input": {{"filename": "{target}"}}}}'
|
|
171
|
+
) # must not raise
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── reindex_hook inside a repo ────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_reindex_hook_in_a_repo_queues_and_spawns_a_drainer(_repo, _spawned, _indexed):
|
|
178
|
+
target = _repo / "a.py"
|
|
179
|
+
target.write_text("def f(): pass")
|
|
180
|
+
|
|
181
|
+
hooks.reindex_hook(_payload(target))
|
|
182
|
+
|
|
183
|
+
assert _indexed == [] # the drainer writes, not the hook
|
|
184
|
+
assert [argv[1:5] for argv, _ in _spawned] == [
|
|
185
|
+
["-m", "witan_code", "_drain-pending", str(_repo)]
|
|
186
|
+
]
|
|
187
|
+
assert hooks._take_pending(_repo) == [target]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_reindex_hook_leaves_the_path_to_a_running_drainer(_repo, _spawned):
|
|
191
|
+
target = _repo / "a.py"
|
|
192
|
+
target.write_text("def f(): pass")
|
|
193
|
+
held = hooks._try_lock(_repo)
|
|
194
|
+
|
|
195
|
+
hooks.reindex_hook(_payload(target))
|
|
196
|
+
|
|
197
|
+
os.close(held)
|
|
198
|
+
assert _spawned == []
|
|
199
|
+
assert hooks._take_pending(_repo) == [target]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── drain_pending ─────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_drain_pending_indexes_each_queued_target_once_and_releases(_repo, _indexed):
|
|
206
|
+
a, b = _repo / "a.py", _repo / "b.py"
|
|
207
|
+
for path in (a, b):
|
|
208
|
+
path.write_text("x = 1")
|
|
209
|
+
for path in (a, b, a, a):
|
|
210
|
+
hooks._enqueue(_repo, path)
|
|
211
|
+
|
|
212
|
+
hooks.drain_pending(_repo, hooks._try_lock(_repo))
|
|
213
|
+
|
|
214
|
+
assert _indexed == [a, b]
|
|
215
|
+
assert not hooks._has_pending(_repo)
|
|
216
|
+
assert not context._busy_path(_repo).exists()
|
|
217
|
+
fd = hooks._try_lock(_repo)
|
|
218
|
+
assert fd is not None
|
|
219
|
+
os.close(fd)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_drain_pending_picks_up_a_target_queued_while_it_held_the_lock(
|
|
223
|
+
_repo, monkeypatch
|
|
224
|
+
):
|
|
225
|
+
a, b = _repo / "a.py", _repo / "b.py"
|
|
226
|
+
for path in (a, b):
|
|
227
|
+
path.write_text("x = 1")
|
|
228
|
+
hooks._enqueue(_repo, a)
|
|
229
|
+
indexed = []
|
|
230
|
+
|
|
231
|
+
def _index(p, force):
|
|
232
|
+
indexed.append(p)
|
|
233
|
+
if p == a:
|
|
234
|
+
# An edit landing mid-drain: its hook finds the lock held and
|
|
235
|
+
# leaves the path to this drainer's re-check.
|
|
236
|
+
assert hooks._try_lock(_repo) is None
|
|
237
|
+
hooks._enqueue(_repo, b)
|
|
238
|
+
|
|
239
|
+
monkeypatch.setattr(hooks.indexer, "index_path", _index)
|
|
240
|
+
|
|
241
|
+
hooks.drain_pending(_repo, hooks._try_lock(_repo))
|
|
242
|
+
|
|
243
|
+
assert indexed == [a, b]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_drain_pending_marks_the_checkout_busy_while_it_works(_repo, monkeypatch):
|
|
247
|
+
a = _repo / "a.py"
|
|
248
|
+
a.write_text("x = 1")
|
|
249
|
+
hooks._enqueue(_repo, a)
|
|
250
|
+
seen = []
|
|
251
|
+
monkeypatch.setattr(
|
|
252
|
+
hooks.indexer,
|
|
253
|
+
"index_path",
|
|
254
|
+
lambda p, force: seen.append(context.indexing_in_progress()),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
hooks.drain_pending(_repo, hooks._try_lock(_repo))
|
|
258
|
+
|
|
259
|
+
assert seen == [True]
|
|
260
|
+
assert context.indexing_in_progress() is False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_drain_pending_skips_a_target_deleted_since_it_was_queued(_repo, _indexed):
|
|
264
|
+
hooks._enqueue(_repo, _repo / "gone.py")
|
|
265
|
+
|
|
266
|
+
hooks.drain_pending(_repo, hooks._try_lock(_repo))
|
|
267
|
+
|
|
268
|
+
assert _indexed == []
|
|
269
|
+
assert not hooks._has_pending(_repo)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_drain_pending_continues_past_a_failing_target(_repo, monkeypatch):
|
|
273
|
+
a, b = _repo / "a.py", _repo / "b.py"
|
|
274
|
+
for path in (a, b):
|
|
275
|
+
path.write_text("x = 1")
|
|
276
|
+
hooks._enqueue(_repo, path)
|
|
277
|
+
indexed = []
|
|
278
|
+
|
|
279
|
+
def _index(p, force):
|
|
280
|
+
if p == a:
|
|
281
|
+
raise RuntimeError("write authority changed during preparation")
|
|
282
|
+
indexed.append(p)
|
|
283
|
+
|
|
284
|
+
monkeypatch.setattr(hooks.indexer, "index_path", _index)
|
|
285
|
+
|
|
286
|
+
hooks.drain_pending(_repo, hooks._try_lock(_repo))
|
|
287
|
+
|
|
288
|
+
assert indexed == [b]
|
|
289
|
+
fd = hooks._try_lock(_repo)
|
|
290
|
+
assert fd is not None # released despite the failure
|
|
291
|
+
os.close(fd)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── lock and state ────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_a_killed_holder_does_not_leave_the_checkout_locked(_repo):
|
|
298
|
+
"""The reason the lock is a flock: the kernel drops it with the process,
|
|
299
|
+
so nothing has to decide whether a recorded holder is still alive."""
|
|
300
|
+
holder = subprocess.Popen(
|
|
301
|
+
[
|
|
302
|
+
sys.executable,
|
|
303
|
+
"-c",
|
|
304
|
+
"import fcntl, os, sys, time; "
|
|
305
|
+
"fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT); "
|
|
306
|
+
"fcntl.flock(fd, fcntl.LOCK_EX); print('held', flush=True); "
|
|
307
|
+
"time.sleep(60)",
|
|
308
|
+
str(context._lock_path(_repo)),
|
|
309
|
+
],
|
|
310
|
+
stdout=subprocess.PIPE,
|
|
311
|
+
text=True,
|
|
312
|
+
)
|
|
313
|
+
assert holder.stdout.readline().strip() == "held"
|
|
314
|
+
assert hooks._try_lock(_repo) is None
|
|
315
|
+
|
|
316
|
+
holder.kill()
|
|
317
|
+
holder.wait()
|
|
318
|
+
|
|
319
|
+
fd = hooks._try_lock(_repo)
|
|
320
|
+
assert fd is not None
|
|
321
|
+
os.close(fd)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_state_dir_is_private(_state):
|
|
325
|
+
path = context.state_dir()
|
|
326
|
+
|
|
327
|
+
assert path.stat().st_mode & 0o777 == 0o700
|
|
328
|
+
assert path.stat().st_uid == os.getuid()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_state_dir_tightens_an_existing_directory_s_mode(_state):
|
|
332
|
+
path = _state / f"witan-code-{os.getuid()}"
|
|
333
|
+
path.mkdir(mode=0o777)
|
|
334
|
+
path.chmod(0o777)
|
|
335
|
+
|
|
336
|
+
assert context.state_dir().stat().st_mode & 0o777 == 0o700
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_state_dir_refuses_a_symlink_planted_in_its_place(_state, tmp_path):
|
|
340
|
+
elsewhere = tmp_path / "elsewhere"
|
|
341
|
+
elsewhere.mkdir()
|
|
342
|
+
(_state / f"witan-code-{os.getuid()}").symlink_to(elsewhere)
|
|
343
|
+
|
|
344
|
+
with pytest.raises(PermissionError):
|
|
345
|
+
context.state_dir()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_sessions_with_different_witan_overrides_get_separate_drainers(
|
|
349
|
+
_repo, monkeypatch
|
|
350
|
+
):
|
|
351
|
+
"""The drainer indexes with its spawner's environment, so an edit made
|
|
352
|
+
under ``WITAN_TARGET=qa`` must not be applied by a drainer spawned without
|
|
353
|
+
it."""
|
|
354
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
355
|
+
held = hooks._try_lock(_repo)
|
|
356
|
+
|
|
357
|
+
monkeypatch.setenv("WITAN_TARGET", "qa")
|
|
358
|
+
other = hooks._try_lock(_repo)
|
|
359
|
+
|
|
360
|
+
assert other is not None
|
|
361
|
+
os.close(other)
|
|
362
|
+
os.close(held)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_a_path_with_a_newline_is_not_queued(_repo):
|
|
366
|
+
hooks._enqueue(_repo, _repo / "a\nb.py")
|
|
367
|
+
|
|
368
|
+
assert not hooks._has_pending(_repo)
|