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.
- {witan_code-0.18.0 → witan_code-0.19.1}/CHANGELOG.md +72 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/PKG-INFO +3 -3
- {witan_code-0.18.0 → witan_code-0.19.1}/docs/CLI_REFERENCE.md +17 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/pyproject.toml +25 -4
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_branches.py +73 -3
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_cli_dispatch.py +34 -2
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_context.py +30 -0
- witan_code-0.19.1/tests/test_index_requires_a_repo_key.py +67 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_ingest.py +16 -0
- witan_code-0.19.1/tests/test_rebuild_honours_the_repo_key.py +90 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_repo.py +65 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_setup.py +8 -1
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/cli.py +74 -21
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/context.py +1 -2
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/graph.py +11 -1
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/indexer.py +38 -4
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/ingest.py +2 -1
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/repo.py +26 -3
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/schema/code-schema.pg +11 -6
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/server.py +1 -1
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/setup.py +17 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/store.py +16 -1
- {witan_code-0.18.0 → witan_code-0.19.1}/.gitignore +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/README.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/config/claude.json +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/config/copilot.json +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/config/pi.json +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/conftest.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/docs/BRANCH_INDEXING.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/docs/EDGE_PRECISION_TIERS.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/docs/PACKAGE_MAP.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/docs/STAGE2_STITCHING.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/docs/SYMBOL_FORMAT.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/docs/SYMBOL_TABLE.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/docs/USER_GUIDE.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/install.sh +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/__init__.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/conftest.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_batched_deletes.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_bridge.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_chunked_load.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_cli_hooks.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_cli_output_format.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_cli_selected_target.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_config.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_config_targets.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_crossrepo.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_edges.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_elicit.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_enrichment.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_github_app.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_graph.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_hooks.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_identity.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_index_failure_diagnostics.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_index_reports_its_store.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_indexer.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_indexer_scope.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_languages.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_maintenance.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_package_map.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_reaper.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_remote_proxy.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_remote_store.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_server.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_stitch.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_store.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_symbol_table.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_tasks.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_tool_schema.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_views.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/tests/test_visualize.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/__init__.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/__main__.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/bridge.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/bridge_extractors.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/config.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/edges.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/elicit.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/extensions/pi/codegraph.ts +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/github_app.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/hooks.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/identity.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/maintenance.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/output.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/package_map.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries/bridge.gq +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries/code_mutations.gq +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries/code_read.gq +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries/delete.gq +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/bash.scm +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/hcl.scm +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/python.scm +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/sql.scm +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/typescript.scm +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/queries_ts/yaml.scm +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/reaper.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/remote/__init__.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/remote/oidc.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/remote/proxy.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/remote/store.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/schema/bridge-schema.pg +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/selected_target.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/skills/witan-code/SKILL.md +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/stitch.py +0 -0
- {witan_code-0.18.0 → witan_code-0.19.1}/witan_code/views.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
)
|