witan-code 0.18.0__tar.gz → 0.19.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 (107) hide show
  1. {witan_code-0.18.0 → witan_code-0.19.1}/CHANGELOG.md +72 -0
  2. {witan_code-0.18.0 → witan_code-0.19.1}/PKG-INFO +3 -3
  3. {witan_code-0.18.0 → witan_code-0.19.1}/docs/CLI_REFERENCE.md +17 -0
  4. {witan_code-0.18.0 → witan_code-0.19.1}/pyproject.toml +25 -4
  5. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_branches.py +73 -3
  6. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_cli_dispatch.py +34 -2
  7. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_context.py +30 -0
  8. witan_code-0.19.1/tests/test_index_requires_a_repo_key.py +67 -0
  9. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_ingest.py +16 -0
  10. witan_code-0.19.1/tests/test_rebuild_honours_the_repo_key.py +90 -0
  11. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_repo.py +65 -0
  12. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_setup.py +8 -1
  13. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/cli.py +74 -21
  14. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/context.py +1 -2
  15. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/graph.py +11 -1
  16. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/indexer.py +38 -4
  17. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/ingest.py +2 -1
  18. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/repo.py +26 -3
  19. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/schema/code-schema.pg +11 -6
  20. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/server.py +1 -1
  21. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/setup.py +17 -0
  22. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/store.py +16 -1
  23. {witan_code-0.18.0 → witan_code-0.19.1}/.gitignore +0 -0
  24. {witan_code-0.18.0 → witan_code-0.19.1}/README.md +0 -0
  25. {witan_code-0.18.0 → witan_code-0.19.1}/config/claude.json +0 -0
  26. {witan_code-0.18.0 → witan_code-0.19.1}/config/copilot.json +0 -0
  27. {witan_code-0.18.0 → witan_code-0.19.1}/config/pi.json +0 -0
  28. {witan_code-0.18.0 → witan_code-0.19.1}/conftest.py +0 -0
  29. {witan_code-0.18.0 → witan_code-0.19.1}/docs/BRANCH_INDEXING.md +0 -0
  30. {witan_code-0.18.0 → witan_code-0.19.1}/docs/EDGE_PRECISION_TIERS.md +0 -0
  31. {witan_code-0.18.0 → witan_code-0.19.1}/docs/PACKAGE_MAP.md +0 -0
  32. {witan_code-0.18.0 → witan_code-0.19.1}/docs/STAGE2_STITCHING.md +0 -0
  33. {witan_code-0.18.0 → witan_code-0.19.1}/docs/SYMBOL_FORMAT.md +0 -0
  34. {witan_code-0.18.0 → witan_code-0.19.1}/docs/SYMBOL_TABLE.md +0 -0
  35. {witan_code-0.18.0 → witan_code-0.19.1}/docs/USER_GUIDE.md +0 -0
  36. {witan_code-0.18.0 → witan_code-0.19.1}/install.sh +0 -0
  37. {witan_code-0.18.0 → witan_code-0.19.1}/tests/__init__.py +0 -0
  38. {witan_code-0.18.0 → witan_code-0.19.1}/tests/conftest.py +0 -0
  39. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_batched_deletes.py +0 -0
  40. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_bridge.py +0 -0
  41. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_chunked_load.py +0 -0
  42. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_cli_hooks.py +0 -0
  43. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_cli_output_format.py +0 -0
  44. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_cli_selected_target.py +0 -0
  45. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_config.py +0 -0
  46. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_config_targets.py +0 -0
  47. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_crossrepo.py +0 -0
  48. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_edges.py +0 -0
  49. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_elicit.py +0 -0
  50. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_enrichment.py +0 -0
  51. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_github_app.py +0 -0
  52. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_graph.py +0 -0
  53. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_hooks.py +0 -0
  54. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_identity.py +0 -0
  55. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_index_failure_diagnostics.py +0 -0
  56. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_index_reports_its_store.py +0 -0
  57. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_indexer.py +0 -0
  58. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_indexer_scope.py +0 -0
  59. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_languages.py +0 -0
  60. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_maintenance.py +0 -0
  61. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_package_map.py +0 -0
  62. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_reaper.py +0 -0
  63. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_remote_proxy.py +0 -0
  64. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_remote_store.py +0 -0
  65. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_server.py +0 -0
  66. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_stitch.py +0 -0
  67. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_store.py +0 -0
  68. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_symbol_table.py +0 -0
  69. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_tasks.py +0 -0
  70. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_tool_schema.py +0 -0
  71. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_views.py +0 -0
  72. {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_visualize.py +0 -0
  73. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/__init__.py +0 -0
  74. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/__main__.py +0 -0
  75. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/bridge.py +0 -0
  76. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/bridge_extractors.py +0 -0
  77. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/config.py +0 -0
  78. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/edges.py +0 -0
  79. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/elicit.py +0 -0
  80. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/extensions/pi/codegraph.ts +0 -0
  81. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/github_app.py +0 -0
  82. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/hooks.py +0 -0
  83. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/identity.py +0 -0
  84. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/maintenance.py +0 -0
  85. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/output.py +0 -0
  86. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/package_map.py +0 -0
  87. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries/bridge.gq +0 -0
  88. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries/code_mutations.gq +0 -0
  89. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries/code_read.gq +0 -0
  90. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries/delete.gq +0 -0
  91. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/bash.scm +0 -0
  92. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/hcl.scm +0 -0
  93. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/python.scm +0 -0
  94. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/sql.scm +0 -0
  95. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/typescript.scm +0 -0
  96. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/yaml.scm +0 -0
  97. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/reaper.py +0 -0
  98. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/remote/__init__.py +0 -0
  99. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/remote/oidc.py +0 -0
  100. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/remote/proxy.py +0 -0
  101. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/remote/store.py +0 -0
  102. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/schema/bridge-schema.pg +0 -0
  103. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/selected_target.py +0 -0
  104. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/skills/witan-code/SKILL.md +0 -0
  105. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/stitch.py +0 -0
  106. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/views.py +0 -0
  107. {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/visualize.py +0 -0
@@ -6,6 +6,78 @@ 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]
10
+
11
+ ## [0.19.1] - 2026-09-18
12
+
13
+ ### Changed
14
+
15
+ - **Require `witan-core>=0.37`.** This keeps fresh `witan-code` installs on the
16
+ current shared core release alongside `witan-council`.
17
+
18
+ ## [0.19.0] - 2026-09-16
19
+
20
+ ### Changed
21
+
22
+ - **Every code-graph edge type is keyed on `(@src, @dst)`.** A repeated
23
+ `Calls`/`References`/`Imports` pair upserts instead of appending. Needs
24
+ omnigraph 0.11; code graphs are rebuilt by reindexing. The indexer's
25
+ `_dedupe` stays, because a duplicate pair inside one load now fails the whole
26
+ batch.
27
+
28
+ - **`ClusterGraphMissing` and `IngestRefused` are logged at WARNING, not
29
+ reported to Sentry.** Both are now `witan_core.refusal.Refusal`s, so fastmcp
30
+ logs them at WARNING with no traceback and the client receives the message
31
+ unchanged. Both are still `RuntimeError`s. Requires witan-core 0.35.
32
+
33
+ - **Indexing a target with no resolvable repo key is refused, not guessed.**
34
+ When no `origin` remote resolved, the indexer fell back to the target's
35
+ directory name and wrote a permanent store into the shared per-repo store
36
+ directory under it. That key is neither unique — two `/tmp/*/tests` layouts
37
+ collide on `tests`, one silently overwriting the other's index — nor
38
+ recognizable next to real repo graphs; 36 such stores accumulated on one
39
+ machine, named `out2`, `tmp`, `data`, `sf`. Indexing something with no remote
40
+ is nearly always accidental (a scratch checkout, a temp repro layout), so the
41
+ write path now raises `repo.RepoNotDetected` naming the two opt-ins.
42
+ `witan code index`/`reindex` gained `--repo <uri>` alongside the existing
43
+ `WITAN_REPO`. **Reads of a local store are unaffected**: `repo.detect()` keeps
44
+ the directory fallback by default, so local stores already created under a
45
+ bare name stay reachable. Reads against a cluster drop it (see Fixed). The
46
+ hook paths (`session-init`, the PostToolUse reindex) already
47
+ swallow indexing errors, so this degrades to a no-op there rather than
48
+ surfacing.
49
+
50
+ - **`reindex --rebuild` resolves the repo key before it deletes anything, and
51
+ deletes the store that key names.** `--rebuild` runs ahead of the index, and
52
+ it used to resolve its own key with the directory-name fallback — so
53
+ `--rebuild --repo <uri>` on a remoteless checkout checked and deleted the
54
+ legacy bare-name graph rather than the one it was told to use. Worse, the
55
+ refusal above lived only inside `index_path`, which runs afterwards: with
56
+ `--yes` skipping the confirmation prompt, a run that was about to be refused
57
+ could still drop the shared bridge graph first. The key is now resolved once,
58
+ up front, by `indexer.resolve_repo_key` and passed into the rebuild.
59
+
60
+ ### Fixed
61
+
62
+ - **Reads against a cluster no longer probe a graph named after the checkout's
63
+ directory.** In a checkout with no `origin` remote, `repo.detect()` falls back
64
+ to the directory name, and reads used that key against shared graphs too,
65
+ where no graph is keyed that way. Every prompt in a scratch directory sent
66
+ `code_store_views` for a graph like `code-scratchpad`, `code-tmp` or
67
+ `code-memory`, which made up most `ClusterGraphMissing` events in Sentry.
68
+ `store.detect_repo(cfg)` turns the fallback off when `cfg.is_cluster`. The
69
+ server's read path, the `inject-context` hook and `symbols` use it, and
70
+ `symbols` also turns it off whenever it dispatches to a deployment, which a
71
+ target with only `remote_url` does while keeping a local code store. Local
72
+ stores keep the fallback.
73
+
74
+ - **Two concurrent `code_store_open` calls for one view no longer fail the
75
+ second.** `ensure_branch` listed branches and then ran `branch create`, so two
76
+ callers could both see the view missing, and the loser failed with `branch
77
+ '<view>' already exists`. A create that fails that way now counts as success,
78
+ once a fresh listing confirms the branch is there. Any other create failure
79
+ still raises.
80
+
9
81
  ## [0.18.0] - 2026-09-02
10
82
 
11
83
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: witan-code
3
- Version: 0.18.0
3
+ Version: 0.19.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
@@ -10,7 +10,7 @@ License-Expression: BSD-3-Clause
10
10
  Keywords: agent,code-graph,coding-agent,mcp,static-analysis,tree-sitter
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.11
13
- Requires-Dist: agent-config-kit<1,>=0.4
13
+ Requires-Dist: agent-config-kit<1,>=0.7
14
14
  Requires-Dist: cyclopts<5,>=4
15
15
  Requires-Dist: fastmcp<5,>=4
16
16
  Requires-Dist: joserfc<2,>=1.0
@@ -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.32
29
+ Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.37
30
30
  Provides-Extra: tasks
31
31
  Requires-Dist: fastmcp-tasks<5,>=4; extra == 'tasks'
32
32
  Description-Content-Type: text/markdown
@@ -38,12 +38,21 @@ unchanged since the last index are skipped.
38
38
  | Argument | Type | Default | Notes |
39
39
  |---|---|---|---|
40
40
  | `PATH` / `--path` | `Path` | `.` | Positional or `--path`; file or directory |
41
+ | `--repo` | `str` | detected | Canonical repo URI to key the code graph on |
41
42
 
42
43
  Prints a summary line: `scanned/indexed/skipped/symbols/edges/bindings/errors`.
43
44
 
45
+ **A repo key is required.** It comes from the `origin` remote, or from `--repo`
46
+ / `WITAN_REPO`. When none resolves, indexing is **refused** rather than filed
47
+ under the target's directory name — that key is not unique (two `/tmp/*/tests`
48
+ layouts collide on `tests`) and leaves a permanent store in the shared code
49
+ directory alongside real per-repo graphs. Reads still fall back to the
50
+ directory name, so stores created under one before this remain reachable.
51
+
44
52
  ```bash
45
53
  witan-code index # index the whole repo, cwd
46
54
  witan-code index app/models.py # index a single file
55
+ witan-code index /tmp/scratch --repo https://github.com/org/name # no origin
47
56
  ```
