witan-code 0.4.0__tar.gz → 0.6.0__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 (79) hide show
  1. {witan_code-0.4.0 → witan_code-0.6.0}/CHANGELOG.md +37 -0
  2. {witan_code-0.4.0 → witan_code-0.6.0}/PKG-INFO +25 -1
  3. {witan_code-0.4.0 → witan_code-0.6.0}/README.md +23 -0
  4. {witan_code-0.4.0 → witan_code-0.6.0}/pyproject.toml +7 -4
  5. witan_code-0.6.0/tests/test_config.py +64 -0
  6. witan_code-0.6.0/tests/test_config_targets.py +247 -0
  7. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_repo.py +36 -0
  8. witan_code-0.6.0/witan_code/config.py +214 -0
  9. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/elicit.py +1 -1
  10. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/graph.py +2 -1
  11. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/repo.py +11 -5
  12. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/schema/bridge-schema.pg +6 -2
  13. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/schema/code-schema.pg +6 -1
  14. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/server.py +3 -0
  15. witan_code-0.4.0/witan_code/config.py +0 -65
  16. {witan_code-0.4.0 → witan_code-0.6.0}/.gitignore +0 -0
  17. {witan_code-0.4.0 → witan_code-0.6.0}/config/claude.json +0 -0
  18. {witan_code-0.4.0 → witan_code-0.6.0}/config/copilot.json +0 -0
  19. {witan_code-0.4.0 → witan_code-0.6.0}/config/pi.json +0 -0
  20. {witan_code-0.4.0 → witan_code-0.6.0}/docs/BRANCH_INDEXING.md +0 -0
  21. {witan_code-0.4.0 → witan_code-0.6.0}/docs/CLI_REFERENCE.md +0 -0
  22. {witan_code-0.4.0 → witan_code-0.6.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
  23. {witan_code-0.4.0 → witan_code-0.6.0}/docs/PACKAGE_MAP.md +0 -0
  24. {witan_code-0.4.0 → witan_code-0.6.0}/docs/STAGE2_STITCHING.md +0 -0
  25. {witan_code-0.4.0 → witan_code-0.6.0}/docs/SYMBOL_FORMAT.md +0 -0
  26. {witan_code-0.4.0 → witan_code-0.6.0}/docs/SYMBOL_TABLE.md +0 -0
  27. {witan_code-0.4.0 → witan_code-0.6.0}/docs/USER_GUIDE.md +0 -0
  28. {witan_code-0.4.0 → witan_code-0.6.0}/install.sh +0 -0
  29. {witan_code-0.4.0 → witan_code-0.6.0}/tests/__init__.py +0 -0
  30. {witan_code-0.4.0 → witan_code-0.6.0}/tests/conftest.py +0 -0
  31. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_branches.py +0 -0
  32. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_bridge.py +0 -0
  33. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_cli_hooks.py +0 -0
  34. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_cli_output_format.py +0 -0
  35. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_context.py +0 -0
  36. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_crossrepo.py +0 -0
  37. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_edges.py +0 -0
  38. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_elicit.py +0 -0
  39. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_enrichment.py +0 -0
  40. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_graph.py +0 -0
  41. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_hooks.py +0 -0
  42. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_indexer.py +0 -0
  43. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_languages.py +0 -0
  44. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_maintenance.py +0 -0
  45. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_package_map.py +0 -0
  46. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_server.py +0 -0
  47. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_setup.py +0 -0
  48. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_stitch.py +0 -0
  49. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_symbol_table.py +0 -0
  50. {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_visualize.py +0 -0
  51. {witan_code-0.4.0 → witan_code-0.6.0}/uv.lock +0 -0
  52. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/__init__.py +0 -0
  53. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/__main__.py +0 -0
  54. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/bridge.py +0 -0
  55. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/bridge_extractors.py +0 -0
  56. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/cli.py +0 -0
  57. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/context.py +0 -0
  58. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/edges.py +0 -0
  59. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/extensions/pi/codegraph.ts +0 -0
  60. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/hooks.py +0 -0
  61. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/indexer.py +0 -0
  62. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/maintenance.py +0 -0
  63. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/output.py +0 -0
  64. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/package_map.py +0 -0
  65. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries/bridge.gq +0 -0
  66. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries/code_mutations.gq +0 -0
  67. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries/code_read.gq +0 -0
  68. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries/delete.gq +0 -0
  69. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/bash.scm +0 -0
  70. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/hcl.scm +0 -0
  71. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/python.scm +0 -0
  72. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/sql.scm +0 -0
  73. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/typescript.scm +0 -0
  74. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/yaml.scm +0 -0
  75. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/setup.py +0 -0
  76. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/skills/witan-code/SKILL.md +0 -0
  77. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/stitch.py +0 -0
  78. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/store.py +0 -0
  79. {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/visualize.py +0 -0
@@ -6,6 +6,43 @@ 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
+ ## [0.6.0] - 2026-07-21
10
+
11
+ ### Fixed
12
+
13
+ - **`repo.detect(override=...)` and the `WITAN_REPO` env var now route
14
+ through `normalise`** (issue #142), same as an auto-detected git remote —
15
+ matching the fix in witan-council 0.6.0. Previously an explicitly-passed
16
+ `repo=` (or `WITAN_REPO`) was stored verbatim, bypassing
17
+ canonicalization. `graph_id` (the shared-cluster graph id) already
18
+ case-folded unconditionally and needs no change; a repo whose canonical
19
+ key changes case after this fix should be re-indexed (`witan-code
20
+ reindex`) so its per-repo store lands under the new key.
21
+ - Depends on `witan-core[cli]>=0.2,<1` (unchanged range; picks up 0.4.0's
22
+ repo-key case-fold in `normalise`, which this package's `repo.detect`
23
+ relies on).
24
+
25
+ ## [0.5.0] - 2026-07-20
26
+
27
+ ### Added
28
+
29
+ - `config.toml` + `[targets.<name>]` support (previously env-var only):
30
+ `WITAN_CONFIG` / `~/.config/witan/config.toml`, a global `code_dir`/
31
+ `author`, and named targets overriding `code_dir`/`author`, selected by
32
+ `WITAN_TARGET` env var, an explicit `load(target=...)`, or auto-detection
33
+ via `match_paths`/`match_repos`/`match_hosts`/`match_orgs` (same
34
+ precedence and file as witan — see `witan_core.target_config` and
35
+ `witan.config.load()`'s docstring). A target can carry witan's
36
+ `server`/`graph`/`token` alongside this server's `code_dir` under one
37
+ name; witan-code reads only the fields it knows. `Config` gained
38
+ `target_name`. `load()` now optionally takes a `target` argument (was
39
+ zero-argument only). New dependency: `pydantic>=2,<3`.
40
+
41
+ ### Changed
42
+
43
+ - Now depends on `witan-core[cli]>=0.2,<1` (unchanged range; picks up
44
+ 0.3.0, which adds the shared target-routing logic this release uses).
45
+
9
46
  ## [0.4.0] - 2026-07-16
10
47
 
11
48
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witan-code
3
- Version: 0.4.0
3
+ Version: 0.6.0
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
@@ -13,6 +13,7 @@ Requires-Python: >=3.11
13
13
  Requires-Dist: agent-config-kit<1,>=0.4
14
14
  Requires-Dist: cyclopts<5,>=4
15
15
  Requires-Dist: fastmcp<4,>=3.4.2
16
+ Requires-Dist: pydantic<3,>=2
16
17
  Requires-Dist: pyyaml<7,>=6
17
18
  Requires-Dist: rich>=13
18
19
  Requires-Dist: tomli-w<2,>=1
@@ -334,12 +335,35 @@ symlink alternative:
334
335
  | `WITAN_AUTHOR` / `USER` | `unknown` | attribution string |
335
336
  | `WITAN_REPO` | — | override the detected repo slug |
336
337
  | `WITAN_CODE_OPTIMIZE_INTERVAL` | `86400` (daily) | throttle window (seconds) for `checkpoint`'s opportunistic store compaction; `0` disables it |
338
+ | `WITAN_CONFIG` | `~/.config/witan/config.toml` | config file path (see below) |
339
+ | `WITAN_TARGET` | — | force a named `[targets.<name>]` block instead of auto-detecting one |
337
340
 
338
341
  The store URI for a repo is `<dir>/<sanitized-slug>.omni`, where the slug's `/`
339
342
  and `:` are replaced with `_`. The shared cross-repo bridge lives alongside them
340
343
  at `<dir>/_bridge.omni` and is created lazily on the first index that yields any
341
344
  bindings.
342
345
 
346
+ ### config.toml
347
+
348
+ witan-code reads the same `config.toml` as witan (witan-council) — a global
349
+ `code_dir`/`author`, plus named `[targets.<name>]` blocks that override them
350
+ and are selected by `WITAN_TARGET`, `load(target=...)` (Python API only — no
351
+ CLI `--target` flag yet), or auto-detection against the current
352
+ repo/checkout (`match_paths` > `match_repos` > `match_hosts` > `match_orgs`
353
+ — see witan's README/`witan/config.py` docstring for the full precedence).
354
+ Because the file is shared, one target block can carry witan's
355
+ `server`/`graph`/`token` alongside `code_dir` under
356
+ the same name — each server reads only the fields it knows:
357
+
358
+ ```toml
359
+ [targets.work]
360
+ server = "http://witan.internal:8080" # witan (witan-council)
361
+ code_dir = "/mnt/work/witan-code" # witan-code
362
+ match_orgs = ["myorg"]
363
+ ```
364
+
365
+ Environment variables always win over `config.toml`.
366
+
343
367
  ## MCP tools
344
368
 
345
369
  Each tool resolves the per-repo store from the current working directory and
@@ -305,12 +305,35 @@ symlink alternative:
305
305
  | `WITAN_AUTHOR` / `USER` | `unknown` | attribution string |
306
306
  | `WITAN_REPO` | — | override the detected repo slug |
307
307
  | `WITAN_CODE_OPTIMIZE_INTERVAL` | `86400` (daily) | throttle window (seconds) for `checkpoint`'s opportunistic store compaction; `0` disables it |
308
+ | `WITAN_CONFIG` | `~/.config/witan/config.toml` | config file path (see below) |
309
+ | `WITAN_TARGET` | — | force a named `[targets.<name>]` block instead of auto-detecting one |
308
310
 
309
311
  The store URI for a repo is `<dir>/<sanitized-slug>.omni`, where the slug's `/`
310
312
  and `:` are replaced with `_`. The shared cross-repo bridge lives alongside them
311
313
  at `<dir>/_bridge.omni` and is created lazily on the first index that yields any
312
314
  bindings.
313
315
 
316
+ ### config.toml
317
+
318
+ witan-code reads the same `config.toml` as witan (witan-council) — a global
319
+ `code_dir`/`author`, plus named `[targets.<name>]` blocks that override them
320
+ and are selected by `WITAN_TARGET`, `load(target=...)` (Python API only — no
321
+ CLI `--target` flag yet), or auto-detection against the current
322
+ repo/checkout (`match_paths` > `match_repos` > `match_hosts` > `match_orgs`
323
+ — see witan's README/`witan/config.py` docstring for the full precedence).
324
+ Because the file is shared, one target block can carry witan's
325
+ `server`/`graph`/`token` alongside `code_dir` under
326
+ the same name — each server reads only the fields it knows:
327
+
328
+ ```toml
329
+ [targets.work]
330
+ server = "http://witan.internal:8080" # witan (witan-council)
331
+ code_dir = "/mnt/work/witan-code" # witan-code
332
+ match_orgs = ["myorg"]
333
+ ```
334
+
335
+ Environment variables always win over `config.toml`.
336
+
314
337
  ## MCP tools
315
338
 
316
339
  Each tool resolves the per-repo store from the current working directory and
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "witan-code"
3
- version = "0.4.0"
3
+ version = "0.6.0"
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"
@@ -14,6 +14,9 @@ dependencies = [
14
14
  "witan-core[cli]>=0.2,<1",
15
15
  "fastmcp>=3.4.2,<4",
16
16
  "cyclopts>=4,<5",
17
+ # [targets.<name>] override models (config.py) — also transitively pulled
18
+ # in by fastmcp, declared explicitly since config.py imports it directly.
19
+ "pydantic>=2,<3",
17
20
  # Pinned to 0.25.x: tree-sitter 0.26.0 has a use-after-free in its pyo3 Node
18
21
  # binding that segfaults (exit 139) under the heavy .parent()/.children()
19
22
  # churn _walk_defs does on real-world-sized files — deterministically on the
@@ -58,8 +61,8 @@ build-backend = "hatchling.build"
58
61
  # agent-config-kit version bumped alongside it — even before that version
59
62
  # has actually landed on PyPI. Published wheels only see the PyPI-range
60
63
  # dependency declared above.
61
- agent-config-kit = { path = "../../../packages/agent-config-kit", editable = true }
62
- witan-core = { path = "../../../packages/witan-core", editable = true }
64
+ agent-config-kit = { workspace = true }
65
+ witan-core = { workspace = true }
63
66
 
64
67
  [dependency-groups]
65
68
  test = ["pytest>=8"]
@@ -72,7 +75,7 @@ testpaths = ["tests"]
72
75
  packages = ["witan_code"]
73
76
 
74
77
  [tool.bumpversion]
75
- current_version = "0.4.0"
78
+ current_version = "0.6.0"
76
79
  allow_dirty = true
77
80
 
78
81
  [[tool.bumpversion.files]]
@@ -0,0 +1,64 @@
1
+ """Canonical cluster graph-id derivation (config.graph_id).
2
+
3
+ This id is a cross-repo contract: witan-code addresses `--graph <id>` and
4
+ ol-infrastructure's cluster.yaml provisioning declares the same `<id>`. If this
5
+ algorithm drifts from the provisioning side, clients address graphs the cluster
6
+ never created — so pin the observable mapping here.
7
+ """
8
+
9
+ from witan_code import config
10
+
11
+
12
+ def test_graph_id_canonical_example():
13
+ # The example from the deployment decision; must be byte-for-byte stable.
14
+ assert (
15
+ config.graph_id("https://github.com/mitodl/ol-django")
16
+ == "code-github-com-mitodl-ol-django"
17
+ )
18
+
19
+
20
+ def test_graph_id_matches_constraint():
21
+ for repo in (
22
+ "https://github.com/mitodl/ol-django",
23
+ "http://example.com/x",
24
+ "git@github.com:mitodl/agent-kit.git",
25
+ "github.com/mitodl/ol-infrastructure",
26
+ "https://github.com/MITODL/Mixed_Case.Repo",
27
+ ):
28
+ assert config.GRAPH_ID_RE.match(config.graph_id(repo)), repo
29
+
30
+
31
+ def test_graph_id_no_underscores_and_lowercase():
32
+ gid = config.graph_id("https://github.com/MITODL/Mixed_Case.Repo")
33
+ assert "_" not in gid
34
+ assert gid == gid.lower()
35
+
36
+
37
+ def test_graph_id_strips_scheme():
38
+ with_scheme = config.graph_id("https://github.com/mitodl/x")
39
+ without = config.graph_id("github.com/mitodl/x")
40
+ assert with_scheme == without == "code-github-com-mitodl-x"
41
+
42
+
43
+ def test_graph_id_is_prefixed():
44
+ assert config.graph_id("github.com/a/b").startswith(config.CODE_GRAPH_PREFIX)
45
+
46
+
47
+ def test_graph_id_long_repo_truncates_with_hash():
48
+ long_repo = "https://github.com/mitodl/" + "a" * 200
49
+ gid = config.graph_id(long_repo)
50
+ assert len(gid) <= 64
51
+ assert config.GRAPH_ID_RE.match(gid)
52
+ # Distinct long repos disambiguate via the URI hash, never colliding.
53
+ other = config.graph_id("https://github.com/mitodl/" + "a" * 199 + "b")
54
+ assert gid != other
55
+
56
+
57
+ def test_graph_id_is_deterministic():
58
+ repo = "https://github.com/mitodl/" + "z" * 100
59
+ assert config.graph_id(repo) == config.graph_id(repo)
60
+
61
+
62
+ def test_bridge_graph_id_constant():
63
+ assert config.BRIDGE_GRAPH_ID == "code-bridge"
64
+ assert config.GRAPH_ID_RE.match(config.BRIDGE_GRAPH_ID)
@@ -0,0 +1,247 @@
1
+ """Tests for witan_code.config's [targets.<name>] routing (load(), _parse_targets).
2
+
3
+ The match-precedence logic itself (match_paths/match_repos/match_hosts/
4
+ match_orgs) lives in witan_core.target_config, with its own test suite at
5
+ packages/witan-core/tests/test_target_config.py; this file covers only
6
+ witan-code's own typed target model and load() integration.
7
+ """
8
+
9
+ import textwrap
10
+
11
+ import pytest
12
+
13
+ from witan_code.config import _parse_targets, load
14
+
15
+
16
+ # ── _parse_targets ────────────────────────────────────────────────────────────
17
+
18
+
19
+ def test_parse_targets_empty():
20
+ assert _parse_targets({}) == []
21
+
22
+
23
+ def test_parse_targets_basic():
24
+ raw = {
25
+ "targets": {"work": {"code_dir": "/mnt/work-code", "match_orgs": ["mitodl"]}}
26
+ }
27
+ result = _parse_targets(raw)
28
+ assert len(result) == 1
29
+ assert result[0].name == "work"
30
+ assert result[0].code_dir == "/mnt/work-code"
31
+ assert result[0].match_orgs == ["mitodl"]
32
+ assert result[0].match_paths == []
33
+
34
+
35
+ def test_parse_targets_targets_not_a_table():
36
+ with pytest.raises(ValueError, match="must be a table"):
37
+ _parse_targets({"targets": ["not", "a", "table"]})
38
+
39
+
40
+ def test_parse_targets_target_entry_not_a_table():
41
+ with pytest.raises(ValueError, match="must be a table"):
42
+ _parse_targets({"targets": {"work": "not-a-table"}})
43
+
44
+
45
+ def test_parse_targets_invalid_match_orgs_type():
46
+ raw = {"targets": {"work": {"match_orgs": 42}}}
47
+ with pytest.raises(ValueError, match="Expected a list or string"):
48
+ _parse_targets(raw)
49
+
50
+
51
+ # ── load() ────────────────────────────────────────────────────────────────────
52
+
53
+
54
+ @pytest.fixture
55
+ def toml_file(tmp_path):
56
+ """Write a config.toml and return its path."""
57
+
58
+ def _write(content: str) -> str:
59
+ p = tmp_path / "config.toml"
60
+ p.write_text(textwrap.dedent(content))
61
+ return str(p)
62
+
63
+ return _write
64
+
65
+
66
+ def test_load_defaults(monkeypatch, toml_file):
67
+ monkeypatch.setenv("WITAN_CONFIG", toml_file(""))
68
+ monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
69
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
70
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/nobody/nothing")
71
+
72
+ cfg = load()
73
+ assert cfg.target_name is None
74
+ assert str(cfg.code_dir).endswith("/.local/share/witan/code")
75
+
76
+
77
+ def test_load_env_overrides_file(monkeypatch, toml_file):
78
+ monkeypatch.setenv("WITAN_CONFIG", toml_file('code_dir = "/from-file"'))
79
+ monkeypatch.setenv("WITAN_CODE_DIR", "/from-env")
80
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/nobody/nothing")
81
+
82
+ cfg = load()
83
+ assert str(cfg.code_dir) == "/from-env"
84
+
85
+
86
+ def test_load_global_file_code_dir(monkeypatch, toml_file):
87
+ monkeypatch.setenv("WITAN_CONFIG", toml_file('code_dir = "/from-file"'))
88
+ monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
89
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/nobody/nothing")
90
+
91
+ cfg = load()
92
+ assert str(cfg.code_dir) == "/from-file"
93
+
94
+
95
+ def test_load_auto_detects_target_by_org(monkeypatch, toml_file):
96
+ monkeypatch.setenv(
97
+ "WITAN_CONFIG",
98
+ toml_file(
99
+ """
100
+ [targets.work]
101
+ code_dir = "/work-code"
102
+ match_orgs = ["mitodl"]
103
+ """
104
+ ),
105
+ )
106
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
107
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
108
+ monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
109
+
110
+ cfg = load()
111
+ assert cfg.target_name == "work"
112
+ assert str(cfg.code_dir) == "/work-code"
113
+
114
+
115
+ def test_load_auto_detects_target_by_path(monkeypatch, toml_file, tmp_path):
116
+ checkout = tmp_path / "code" / "personal" / "dotfiles"
117
+ checkout.mkdir(parents=True)
118
+ monkeypatch.setenv(
119
+ "WITAN_CONFIG",
120
+ toml_file(
121
+ f"""
122
+ [targets.personal]
123
+ code_dir = "/personal-code"
124
+ match_paths = [{str(tmp_path / "code" / "personal")!r}]
125
+ """
126
+ ),
127
+ )
128
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(checkout))
129
+ monkeypatch.setenv("WITAN_REPO", "")
130
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
131
+ monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
132
+
133
+ cfg = load()
134
+ assert cfg.target_name == "personal"
135
+ assert str(cfg.code_dir) == "/personal-code"
136
+
137
+
138
+ def test_load_explicit_target_arg(monkeypatch, toml_file):
139
+ monkeypatch.setenv(
140
+ "WITAN_CONFIG",
141
+ toml_file(
142
+ """
143
+ [targets.personal]
144
+ code_dir = "/personal-code"
145
+ match_orgs = ["alice"]
146
+ """
147
+ ),
148
+ )
149
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
150
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
151
+ monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
152
+
153
+ cfg = load(target="personal")
154
+ assert cfg.target_name == "personal"
155
+ assert str(cfg.code_dir) == "/personal-code"
156
+
157
+
158
+ def test_load_witan_target_env(monkeypatch, toml_file):
159
+ monkeypatch.setenv(
160
+ "WITAN_CONFIG",
161
+ toml_file(
162
+ """
163
+ [targets.work]
164
+ code_dir = "/work-code"
165
+ match_orgs = ["mitodl"]
166
+ """
167
+ ),
168
+ )
169
+ monkeypatch.setenv("WITAN_TARGET", "work")
170
+ monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
171
+
172
+ cfg = load()
173
+ assert cfg.target_name == "work"
174
+
175
+
176
+ def test_load_target_inherits_global_defaults(monkeypatch, toml_file):
177
+ """A target that omits author falls through to the global config value."""
178
+ monkeypatch.setenv(
179
+ "WITAN_CONFIG",
180
+ toml_file(
181
+ """
182
+ author = "Alice"
183
+
184
+ [targets.work]
185
+ code_dir = "/work-code"
186
+ match_orgs = ["mitodl"]
187
+ """
188
+ ),
189
+ )
190
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
191
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
192
+ monkeypatch.delenv("WITAN_AUTHOR", raising=False)
193
+ monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
194
+
195
+ cfg = load()
196
+ assert cfg.target_name == "work"
197
+ assert cfg.author == "Alice"
198
+
199
+
200
+ def test_load_unknown_explicit_target_raises(monkeypatch, toml_file):
201
+ monkeypatch.setenv("WITAN_CONFIG", toml_file('[targets.work]\nmatch_orgs = ["x"]'))
202
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
203
+
204
+ with pytest.raises(ValueError, match="Unknown target 'nope'"):
205
+ load(target="nope")
206
+
207
+
208
+ def test_load_missing_config_file_uses_defaults(monkeypatch, tmp_path):
209
+ monkeypatch.setenv("WITAN_CONFIG", str(tmp_path / "nonexistent.toml"))
210
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
211
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/nobody/nothing")
212
+
213
+ cfg = load()
214
+ assert cfg.target_name is None
215
+
216
+
217
+ def test_load_malformed_toml_raises(monkeypatch, tmp_path):
218
+ bad = tmp_path / "bad.toml"
219
+ bad.write_text("this is not [ valid toml !!!")
220
+ monkeypatch.setenv("WITAN_CONFIG", str(bad))
221
+
222
+ with pytest.raises(ValueError, match="Failed to parse config file"):
223
+ load()
224
+
225
+
226
+ def test_load_shares_config_file_with_witan(monkeypatch, toml_file):
227
+ """One [targets.<name>] block can carry both witan's server/graph and
228
+ witan-code's code_dir — this server only reads the fields it knows."""
229
+ monkeypatch.setenv(
230
+ "WITAN_CONFIG",
231
+ toml_file(
232
+ """
233
+ [targets.work]
234
+ server = "http://work:8080"
235
+ graph = "council-work"
236
+ code_dir = "/work-code"
237
+ match_orgs = ["mitodl"]
238
+ """
239
+ ),
240
+ )
241
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
242
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
243
+ monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
244
+
245
+ cfg = load()
246
+ assert cfg.target_name == "work"
247
+ assert str(cfg.code_dir) == "/work-code"
@@ -18,6 +18,42 @@ def test_detect_env_override(monkeypatch):
18
18
  assert repo.detect() == "https://github.com/test/cg"
19
19
 
20
20
 
21
+ def test_detect_env_override_is_normalised(monkeypatch):
22
+ monkeypatch.setenv("WITAN_REPO", "https://GitHub.com/MITODL/OL-Django")
23
+ assert repo.detect() == "https://github.com/mitodl/ol-django"
24
+
25
+
26
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
27
+ def test_detect_empty_env_disables_autodetection(monkeypatch, tmp_path):
28
+ """An explicit empty WITAN_REPO must disable auto-detection outright,
29
+ matching witan-council's repo.detect — not fall through to git remote
30
+ lookup (the walrus-operator `if env_repo := ...` form treats "" as
31
+ falsy and used to do exactly that)."""
32
+ monkeypatch.setenv("WITAN_REPO", "")
33
+ subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
34
+ subprocess.run(
35
+ [
36
+ "git",
37
+ "-C",
38
+ str(tmp_path),
39
+ "remote",
40
+ "add",
41
+ "origin",
42
+ "git@github.com:mitodl/should-not-be-detected.git",
43
+ ],
44
+ check=True,
45
+ )
46
+ assert repo.detect(start=tmp_path) is None
47
+
48
+
49
+ def test_detect_override_arg_is_normalised(monkeypatch):
50
+ monkeypatch.delenv("WITAN_REPO", raising=False)
51
+ assert (
52
+ repo.detect(override="git@github.com:MITODL/OL-Django.git")
53
+ == "https://github.com/mitodl/ol-django"
54
+ )
55
+
56
+
21
57
  @pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
22
58
  def test_detect_tolerates_multivalued_fetch(tmp_path, monkeypatch):
23
59
  # git allows several `fetch =` lines under a remote; configparser rejects
@@ -0,0 +1,214 @@
1
+ import hashlib
2
+ import os
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
+ from witan_core.config_file import load_toml as _load_toml_shared
9
+ from witan_core.target_config import (
10
+ local_project_path,
11
+ match_target,
12
+ parse_target_tables,
13
+ to_list,
14
+ )
15
+
16
+ from . import repo as repo_module
17
+
18
+ # Bundled query files, resolved relative to this file.
19
+ _QUERIES_DIR = Path(__file__).parent / "queries"
20
+ _SCHEMA_FILE = Path(__file__).parent / "schema" / "code-schema.pg"
21
+ _BRIDGE_SCHEMA_FILE = Path(__file__).parent / "schema" / "bridge-schema.pg"
22
+ _DEFAULT_CODE_DIR = Path.home() / ".local" / "share" / "witan" / "code"
23
+
24
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "witan" / "config.toml"
25
+ """Shared with witan (witan-council) — see witan_core.config_file. A
26
+ [targets.<name>] block can carry ``code_dir`` here alongside witan's
27
+ ``server``/``graph``/``token`` under the same name, routed together."""
28
+
29
+ # Filename of the single shared cross-repo bridge store, a sibling of the
30
+ # per-repo `<slug>.omni` stores in code_dir. Not routed through sanitize_slug
31
+ # (whose .strip("_") would eat the leading underscore); no real repo slug
32
+ # resolves to this name.
33
+ BRIDGE_STORE_NAME = "_bridge.omni"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Config:
38
+ code_dir: Path
39
+ """Directory holding per-repo code stores (one ``<slug>.omni`` each)."""
40
+
41
+ author: str
42
+ """Attribution string (carried for parity with Layer 1; unused on inserts)."""
43
+
44
+ queries_dir: Path
45
+ """Directory containing code_read.gq, code_mutations.gq, delete.gq."""
46
+
47
+ schema_file: Path
48
+ """Path to code-schema.pg, used to lazily init a per-repo store."""
49
+
50
+ bridge_schema_file: Path
51
+ """Path to bridge-schema.pg, used to lazily init the shared bridge store."""
52
+
53
+ target_name: str | None = None
54
+ """Name of the matched [targets.<name>] section, or None for global defaults."""
55
+
56
+
57
+ class _Target(BaseModel):
58
+ model_config = ConfigDict(frozen=True)
59
+
60
+ name: str
61
+ code_dir: str | None = None
62
+ author: str | None = None
63
+ match_orgs: list[str] = Field(default_factory=list)
64
+ match_repos: list[str] = Field(default_factory=list)
65
+ match_hosts: list[str] = Field(default_factory=list)
66
+ match_paths: list[str] = Field(default_factory=list)
67
+
68
+ @field_validator(
69
+ "match_orgs", "match_repos", "match_hosts", "match_paths", mode="before"
70
+ )
71
+ @classmethod
72
+ def _normalize_match_list(cls, v: object) -> list[str]:
73
+ return to_list(v)
74
+
75
+
76
+ def _load_toml() -> dict:
77
+ """Load WITAN_CONFIG path or ~/.config/witan/config.toml. Returns {} on missing file.
78
+
79
+ Reads the same file as witan (witan-council) — see DEFAULT_CONFIG_PATH.
80
+ """
81
+ return _load_toml_shared(DEFAULT_CONFIG_PATH)
82
+
83
+
84
+ def _parse_targets(raw: dict) -> list[_Target]:
85
+ return [_Target(name=name, **cfg) for name, cfg in parse_target_tables(raw).items()]
86
+
87
+
88
+ def _first(*values: str | None, default: str | None = None) -> str | None:
89
+ for v in values:
90
+ if v:
91
+ return v
92
+ return default
93
+
94
+
95
+ def load(target: str | None = None) -> Config:
96
+ """Load config from config.toml + environment, selecting a named target if applicable.
97
+
98
+ Resolution order (highest -> lowest precedence):
99
+ 1. Environment variables (WITAN_CODE_DIR, WITAN_AUTHOR)
100
+ 2. Named target: ``target`` arg > WITAN_TARGET env var > auto-detect by
101
+ repo or local checkout path
102
+ 3. Global values in config.toml
103
+ 4. Hardcoded defaults
104
+
105
+ Shares config.toml (``WITAN_CONFIG`` / ``~/.config/witan/config.toml``)
106
+ and the ``[targets.<name>]`` tables with witan (witan-council) — the same
107
+ target block can carry witan's ``server``/``graph``/``token`` alongside
108
+ this server's ``code_dir``, routed together. See ``witan.config.load()``'s
109
+ docstring for the full ``match_paths``/``match_repos``/``match_hosts``/
110
+ ``match_orgs`` precedence (shared logic: ``witan_core.target_config``).
111
+
112
+ Raises ValueError for an explicitly-requested target that is not defined.
113
+ """
114
+ file_cfg = _load_toml()
115
+ targets = _parse_targets(file_cfg)
116
+
117
+ explicit = target or os.environ.get("WITAN_TARGET")
118
+ if explicit:
119
+ selected = next((t for t in targets if t.name == explicit), None)
120
+ if selected is None:
121
+ available = ", ".join(t.name for t in targets) or "(none defined)"
122
+ raise ValueError(f"Unknown target {explicit!r}. Available: {available}")
123
+ else:
124
+ selected = match_target(
125
+ targets, repo_uri=repo_module.detect(), local_path=local_project_path()
126
+ )
127
+
128
+ raw_code_dir = _first(
129
+ os.environ.get("WITAN_CODE_DIR"),
130
+ selected.code_dir if selected else None,
131
+ file_cfg.get("code_dir"),
132
+ default=str(_DEFAULT_CODE_DIR),
133
+ )
134
+
135
+ return Config(
136
+ code_dir=Path(raw_code_dir).expanduser(),
137
+ author=_first(
138
+ os.environ.get("WITAN_AUTHOR"),
139
+ selected.author if selected else None,
140
+ file_cfg.get("author"),
141
+ os.environ.get("USER"),
142
+ default="unknown",
143
+ ),
144
+ queries_dir=_QUERIES_DIR,
145
+ schema_file=_SCHEMA_FILE,
146
+ bridge_schema_file=_BRIDGE_SCHEMA_FILE,
147
+ target_name=selected.name if selected else None,
148
+ )
149
+
150
+
151
+ def sanitize_slug(slug: str) -> str:
152
+ """Make a repo slug safe for use as a LOCAL filename / branch-name component.
153
+
154
+ Emits underscores (``[/:]+`` → ``_``). Fine for local ``<slug>.omni`` store
155
+ dirs and per-repo branch prefixes, but NOT valid as a shared-cluster graph
156
+ id — omnigraph graph ids must match ``^[a-zA-Z0-9-]{1,64}$`` (no
157
+ underscores). Use :func:`graph_id` to derive the cluster ``--graph`` id.
158
+ """
159
+ return re.sub(r"[/:]+", "_", slug).strip("_")
160
+
161
+
162
+ # ── Shared-cluster graph id ──────────────────────────────────────────────────
163
+ #
164
+ # On the deployed omnigraph-server, each repo's code graph is a distinct cluster
165
+ # graph addressed as `--server <url> --graph <id>`. `graph_id()` is the CANONICAL
166
+ # repo-URI → graph-id function.
167
+ #
168
+ # SHARED CONTRACT — this exact algorithm is mirrored by ol-infrastructure's
169
+ # Pulumi provisioning (toolhive_witan/data_tier.py, which declares each
170
+ # `code-<repo>` graph in the cluster.yaml ConfigMap). witan-code selects the
171
+ # `--graph` id and provisioning declares the same id; they MUST agree
172
+ # byte-for-byte or a client will address a graph the cluster never created. Any
173
+ # change here has to land in lockstep on both sides — see task
174
+ # tk-code-graph-deployment-topology-shared-per-repo-c-cac400.
175
+ CODE_GRAPH_PREFIX = "code-"
176
+ # The shared cross-repo bridge graph (Layer 2.5), analogous to the local
177
+ # `_bridge.omni` store. Fixed id, not derived from any repo.
178
+ BRIDGE_GRAPH_ID = "code-bridge"
179
+ # omnigraph's graph-id constraint. Enforced by construction in `graph_id`.
180
+ _GRAPH_ID_MAX_LEN = 64
181
+ GRAPH_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,64}$")
182
+
183
+
184
+ def graph_id(repo: str) -> str:
185
+ """Canonical cluster graph-id for ``repo``'s code graph.
186
+
187
+ e.g. ``https://github.com/mitodl/ol-django`` → ``code-github-com-mitodl-ol-django``.
188
+
189
+ Strip the URI scheme, collapse every run of non-alphanumerics to ``-``,
190
+ lowercase, and prefix ``code-``. The result always satisfies
191
+ :data:`GRAPH_ID_RE`. Slugs that would exceed :data:`_GRAPH_ID_MAX_LEN` are
192
+ truncated and disambiguated with a hash of the full repo URI (so distinct
193
+ long repos never collide on the same id).
194
+ """
195
+ body = re.sub(r"(?i)^[a-z][a-z0-9+.-]*://", "", repo) # strip scheme
196
+ body = re.sub(r"[^a-zA-Z0-9]+", "-", body).strip("-").lower()
197
+ candidate = f"{CODE_GRAPH_PREFIX}{body}"
198
+ if len(candidate) <= _GRAPH_ID_MAX_LEN:
199
+ return candidate
200
+ digest = hashlib.sha256(repo.encode()).hexdigest()[:8]
201
+ keep = _GRAPH_ID_MAX_LEN - len(CODE_GRAPH_PREFIX) - len(digest) - 1
202
+ return f"{CODE_GRAPH_PREFIX}{body[:keep].strip('-')}-{digest}"
203
+
204
+
205
+ def store_path(slug: str, code_dir: Path | None = None) -> Path:
206
+ """Resolve the per-repo store path for ``slug``."""
207
+ base = code_dir or load().code_dir
208
+ return base / f"{sanitize_slug(slug)}.omni"
209
+
210
+
211
+ def bridge_store_path(code_dir: Path | None = None) -> Path:
212
+ """Resolve the shared cross-repo bridge store path."""
213
+ base = code_dir or load().code_dir
214
+ return base / BRIDGE_STORE_NAME
@@ -44,7 +44,7 @@ async def choose_repo(
44
44
  doesn't match any candidate — returns ``None``, meaning "keep every
45
45
  match" (today's behavior, unchanged).
46
46
  """
47
- answer = await text(ctx, message, default="")
47
+ answer = await text(ctx, message, default="", title="Repo")
48
48
  if not answer:
49
49
  return None
50
50
  normalized = answer.strip().casefold()
@@ -29,12 +29,13 @@ class OmnigraphClient(_BaseOmnigraphClient):
29
29
  queries_dir: Path,
30
30
  token: str | None = None,
31
31
  branch: str | None = None,
32
+ graph_id: str | None = None,
32
33
  ) -> None:
33
34
  # Target omnigraph branch for query/mutate/load; None = the store's main
34
35
  # branch. Loads pass `--from main` so the branch forks lazily on first
35
36
  # write (docs/BRANCH_INDEXING.md).
36
37
  self.branch = branch
37
- super().__init__(graph_uri, queries_dir, token)
38
+ super().__init__(graph_uri, queries_dir, token, graph_id=graph_id)
38
39
 
39
40
  def load(self, records: list[dict], mode: str = "merge") -> None:
40
41
  """Bulk-load node/edge records via one ``omnigraph load`` call.
@@ -12,8 +12,13 @@ def detect(override: str | None = None, start: Path | None = None) -> str | None
12
12
  Return a canonical repo key (HTTPS URI) for ``start`` (or the cwd).
13
13
 
14
14
  Resolution order:
15
- 1. ``override`` parameter (explicit caller value)
16
- 2. ``WITAN_REPO`` environment variable
15
+ 1. ``override`` parameter (explicit caller value) — canonicalized the
16
+ same way an auto-detected remote is (see ``normalise``); an explicit
17
+ empty string means "no repo scope"
18
+ 2. ``WITAN_REPO`` environment variable — canonicalized the same way; an
19
+ explicit empty string also disables auto-detection here, matching
20
+ witan-council's ``repo.detect`` (e.g. set in a global MCP server
21
+ config where the server CWD is not the session's repo)
17
22
  3. ``git remote get-url origin`` (handles worktrees and multi-valued
18
23
  config keys that ``configparser`` rejects)
19
24
  4. ``origin`` remote URL parsed from the nearest ``.git/config``
@@ -21,10 +26,11 @@ def detect(override: str | None = None, start: Path | None = None) -> str | None
21
26
  6. ``None`` — no repo context available
22
27
  """
23
28
  if override is not None:
24
- return override or None
29
+ return normalise(override) if override else None
25
30
 
26
- if env_repo := os.environ.get("WITAN_REPO"):
27
- return env_repo
31
+ env_repo = os.environ.get("WITAN_REPO")
32
+ if env_repo is not None:
33
+ return normalise(env_repo) if env_repo else None
28
34
 
29
35
  base = start or Path.cwd()
30
36
 
@@ -1,4 +1,7 @@
1
- // Cross-Repo Context Bridge (Layer 2.5) — shared LOCAL store (_bridge.omni).
1
+ // Cross-Repo Context Bridge (Layer 2.5) — one shared store across all repos.
2
+ //
3
+ // Deployed as the `code-bridge` graph on the shared, S3-backed omnigraph-server
4
+ // cluster (config.BRIDGE_GRAPH_ID); locally it is the `_bridge.omni` store.
2
5
  //
3
6
  // One flat node per interface binding. Cross-repo linkages are computed by
4
7
  // GROUPING bindings on (kind, key_norm) where roles/repos differ — there are
@@ -8,7 +11,8 @@
8
11
  // run + every PostToolUse reindex hook). Flat bindings are each scoped to their
9
12
  // own repo+file, so concurrent writers never touch the same row.
10
13
  //
11
- // Local-only and re-derivable, like the per-repo Layer-2 stores; never synced.
14
+ // Re-derivable, like the per-repo Layer-2 graphs; seed the shared copy by
15
+ // re-indexing on the server or export→load, never by copying a local store to S3.
12
16
  //
13
17
  // Id convention:
14
18
  // InterfaceBinding.slug = repo|file|kind|key_norm|role|symbol_id
@@ -1,6 +1,11 @@
1
1
  // Code Graph (Layer 2) — per-repo tree-sitter-derived symbol graph.
2
2
  //
3
- // One store per repository, kept LOCAL only (never synced to team S3).
3
+ // One graph per repository. Deployed as a distinct graph on the shared,
4
+ // S3-backed omnigraph-server cluster, addressed as `--graph code-<repo>` (the
5
+ // canonical id from witan_code.config.graph_id); a local `<slug>.omni` store is
6
+ // the standalone/dev fallback. The graph is re-derivable from source either way,
7
+ // so seed the shared copy by re-indexing on the server or export→load — never
8
+ // copy a local store up to S3 (Lance embeds root-relative paths).
4
9
  // Composes with witan (Layer 1) via soft symbol-ID references:
5
10
  // Layer-1 nodes store symbol ids of the form `repo#path::Qualified.Name`,
6
11
  // which resolve here without a hard cross-store edge.
@@ -236,6 +236,7 @@ async def _confirm_and_reindex(
236
236
  f"No code graph indexed yet for {repo}. Index it now? "
237
237
  "(may take a while on a large repo)",
238
238
  default_when_unsupported=False,
239
+ title="Index now?",
239
240
  )
240
241
  if not ok:
241
242
  return None
@@ -260,6 +261,7 @@ async def _confirm_and_reindex_bridge(ctx: Context | None) -> OmnigraphClient |
260
261
  f"No cross-repo graph indexed yet. Index the current repo ({repo}) "
261
262
  "now to start building it?",
262
263
  default_when_unsupported=False,
264
+ title="Index now?",
263
265
  )
264
266
  if not ok:
265
267
  return None
@@ -497,6 +499,7 @@ async def code_symbols_in_file(
497
499
  "No repo detected for this file (not in a git repo, or no remote). "
498
500
  "Enter its canonical repo URI (e.g. https://github.com/org/name):",
499
501
  default="",
502
+ title="Repo URI",
500
503
  )
501
504
  if not slug:
502
505
  return []
@@ -1,65 +0,0 @@
1
- import os
2
- import re
3
- from dataclasses import dataclass
4
- from pathlib import Path
5
-
6
- # Bundled query files, resolved relative to this file.
7
- _QUERIES_DIR = Path(__file__).parent / "queries"
8
- _SCHEMA_FILE = Path(__file__).parent / "schema" / "code-schema.pg"
9
- _BRIDGE_SCHEMA_FILE = Path(__file__).parent / "schema" / "bridge-schema.pg"
10
- _DEFAULT_CODE_DIR = Path.home() / ".local" / "share" / "witan" / "code"
11
-
12
- # Filename of the single shared cross-repo bridge store, a sibling of the
13
- # per-repo `<slug>.omni` stores in code_dir. Not routed through sanitize_slug
14
- # (whose .strip("_") would eat the leading underscore); no real repo slug
15
- # resolves to this name.
16
- BRIDGE_STORE_NAME = "_bridge.omni"
17
-
18
-
19
- @dataclass(frozen=True)
20
- class Config:
21
- code_dir: Path
22
- """Directory holding per-repo code stores (one ``<slug>.omni`` each)."""
23
-
24
- author: str
25
- """Attribution string (carried for parity with Layer 1; unused on inserts)."""
26
-
27
- queries_dir: Path
28
- """Directory containing code_read.gq, code_mutations.gq, delete.gq."""
29
-
30
- schema_file: Path
31
- """Path to code-schema.pg, used to lazily init a per-repo store."""
32
-
33
- bridge_schema_file: Path
34
- """Path to bridge-schema.pg, used to lazily init the shared bridge store."""
35
-
36
-
37
- def load() -> Config:
38
- """Load config from environment. All variables are optional with defaults."""
39
- return Config(
40
- code_dir=Path(os.environ.get("WITAN_CODE_DIR", str(_DEFAULT_CODE_DIR))),
41
- author=os.environ.get(
42
- "WITAN_AUTHOR",
43
- os.environ.get("USER", "unknown"),
44
- ),
45
- queries_dir=_QUERIES_DIR,
46
- schema_file=_SCHEMA_FILE,
47
- bridge_schema_file=_BRIDGE_SCHEMA_FILE,
48
- )
49
-
50
-
51
- def sanitize_slug(slug: str) -> str:
52
- """Make a repo slug safe for use as a filename component."""
53
- return re.sub(r"[/:]+", "_", slug).strip("_")
54
-
55
-
56
- def store_path(slug: str, code_dir: Path | None = None) -> Path:
57
- """Resolve the per-repo store path for ``slug``."""
58
- base = code_dir or load().code_dir
59
- return base / f"{sanitize_slug(slug)}.omni"
60
-
61
-
62
- def bridge_store_path(code_dir: Path | None = None) -> Path:
63
- """Resolve the shared cross-repo bridge store path."""
64
- base = code_dir or load().code_dir
65
- return base / BRIDGE_STORE_NAME
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes