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.
Files changed (111) hide show
  1. {witan_code-0.19.1 → witan_code-0.20.1}/.gitignore +4 -0
  2. {witan_code-0.19.1 → witan_code-0.20.1}/CHANGELOG.md +69 -1
  3. {witan_code-0.19.1 → witan_code-0.20.1}/PKG-INFO +2 -2
  4. witan_code-0.20.1/changelog.d/README.md +13 -0
  5. {witan_code-0.19.1 → witan_code-0.20.1}/pyproject.toml +11 -3
  6. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_branches.py +77 -7
  7. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_context.py +6 -3
  8. witan_code-0.20.1/tests/test_hooks.py +368 -0
  9. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_reaper.py +48 -1
  10. witan_code-0.20.1/tests/test_refusal_audit.py +48 -0
  11. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_store.py +62 -0
  12. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/cli.py +13 -10
  13. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/context.py +85 -18
  14. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/graph.py +194 -32
  15. witan_code-0.20.1/witan_code/hooks.py +234 -0
  16. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/store.py +18 -1
  17. witan_code-0.19.1/tests/test_hooks.py +0 -188
  18. witan_code-0.19.1/witan_code/hooks.py +0 -116
  19. {witan_code-0.19.1 → witan_code-0.20.1}/README.md +0 -0
  20. {witan_code-0.19.1 → witan_code-0.20.1}/config/claude.json +0 -0
  21. {witan_code-0.19.1 → witan_code-0.20.1}/config/copilot.json +0 -0
  22. {witan_code-0.19.1 → witan_code-0.20.1}/config/pi.json +0 -0
  23. {witan_code-0.19.1 → witan_code-0.20.1}/conftest.py +0 -0
  24. {witan_code-0.19.1 → witan_code-0.20.1}/docs/BRANCH_INDEXING.md +0 -0
  25. {witan_code-0.19.1 → witan_code-0.20.1}/docs/CLI_REFERENCE.md +0 -0
  26. {witan_code-0.19.1 → witan_code-0.20.1}/docs/EDGE_PRECISION_TIERS.md +0 -0
  27. {witan_code-0.19.1 → witan_code-0.20.1}/docs/PACKAGE_MAP.md +0 -0
  28. {witan_code-0.19.1 → witan_code-0.20.1}/docs/STAGE2_STITCHING.md +0 -0
  29. {witan_code-0.19.1 → witan_code-0.20.1}/docs/SYMBOL_FORMAT.md +0 -0
  30. {witan_code-0.19.1 → witan_code-0.20.1}/docs/SYMBOL_TABLE.md +0 -0
  31. {witan_code-0.19.1 → witan_code-0.20.1}/docs/USER_GUIDE.md +0 -0
  32. {witan_code-0.19.1 → witan_code-0.20.1}/install.sh +0 -0
  33. {witan_code-0.19.1 → witan_code-0.20.1}/tests/__init__.py +0 -0
  34. {witan_code-0.19.1 → witan_code-0.20.1}/tests/conftest.py +0 -0
  35. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_batched_deletes.py +0 -0
  36. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_bridge.py +0 -0
  37. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_chunked_load.py +0 -0
  38. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_cli_dispatch.py +0 -0
  39. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_cli_hooks.py +0 -0
  40. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_cli_output_format.py +0 -0
  41. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_cli_selected_target.py +0 -0
  42. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_config.py +0 -0
  43. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_config_targets.py +0 -0
  44. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_crossrepo.py +0 -0
  45. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_edges.py +0 -0
  46. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_elicit.py +0 -0
  47. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_enrichment.py +0 -0
  48. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_github_app.py +0 -0
  49. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_graph.py +0 -0
  50. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_identity.py +0 -0
  51. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_index_failure_diagnostics.py +0 -0
  52. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_index_reports_its_store.py +0 -0
  53. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_index_requires_a_repo_key.py +0 -0
  54. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_indexer.py +0 -0
  55. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_indexer_scope.py +0 -0
  56. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_ingest.py +0 -0
  57. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_languages.py +0 -0
  58. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_maintenance.py +0 -0
  59. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_package_map.py +0 -0
  60. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_rebuild_honours_the_repo_key.py +0 -0
  61. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_remote_proxy.py +0 -0
  62. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_remote_store.py +0 -0
  63. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_repo.py +0 -0
  64. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_server.py +0 -0
  65. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_setup.py +0 -0
  66. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_stitch.py +0 -0
  67. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_symbol_table.py +0 -0
  68. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_tasks.py +0 -0
  69. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_tool_schema.py +0 -0
  70. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_views.py +0 -0
  71. {witan_code-0.19.1 → witan_code-0.20.1}/tests/test_visualize.py +0 -0
  72. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/__init__.py +0 -0
  73. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/__main__.py +0 -0
  74. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/bridge.py +0 -0
  75. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/bridge_extractors.py +0 -0
  76. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/config.py +0 -0
  77. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/edges.py +0 -0
  78. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/elicit.py +0 -0
  79. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/extensions/pi/codegraph.ts +0 -0
  80. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/github_app.py +0 -0
  81. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/identity.py +0 -0
  82. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/indexer.py +0 -0
  83. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/ingest.py +0 -0
  84. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/maintenance.py +0 -0
  85. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/output.py +0 -0
  86. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/package_map.py +0 -0
  87. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries/bridge.gq +0 -0
  88. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries/code_mutations.gq +0 -0
  89. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries/code_read.gq +0 -0
  90. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries/delete.gq +0 -0
  91. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/bash.scm +0 -0
  92. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/hcl.scm +0 -0
  93. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/python.scm +0 -0
  94. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/sql.scm +0 -0
  95. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/typescript.scm +0 -0
  96. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/queries_ts/yaml.scm +0 -0
  97. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/reaper.py +0 -0
  98. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/remote/__init__.py +0 -0
  99. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/remote/oidc.py +0 -0
  100. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/remote/proxy.py +0 -0
  101. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/remote/store.py +0 -0
  102. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/repo.py +0 -0
  103. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/schema/bridge-schema.pg +0 -0
  104. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/schema/code-schema.pg +0 -0
  105. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/selected_target.py +0 -0
  106. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/server.py +0 -0
  107. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/setup.py +0 -0
  108. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/skills/witan-code/SKILL.md +0 -0
  109. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/stitch.py +0 -0
  110. {witan_code-0.19.1 → witan_code-0.20.1}/witan_code/views.py +0 -0
  111. {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
- ## [Unreleased]
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.19.1
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.37
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.19.1"
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.37,<1",
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.19.1"
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
- ("error", "listed"),
199
+ ("exc", "listed"),
198
200
  [
199
- ("omnigraph branch failed (exit 1):\npermission denied", ["main"]),
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
- "omnigraph branch failed (exit 1):\nbranch 'feature_race' already exists",
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, error, listed
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 RuntimeError(error)
240
+ raise exc
221
241
 
222
- monkeypatch.setattr(client, "_run", fail)
242
+ monkeypatch.setattr(client, "statement", fail)
223
243
  with pytest.raises(RuntimeError) as excinfo:
224
244
  client.ensure_branch()
225
- assert str(excinfo.value) == error
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._lock_path(project_dir)
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.setenv("TMPDIR", str(tmp_path))
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.setenv("TMPDIR", str(tmp_path))
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)