48
57
 
49
58
  ## `reindex [PATH]`
@@ -54,6 +63,14 @@ re-parsed and re-inserted even if unchanged.
54
63
  | Argument | Type | Default | Notes |
55
64
  |---|---|---|---|
56
65
  | `PATH` / `--path` | `Path` | `.` | Positional or `--path` |
66
+ | `--rebuild` | `bool` | `false` | Delete unreadable stores before indexing |
67
+ | `--yes` | `bool` | `false` | Skip `--rebuild`'s confirmation prompt |
68
+ | `--repo` | `str` | detected | Canonical repo URI to key the code graph on |
69
+
70
+ Same repo-key requirement as `index` above. The key is resolved **before**
71
+ `--rebuild` deletes anything, and the rebuild deletes the store that key names —
72
+ so a run that would be refused deletes nothing, and `--rebuild --repo <uri>`
73
+ cannot drop a different graph than the one it is about to write.
57
74
 
58
75
  ```bash
59
76
  witan-code reindex # rebuild the whole repo's store
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "witan-code"
3
- version = "0.18.0"
3
+ version = "0.19.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"
@@ -10,7 +10,14 @@ classifiers = [
10
10
  "Programming Language :: Python :: 3",
11
11
  ]
12
12
  dependencies = [
13
- "agent-config-kit>=0.4,<1",
13
+ # >=0.7: witan_code/setup.py passes `mcp_servers_by_platform` to
14
+ # `RegistrationBundle`, a field added in agent-config-kit 0.7.0. It is a
15
+ # dataclass, so an external install resolving 0.4–0.6 raises TypeError on
16
+ # the unexpected keyword the moment `witan-code setup` builds its bundle.
17
+ # Same blind spot as the witan-core floors below — the workspace resolves
18
+ # agent-config-kit by path — except there is NO `check-core-floor`
19
+ # equivalent for this dependency, so this line is the only guard.
20
+ "agent-config-kit>=0.7,<1",
14
21
  # `remote`: the ADR-0005 client stack (OIDC device grant + MCP-client
15
22
  # proxy) behind `witan-code login` and the read commands' remote mode.
16
23
  # Costs nothing extra in practice — fastmcp is already required here and
@@ -121,7 +128,21 @@ dependencies = [
121
128
  # branches were open. Both times the pyproject line auto-merged without a
122
129
  # conflict, since both sides named the same number. Re-check it by hand on
123
130
  # every rebase; a floor one release low here is silent.
124
- "witan-core[cli,remote,observability,sentry]>=0.32,<1",
131
+ #
132
+ # ★ >=0.34 IS AN IMPORT FLOOR, the ordinary kind: `cli.py` imports
133
+ # `with_console_ctx` from `witan_core.elicit`, added in witan-core 0.34.0 in
134
+ # the same change as this caller. Caught by `just check-core-floor` on PR
135
+ # CI, which is exactly the case that check exists for.
136
+ #
137
+ # ★ >=0.35 IS AN IMPORT FLOOR too: `store.py` and `ingest.py` import
138
+ # `witan_core.refusal`, added in witan-core 0.35.0 in the same change.
139
+ #
140
+ # ★ >=0.37 KEEPS BOTH SERVERS ON THE CURRENT SHARED CORE RELEASE. It also
141
+ # preserves >=0.36's format floor: omnigraph 0.11 is the only binary that
142
+ # parses the keyed `code-schema.pg`, and it restores the null fields 0.11
143
+ # drops from query rows. An older witan-core imports fine and then fails
144
+ # on the first schema init.
145
+ "witan-core[cli,remote,observability,sentry]>=0.37,<1",
125
146
  "fastmcp>=4,<5",
126
147
  "cyclopts>=4,<5",
127
148
  # [targets.<name>] override models (config.py) — also transitively pulled
@@ -204,7 +225,7 @@ testpaths = ["tests"]
204
225
  packages = ["witan_code"]
205
226
 
206
227
  [tool.bumpversion]
207
- current_version = "0.18.0"
228
+ current_version = "0.19.1"
208
229
  allow_dirty = true
209
230
 
210
231
  [[tool.bumpversion.files]]
@@ -4,6 +4,8 @@ import asyncio
4
4
  import inspect
5
5
  import subprocess
6
6
 
7
+ import pytest
8
+
7
9
  from witan_code import repo as repo_module
8
10
 
9
11
  from .conftest import SAMPLE, requires_stack
@@ -155,6 +157,74 @@ def test_feature_branch_indexes_to_own_branch(tmp_path, monkeypatch):
155
157
  assert not on_main, "main view must not see in-flight branch symbols"
156
158
 
157
159
 
160
+ @requires_stack
161
+ def test_a_view_opened_by_two_callers_at_once_is_not_an_error(tmp_path, monkeypatch):
162
+ """Two concurrent `code_store_open` calls for one view both list branches
163
+ before either creates it, so the second create fails with "already exists"
164
+ (Sentry WITAN-4). The view exists, which is what both callers asked for."""
165
+ from witan_code import config as cfg_module
166
+ from witan_code import indexer
167
+ from witan_code.graph import OmnigraphClient
168
+
169
+ monkeypatch.setenv("WITAN_REPO", REPO)
170
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
171
+ cfg = cfg_module.load()
172
+ base = _git_repo(tmp_path / "r")
173
+ (base / "svc.py").write_text(SAMPLE)
174
+ indexer.index_path(base, config=cfg)
175
+ store = str(cfg_module.store_path(REPO, cfg.code_dir))
176
+
177
+ winner = OmnigraphClient(store, cfg.queries_dir, branch="feature_race")
178
+ loser = OmnigraphClient(store, cfg.queries_dir, branch="feature_race")
179
+ stale = loser.list_branches()
180
+ winner.ensure_branch()
181
+
182
+ real_list = loser.list_branches
183
+ listings = []
184
+
185
+ def list_branches():
186
+ listings.append(None)
187
+ return stale if len(listings) == 1 else real_list()
188
+
189
+ monkeypatch.setattr(loser, "list_branches", list_branches)
190
+ loser.ensure_branch()
191
+ assert len(listings) == 2, "the loser should have tried to create, then re-listed"
192
+ assert "feature_race" in real_list()
193
+
194
+
195
+ @requires_stack
196
+ @pytest.mark.parametrize(
197
+ ("error", "listed"),
198
+ [
199
+ ("omnigraph branch failed (exit 1):\npermission denied", ["main"]),
200
+ # The message alone is not trusted: the branch has to actually be there.
201
+ (
202
+ "omnigraph branch failed (exit 1):\nbranch 'feature_race' already exists",
203
+ ["main"],
204
+ ),
205
+ ],
206
+ )
207
+ def test_a_create_failure_that_is_not_the_race_still_raises(
208
+ tmp_path, monkeypatch, error, listed
209
+ ):
210
+ from witan_code import config as cfg_module
211
+ from witan_code.graph import OmnigraphClient
212
+
213
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
214
+ client = OmnigraphClient(
215
+ str(tmp_path / "store"), cfg_module.load().queries_dir, branch="feature_race"
216
+ )
217
+ monkeypatch.setattr(client, "list_branches", lambda: listed)
218
+
219
+ def fail(*_args, **_kwargs):
220
+ raise RuntimeError(error)
221
+
222
+ monkeypatch.setattr(client, "_run", fail)
223
+ with pytest.raises(RuntimeError) as excinfo:
224
+ client.ensure_branch()
225
+ assert str(excinfo.value) == error
226
+
227
+
158
228
  @requires_stack
159
229
  def test_a_logged_in_writer_owns_the_views_it_indexes(
160
230
  tmp_path, monkeypatch, logged_in_actor
@@ -310,7 +380,7 @@ def test_cached_git_amortizes_repeated_calls_within_ttl(monkeypatch):
310
380
  monkeypatch.setattr(
311
381
  srv.repo_module,
312
382
  "detect",
313
- lambda: calls.__setitem__("detect", calls["detect"] + 1) or "r",
383
+ lambda **_kw: calls.__setitem__("detect", calls["detect"] + 1) or "r",
314
384
  )
315
385
  monkeypatch.setattr(
316
386
  srv.repo_module,
@@ -330,7 +400,7 @@ def test_cached_git_refreshes_after_ttl(monkeypatch):
330
400
 
331
401
  srv._git_context.clear()
332
402
  values = iter(["a", "b"])
333
- monkeypatch.setattr(srv.repo_module, "detect", lambda: next(values))
403
+ monkeypatch.setattr(srv.repo_module, "detect", lambda **_kw: next(values))
334
404
 
335
405
  assert srv._cached_detect() == "a"
336
406
  srv._git_context["detect"] = (
@@ -355,7 +425,7 @@ def test_git_context_survives_within_a_test(monkeypatch):
355
425
  from witan_code import server as srv
356
426
 
357
427
  srv._git_context.clear()
358
- monkeypatch.setattr(srv.repo_module, "detect", lambda: LEAKED_REPO)
428
+ monkeypatch.setattr(srv.repo_module, "detect", lambda **_kw: LEAKED_REPO)
359
429
  assert srv._cached_detect() == LEAKED_REPO
360
430
  assert srv._git_context["detect"][1] == LEAKED_REPO
361
431
 
@@ -90,6 +90,38 @@ def test_symbols_resolves_the_repo_client_side(monkeypatch):
90
90
  assert srv.calls[0][1]["repo"] == "https://github.com/test/detected"
91
91
 
92
92
 
93
+ @pytest.mark.parametrize(("remote", "fallback"), [(True, False), (False, True)])
94
+ def test_symbols_never_sends_a_directory_name_to_a_deployment(
95
+ monkeypatch, remote, fallback
96
+ ):
97
+ """A target with only `remote_url` keeps a local code store, so the store
98
+ config alone would allow the fallback; the remote dispatch must not."""
99
+ monkeypatch.delenv("WITAN_CODE_SERVER", raising=False)
100
+ monkeypatch.delenv("WITAN_CODE_TRANSPORT", raising=False)
101
+ srv = _stub(code_repo_symbols=[])
102
+ monkeypatch.setattr(cli_module, "_srv", lambda: srv)
103
+ monkeypatch.setattr(cli_module, "_is_remote", lambda: remote)
104
+ seen = []
105
+
106
+ def _detect(*_a, dirname_fallback=True, **_kw):
107
+ seen.append(dirname_fallback)
108
+ return "scratchproj" if dirname_fallback else None
109
+
110
+ monkeypatch.setattr("witan_code.repo.detect", _detect)
111
+
112
+ cli_module.symbols()
113
+
114
+ # Last, not only: the local path loads config, whose target match detects too.
115
+ assert seen[-1] is fallback
116
+ assert srv.calls == (
117
+ []
118
+ if remote
119
+ else [
120
+ ("code_repo_symbols", {"repo": "scratchproj", "role": None, "scheme": None})
121
+ ]
122
+ )
123
+
124
+
93
125
  def test_stitch_dispatches_to_precise_edges(monkeypatch, capsys):
94
126
  srv = _stub(
95
127
  code_precise_edges=[
@@ -320,7 +352,7 @@ def test_rebuild_refuses_a_path_that_is_not_the_repo_root(
320
352
  monkeypatch.setattr(store_module, "discard_store", _boom)
321
353
 
322
354
  with pytest.raises(SystemExit):
323
- cli_module._rebuild_stores(sub, yes=True)
355
+ cli_module._rebuild_stores(sub, yes=True, slug="https://github.com/test/cg")
324
356
 
325
357
  out = capsys.readouterr().out
326
358
  assert "not the repo root" in out
@@ -352,6 +384,6 @@ def test_rebuild_refuses_a_file_path_with_real_git(tmp_path, monkeypatch, capsys
352
384
  monkeypatch.setattr(store_module, "discard_store", _boom)
353
385
 
354
386
  with pytest.raises(SystemExit):
355
- cli_module._rebuild_stores(target, yes=True)
387
+ cli_module._rebuild_stores(target, yes=True, slug="https://github.com/test/cg")
356
388
 
357
389
  assert "not the repo root" in capsys.readouterr().out
@@ -6,9 +6,13 @@ check, store-exists branching), not the underlying query.
6
6
  """
7
7
 
8
8
  import os
9
+ import shutil
10
+ import subprocess
9
11
  import time
10
12
  from pathlib import Path
11
13
 
14
+ import pytest
15
+
12
16
  from witan_code import context
13
17
 
14
18
 
@@ -45,6 +49,32 @@ def test_inject_context_empty_without_repo(tmp_path, monkeypatch):
45
49
  assert context.inject_context() == ""
46
50
 
47
51
 
52
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
53
+ def test_inject_context_does_not_probe_a_dirname_graph_on_a_cluster(
54
+ tmp_path, monkeypatch
55
+ ):
56
+ """A checkout with no origin has no repo key when the store is a cluster.
57
+
58
+ Every prompt in a scratch directory used to probe ``code-scratchpad``,
59
+ which no provisioning ever declares (Sentry WITAN-G).
60
+ """
61
+ monkeypatch.delenv("WITAN_REPO", raising=False)
62
+ monkeypatch.setenv("WITAN_CODE_SERVER", "https://omnigraph.example")
63
+ checkout = tmp_path / "scratchpad"
64
+ checkout.mkdir()
65
+ subprocess.run(["git", "init", "-q", str(checkout)], check=True)
66
+ monkeypatch.chdir(checkout)
67
+ probed = []
68
+ monkeypatch.setattr(
69
+ context.store_module.StoreRef,
70
+ "exists",
71
+ lambda self, config=None: probed.append(self) or False,
72
+ )
73
+
74
+ assert context.inject_context() == ""
75
+ assert probed == []
76
+
77
+
48
78
  def test_inject_context_empty_when_no_store_and_no_lock(tmp_path, monkeypatch):
49
79
  monkeypatch.setenv("WITAN_REPO", "https://github.com/test/cg")
50
80
  monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
@@ -0,0 +1,67 @@
1
+ """Indexing something with no resolvable repo key is refused, not guessed.
2
+
3
+ The guess used to be the target's directory name, written as a permanent store
4
+ into the SHARED per-repo store directory. That key is neither unique (two
5
+ ``/tmp/*/tests`` layouts collide on ``tests``, one silently overwriting the
6
+ other) nor recognizable next to real repo graphs — 36 such stores accumulated on
7
+ one machine, named ``out2``, ``tmp``, ``data``, ``sf``.
8
+
9
+ No omnigraph binary needed: the refusal lands before the store is touched, which
10
+ is the whole point.
11
+ """
12
+
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from witan_code import indexer
20
+ from witan_code import repo as repo_module
21
+
22
+
23
+ @pytest.fixture
24
+ def scratch(tmp_path, monkeypatch) -> Path:
25
+ """A source tree with no repo key available from anywhere."""
26
+ monkeypatch.delenv("WITAN_REPO", raising=False)
27
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
28
+ src = tmp_path / "tests"
29
+ src.mkdir()
30
+ (src / "a.py").write_text("def a():\n return 1\n")
31
+ return src
32
+
33
+
34
+ def _code_dir(tmp_path: Path) -> list[Path]:
35
+ code = tmp_path / "code"
36
+ return sorted(code.iterdir()) if code.exists() else []
37
+
38
+
39
+ def test_bare_directory_is_refused_and_creates_no_store(scratch, tmp_path):
40
+ with pytest.raises(repo_module.RepoNotDetected) as exc:
41
+ indexer.index_path(scratch)
42
+
43
+ assert "no git remote" in str(exc.value)
44
+ assert "WITAN_REPO" in str(exc.value)
45
+ assert _code_dir(tmp_path) == []
46
+
47
+
48
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
49
+ def test_git_checkout_without_a_remote_is_refused_too(scratch, tmp_path):
50
+ """The other vector: a real checkout whose ``origin`` was never added.
51
+ ``detect`` resolves a git root here, so the refusal has to come from the
52
+ absence of a *remote*, not the absence of git."""
53
+ subprocess.run(["git", "init", "-q", str(scratch)], check=True)
54
+
55
+ with pytest.raises(repo_module.RepoNotDetected):
56
+ indexer.index_path(scratch)
57
+
58
+ assert _code_dir(tmp_path) == []
59
+
60
+
61
+ def test_a_file_under_a_bare_directory_is_refused(scratch, tmp_path):
62
+ """The PostToolUse reindex hook passes a single edited file, which is how
63
+ scratch layouts got indexed without anyone running `index` at all."""
64
+ with pytest.raises(repo_module.RepoNotDetected):
65
+ indexer.index_path(scratch / "a.py")
66
+
67
+ assert _code_dir(tmp_path) == []
@@ -345,6 +345,22 @@ def test_the_graph_listing_names_repos_not_ids(sample_repo):
345
345
  assert ingest.graphs() == [REPO]
346
346
 
347
347
 
348
+ def test_store_refusals_are_warning_level_refusals():
349
+ """Refusing a caller is the tier working, so neither reaches Sentry as an
350
+ error. Both stay RuntimeErrors for the callers that catch them as one."""
351
+ import logging
352
+
353
+ from witan_core.refusal import Refusal
354
+
355
+ from witan_code import store as store_module
356
+
357
+ for cls in (ingest.IngestRefused, store_module.ClusterGraphMissing):
358
+ exc = cls("refused")
359
+ assert isinstance(exc, Refusal)
360
+ assert isinstance(exc, RuntimeError)
361
+ assert exc.log_level == logging.WARNING
362
+
363
+
348
364
  def test_a_graph_the_cluster_does_not_declare_fails_on_the_first_call(monkeypatch):
349
365
  """Not on the thousandth record. A client cannot create a cluster graph —
350
366
  provisioning declares them — so a run that continues writes nowhere."""
@@ -0,0 +1,90 @@
1
+ """`reindex --rebuild` resolves its repo key the same way the index does.
2
+
3
+ `--rebuild` DELETES stores before indexing, so which key it resolves is not a
4
+ display detail. Two ways that went wrong when it resolved its own:
5
+
6
+ 1. It used the directory-name fallback, so `--rebuild --repo <uri>` on a
7
+ remoteless checkout checked and deleted the legacy bare-name graph instead of
8
+ the one it was told to use.
9
+ 2. The refusal for an unkeyable target lived only inside `index_path`, which
10
+ runs *after* the rebuild — so `--rebuild --yes` there could drop the shared
11
+ bridge graph and only then decline to index. A run that is going to be
12
+ refused must delete nothing.
13
+ """
14
+
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ from witan_code import cli as cli_module
21
+
22
+
23
+ @pytest.fixture
24
+ def remoteless_checkout(tmp_path, monkeypatch) -> Path:
25
+ """A real git checkout whose `origin` was never added."""
26
+ monkeypatch.delenv("WITAN_REPO", raising=False)
27
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
28
+ root = tmp_path / "scratchproj"
29
+ root.mkdir()
30
+ subprocess.run(["git", "init", "-q", str(root)], check=True)
31
+ return root
32
+
33
+
34
+ @pytest.fixture
35
+ def never_deletes(monkeypatch):
36
+ """Fail loudly if any store is discarded."""
37
+ from witan_code import store as store_module
38
+
39
+ def _boom(*_args, **_kwargs):
40
+ raise AssertionError("deleted a store on a run that must delete nothing")
41
+
42
+ monkeypatch.setattr(store_module, "discard_store", _boom)
43
+
44
+
45
+ def test_rebuild_uses_the_explicit_repo_not_the_directory_name(
46
+ remoteless_checkout, monkeypatch
47
+ ):
48
+ from witan_code import store as store_module
49
+
50
+ asked: list[str] = []
51
+ monkeypatch.setattr(
52
+ store_module,
53
+ "store_for_repo",
54
+ lambda slug, _cfg: asked.append(slug) or _Missing(),
55
+ )
56
+ monkeypatch.setattr(store_module, "bridge_store", lambda _cfg: _Missing())
57
+
58
+ cli_module._rebuild_stores(
59
+ remoteless_checkout, yes=True, slug="https://github.com/test/cg"
60
+ )
61
+
62
+ # Not "scratchproj" — the directory name the old fallback would have used.
63
+ assert asked == ["https://github.com/test/cg"]
64
+
65
+
66
+ def test_an_unkeyable_target_is_refused_before_anything_is_deleted(
67
+ remoteless_checkout, never_deletes, capsys
68
+ ):
69
+ """The ordering half. `--yes` skips the confirmation prompt, so nothing else
70
+ stands between the old code and a deleted bridge graph."""
71
+ with pytest.raises(SystemExit):
72
+ cli_module.reindex(remoteless_checkout, rebuild=True, yes=True)
73
+
74
+ assert "no git remote" in capsys.readouterr().err
75
+
76
+
77
+ def test_the_refusal_names_both_opt_ins(remoteless_checkout, never_deletes, capsys):
78
+ with pytest.raises(SystemExit):
79
+ cli_module.reindex(remoteless_checkout, rebuild=True, yes=True)
80
+
81
+ err = capsys.readouterr().err
82
+ assert "--repo" in err
83
+ assert "WITAN_REPO" in err
84
+
85
+
86
+ class _Missing:
87
+ """A store ref that does not exist, so nothing is a rebuild candidate."""
88
+
89
+ def exists(self, _cfg=None):
90
+ return False
@@ -85,3 +85,68 @@ def test_detect_tolerates_multivalued_fetch(tmp_path, monkeypatch):
85
85
  check=True,
86
86
  )
87
87
  assert repo.detect(start=tmp_path) == "https://github.com/mitodl/ol-data-platform"
88
+
89
+
90
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
91
+ def test_detect_falls_back_to_dirname_for_reads(tmp_path, monkeypatch):
92
+ """A store already created under a bare directory name has to stay
93
+ reachable, so the read default keeps the fallback."""
94
+ monkeypatch.delenv("WITAN_REPO", raising=False)
95
+ checkout = tmp_path / "scratchproj"
96
+ checkout.mkdir()
97
+ subprocess.run(["git", "init", "-q", str(checkout)], check=True)
98
+
99
+ assert repo.detect(start=checkout) == "scratchproj"
100
+
101
+
102
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
103
+ @pytest.mark.parametrize(
104
+ ("env", "expected"),
105
+ [
106
+ ({}, "scratchproj"),
107
+ ({"WITAN_CODE_SERVER": "https://omnigraph.example"}, None),
108
+ ({"WITAN_CODE_TRANSPORT": "mcp"}, None),
109
+ ],
110
+ )
111
+ def test_detect_repo_only_falls_back_to_dirname_for_a_local_store(
112
+ tmp_path, monkeypatch, env, expected
113
+ ):
114
+ """No cluster graph is keyed by a directory name, so a cluster read gets
115
+ no repo rather than a probe of a graph that cannot exist."""
116
+ from witan_code import store
117
+
118
+ monkeypatch.delenv("WITAN_REPO", raising=False)
119
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
120
+ for name, value in env.items():
121
+ monkeypatch.setenv(name, value)
122
+ checkout = tmp_path / "scratchproj"
123
+ checkout.mkdir()
124
+ subprocess.run(["git", "init", "-q", str(checkout)], check=True)
125
+ monkeypatch.chdir(checkout)
126
+
127
+ assert store.detect_repo() == expected
128
+
129
+
130
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
131
+ def test_detect_without_dirname_fallback_returns_none(tmp_path, monkeypatch):
132
+ monkeypatch.delenv("WITAN_REPO", raising=False)
133
+ checkout = tmp_path / "scratchproj"
134
+ checkout.mkdir()
135
+ subprocess.run(["git", "init", "-q", str(checkout)], check=True)
136
+
137
+ assert repo.detect(start=checkout, dirname_fallback=False) is None
138
+
139
+
140
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
141
+ def test_dirname_fallback_does_not_override_an_explicit_repo(tmp_path, monkeypatch):
142
+ """The opt-in has to work on the write path: WITAN_REPO is resolved before
143
+ the remote lookup, so `dirname_fallback=False` never reaches it."""
144
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/test/cg")
145
+ checkout = tmp_path / "scratchproj"
146
+ checkout.mkdir()
147
+ subprocess.run(["git", "init", "-q", str(checkout)], check=True)
148
+
149
+ assert (
150
+ repo.detect(start=checkout, dirname_fallback=False)
151
+ == "https://github.com/test/cg"
152
+ )