diffcontext 0.5.4__tar.gz → 0.5.5__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 (73) hide show
  1. {diffcontext-0.5.4 → diffcontext-0.5.5}/PKG-INFO +1 -1
  2. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/__init__.py +1 -1
  3. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/cache.py +90 -16
  4. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/cli/__init__.py +17 -2
  5. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/parser.py +2 -2
  6. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/pipeline.py +11 -3
  7. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext.egg-info/PKG-INFO +1 -1
  8. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext.egg-info/SOURCES.txt +1 -0
  9. diffcontext-0.5.5/tests/test_cache_fallback.py +121 -0
  10. {diffcontext-0.5.4 → diffcontext-0.5.5}/LICENSE +0 -0
  11. {diffcontext-0.5.4 → diffcontext-0.5.5}/README.md +0 -0
  12. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/_warn_once.py +0 -0
  13. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/context/__init__.py +0 -0
  14. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/context/compiler.py +0 -0
  15. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/context/selector.py +0 -0
  16. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/diff/__init__.py +0 -0
  17. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/diff/git_diff.py +0 -0
  18. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/diff/state_manager.py +0 -0
  19. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/graph_builder.py +0 -0
  20. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/history.py +0 -0
  21. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/impact/__init__.py +0 -0
  22. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/impact/blast_radius.py +0 -0
  23. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/impact/scoring.py +0 -0
  24. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/impact/traversal.py +0 -0
  25. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/impact/visualizer.py +0 -0
  26. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/languages/__init__.py +0 -0
  27. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/languages/typescript.py +0 -0
  28. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/lexical.py +0 -0
  29. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/mcp_server.py +0 -0
  30. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/models.py +0 -0
  31. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/py.typed +0 -0
  32. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/rerank/__init__.py +0 -0
  33. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/rerank/features.py +0 -0
  34. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/rerank/model.py +0 -0
  35. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/resolver.py +0 -0
  36. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/scanner.py +0 -0
  37. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/symbols.py +0 -0
  38. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/verify/__init__.py +0 -0
  39. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/verify/cases.py +0 -0
  40. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/verify/history.py +0 -0
  41. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext/verify/sufficiency.py +0 -0
  42. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext.egg-info/dependency_links.txt +0 -0
  43. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext.egg-info/entry_points.txt +0 -0
  44. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext.egg-info/requires.txt +0 -0
  45. {diffcontext-0.5.4 → diffcontext-0.5.5}/diffcontext.egg-info/top_level.txt +0 -0
  46. {diffcontext-0.5.4 → diffcontext-0.5.5}/pyproject.toml +0 -0
  47. {diffcontext-0.5.4 → diffcontext-0.5.5}/setup.cfg +0 -0
  48. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_cache.py +0 -0
  49. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_compiler_meta.py +0 -0
  50. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_core.py +0 -0
  51. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_cutoff.py +0 -0
  52. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_determinism.py +0 -0
  53. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_downstream_eval.py +0 -0
  54. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_git_diff.py +0 -0
  55. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_harness_api.py +0 -0
  56. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_history.py +0 -0
  57. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_incremental.py +0 -0
  58. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_index_scoping.py +0 -0
  59. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_lexical.py +0 -0
  60. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_mcp_server.py +0 -0
  61. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_rerank.py +0 -0
  62. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_semantic_ablation.py +0 -0
  63. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_semantic_audit.py +0 -0
  64. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_semantic_gap.py +0 -0
  65. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_semantic_metrics.py +0 -0
  66. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_semantic_mine.py +0 -0
  67. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_service_clone.py +0 -0
  68. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_src_layout.py +0 -0
  69. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_stats.py +0 -0
  70. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_token_budget.py +0 -0
  71. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_typescript_adapter.py +0 -0
  72. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_verify.py +0 -0
  73. {diffcontext-0.5.4 → diffcontext-0.5.5}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffcontext
3
- Version: 0.5.4
3
+ Version: 0.5.5
4
4
  Summary: Static-analysis-powered repository context compiler for LLMs
5
5
  Author-email: Trakshan Mishra <trakshanmishra477@gmail.com>
6
6
  License: MIT
@@ -32,7 +32,7 @@ Usage as a library:
32
32
  print(ctx.reduction_pct) # how much code was filtered out
33
33
  """
34
34
 
35
- __version__ = "0.5.4"
35
+ __version__ = "0.5.5"
36
36
 
37
37
  # Public, semver-covered API. Everything not listed here (graph_builder,
38
38
  # resolver, symbols, scanner, parser internals) is importable but carries no
@@ -1,21 +1,36 @@
1
1
  """
2
2
  cache.py — SQLite-backed persistent caching for AST parsed symbols and the
3
3
  repository call graph.
4
+
5
+ Cache path resolution (cascade, in order):
6
+ 1. DIFFCONTEXT_CACHE_DIR env var (if set)
7
+ 2. repo_path/.diffcontext_cache.db (current behaviour, keeps cache locality)
8
+ 3. $XDG_CACHE_HOME/diffcontext/<sha256 of abs repo_path>.db
9
+ or ~/.cache/diffcontext/<sha256>.db
10
+ 4. sqlite ':memory:' (degraded — no persistence across calls, but working)
11
+
12
+ The cascade ensures DiffContext works on read-only repos (Glama sandbox,
13
+ CI checkouts, containers with read-only rootfs, repos the user doesn't own).
14
+ Do NOT fail the call because of cache — a stale or missing cache only costs
15
+ a re-parse, never correctness.
4
16
  """
5
17
 
6
18
  import hashlib
7
19
  import json
20
+ import logging
21
+ import os
8
22
  import sqlite3
9
23
  from typing import Dict, Callable, List, Optional, Tuple
10
24
 
11
25
  from .models import Symbol
12
26
 
27
+ logger = logging.getLogger(__name__)
28
+
13
29
 
14
30
  def get_file_hash(filepath: str) -> str:
15
- """Compute SHA-256 hash of a file."""
31
+ """Compute SHA-256 of a file."""
16
32
  hasher = hashlib.sha256()
17
33
  with open(filepath, "rb") as f:
18
- # Python files are small enough to read into memory safely
19
34
  hasher.update(f.read())
20
35
  return hasher.hexdigest()
21
36
 
@@ -26,11 +41,7 @@ def hash_source(source_bytes: bytes) -> str:
26
41
 
27
42
 
28
43
  def repo_state_hash(file_hashes: Dict[str, str]) -> str:
29
- """
30
- Single hash summarizing the content state of every Python file in the
31
- repo. Keyed on (relative_path, content_hash) pairs, order-independent.
32
- Any file added, removed, or edited changes this hash.
33
- """
44
+ """Single hash summarizing the content state of every Python file."""
34
45
  hasher = hashlib.sha256()
35
46
  for path in sorted(file_hashes):
36
47
  hasher.update(path.encode("utf-8"))
@@ -40,17 +51,84 @@ def repo_state_hash(file_hashes: Dict[str, str]) -> str:
40
51
  return hasher.hexdigest()
41
52
 
42
53
 
54
+ def _resolve_cache_path(
55
+ repo_path: Optional[str] = None,
56
+ cache_dir: Optional[str] = None,
57
+ ) -> str:
58
+ """
59
+ Resolve the SQLite cache database path via a cascade.
60
+
61
+ 1. If `cache_dir` is explicitly passed (from --cache-dir CLI flag), use it.
62
+ 2. If DIFFCONTEXT_CACHE_DIR env var is set, use it.
63
+ 3. Try repo_path/.diffcontext_cache.db (keeps cache locality with the repo).
64
+ 4. Fall back to $XDG_CACHE_HOME/diffcontext/<hash>.db or ~/.cache/diffcontext/<hash>.db
65
+ 5. Fall back to ':memory:' (degraded — no persistence, but working).
66
+
67
+ Steps 3 and 4 try to create the file/dir; if they fail (read-only
68
+ filesystem, permission denied), the cascade continues. Step 5 always
69
+ works.
70
+
71
+ Returns a path string (or ':memory:').
72
+ """
73
+ # 1. Explicit override (highest priority)
74
+ if cache_dir:
75
+ try:
76
+ os.makedirs(cache_dir, exist_ok=True)
77
+ except OSError:
78
+ pass
79
+ return os.path.join(cache_dir, ".diffcontext_cache.db")
80
+
81
+ # 2. Env var override
82
+ env_cache_dir = os.environ.get("DIFFCONTEXT_CACHE_DIR")
83
+ if env_cache_dir:
84
+ try:
85
+ os.makedirs(env_cache_dir, exist_ok=True)
86
+ except OSError:
87
+ pass
88
+ return os.path.join(env_cache_dir, ".diffcontext_cache.db")
89
+
90
+ # 3. In-repo path (current behaviour, keeps cache locality)
91
+ if repo_path:
92
+ in_repo = os.path.join(repo_path, ".diffcontext_cache.db")
93
+ # Try to create/test writability. If it works, use it.
94
+ try:
95
+ # Touch to test writability (file may already exist)
96
+ fd = os.open(in_repo, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
97
+ os.close(fd)
98
+ return in_repo
99
+ except OSError:
100
+ logger.debug("cache: repo_path not writable (%s), falling back", in_repo)
101
+
102
+ # 4. XDG cache dir
103
+ xdg_cache = os.environ.get("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
104
+ # Hash the abs repo path so different repos don't collide
105
+ repo_hash = hashlib.sha256(os.path.abspath(repo_path or "").encode()).hexdigest()[:16]
106
+ cache_subdir = os.path.join(xdg_cache, "diffcontext")
107
+ xdg_path = os.path.join(cache_subdir, f"{repo_hash}.db")
108
+ try:
109
+ os.makedirs(cache_subdir, exist_ok=True)
110
+ fd = os.open(xdg_path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
111
+ os.close(fd)
112
+ logger.debug("cache: using XDG cache at %s", xdg_path)
113
+ return xdg_path
114
+ except OSError:
115
+ logger.debug("cache: XDG cache dir not writable (%s), falling back to :memory:", xdg_path)
116
+
117
+ # 5. In-memory (always works, but no persistence)
118
+ logger.debug("cache: all disk locations failed, using :memory:")
119
+ return ":memory:"
120
+
121
+
43
122
  class SymbolCache:
44
123
  """
45
124
  Persistent SQLite cache for parsed AST symbols and the call graph.
46
125
 
47
- Safe for concurrent use from multiple threads within one process: the
48
- connection is created with check_same_thread=False and every public
49
- operation holds an internal lock (SQLite serializes at the file level
50
- across processes on its own via WAL).
126
+ The cache path is resolved via _resolve_cache_path(). On read-only
127
+ repos, the cascade falls back to XDG cache or :memory: — the call
128
+ never fails because of cache.
51
129
  """
52
130
 
53
- def __init__(self, db_path: str = ".diffcontext_cache.db"):
131
+ def __init__(self, db_path: str = ":memory:"):
54
132
  import threading
55
133
  self.db_path = db_path
56
134
  self._conn = None
@@ -61,10 +139,6 @@ class SymbolCache:
61
139
  if self._conn is None:
62
140
  self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
63
141
  self._conn.execute("PRAGMA journal_mode=WAL")
64
- # Cache contents are rebuildable from source; NORMAL skips the
65
- # per-commit fsync (a measurable cost at one commit per file on
66
- # a cold index) and risks nothing worse than a stale cache row
67
- # after power loss, which the content hash then invalidates.
68
142
  self._conn.execute("PRAGMA synchronous=NORMAL")
69
143
  self._conn.execute("PRAGMA foreign_keys=ON")
70
144
  self._init_db()
@@ -50,6 +50,12 @@ def main():
50
50
  "(tests/, benchmarks/, docs/, ...). Useful when a change "
51
51
  "spans an excluded dir; .gitignore still applies.",
52
52
  )
53
+ p_index.add_argument(
54
+ "--cache-dir", default=None, metavar="DIR",
55
+ help="Cache database location (default: repo/.diffcontext_cache.db, "
56
+ "falls back to XDG cache or :memory: on read-only repos). "
57
+ "Also settable via DIFFCONTEXT_CACHE_DIR env var.",
58
+ )
53
59
 
54
60
  # --- impact ---
55
61
  p_impact = sub.add_parser("impact", help="Analyze impact of a symbol change")
@@ -83,6 +89,12 @@ def main():
83
89
  p_compile.add_argument("--changed", nargs="+", help="Changed symbol IDs")
84
90
  p_compile.add_argument("--ref", default=None, help="Git ref (auto-detect changes)")
85
91
  p_compile.add_argument("--repo", default=".", help="Repository path")
92
+ p_compile.add_argument(
93
+ "--cache-dir", default=None, metavar="DIR",
94
+ help="Cache database location (default: repo/.diffcontext_cache.db, "
95
+ "falls back to XDG cache or :memory: on read-only repos). "
96
+ "Also settable via DIFFCONTEXT_CACHE_DIR env var.",
97
+ )
86
98
  p_compile.add_argument(
87
99
  "--include", nargs="*", default=[], metavar="DIR",
88
100
  help="Directory names to index despite default exclusions (tests/, "
@@ -270,7 +282,7 @@ def _cmd_index(args):
270
282
  """Index repository: show stats."""
271
283
  t0 = time.perf_counter()
272
284
  include = set(args.include or [])
273
- idx = index_repository(args.repo, include=include)
285
+ idx = index_repository(args.repo, include=include, cache_dir=args.cache_dir)
274
286
  elapsed = (time.perf_counter() - t0) * 1000
275
287
 
276
288
  print(f"Symbols : {len(idx.symbols)}")
@@ -368,7 +380,10 @@ def _cmd_diff(args):
368
380
 
369
381
  def _cmd_compile(args):
370
382
  """Build full context package."""
371
- idx = index_repository(args.repo, include=set(args.include or []))
383
+ idx = index_repository(
384
+ args.repo, include=set(args.include or []),
385
+ cache_dir=getattr(args, "cache_dir", None),
386
+ )
372
387
 
373
388
  # Determine changed symbols
374
389
  if args.changed:
@@ -166,12 +166,12 @@ def extract_all_symbols(
166
166
  that failed to parse (SyntaxError) are appended to it.
167
167
  """
168
168
  from .scanner import find_python_files
169
- from .cache import SymbolCache
169
+ from .cache import SymbolCache, _resolve_cache_path
170
170
 
171
171
  repo_path = os.path.abspath(repo_path)
172
172
  all_symbols: Dict[str, Symbol] = {}
173
173
 
174
- db_path = os.path.join(repo_path, ".diffcontext_cache.db")
174
+ db_path = _resolve_cache_path(repo_path)
175
175
 
176
176
  with SymbolCache(db_path) as cache:
177
177
  for filepath in find_python_files(repo_path):
@@ -24,7 +24,7 @@ from .models import (
24
24
  from .languages import available_adapters, discover_files
25
25
  from .parser import extract_symbols
26
26
  from .scanner import find_python_files, first_excluded_dir
27
- from .cache import SymbolCache, get_file_hash, hash_source, repo_state_hash
27
+ from .cache import SymbolCache, get_file_hash, hash_source, repo_state_hash, _resolve_cache_path
28
28
  from .resolver import build_import_map
29
29
  from .graph_builder import build_repository_graph
30
30
  from ._warn_once import warn_syntax_error_once, check_and_warn_encoding, WarnState
@@ -104,6 +104,7 @@ def _read_and_parse(
104
104
 
105
105
  def index_repository(
106
106
  repo_path: str, include: Optional[Set[str]] = None,
107
+ cache_dir: Optional[str] = None,
107
108
  ) -> RepositoryIndex:
108
109
  """
109
110
  Phase 1: Parse repository and build dependency graph.
@@ -123,9 +124,14 @@ def index_repository(
123
124
  list of files (if any) that failed to parse due to a SyntaxError. The
124
125
  returned index supports in-process incremental updates via
125
126
  `index.update([...])`.
127
+
128
+ `cache_dir` overrides the cache location. If None, the cascade in
129
+ `_resolve_cache_path` is used: repo_path/.diffcontext_cache.db →
130
+ XDG cache → :memory:. Pass a path to force a specific location (also
131
+ settable via DIFFCONTEXT_CACHE_DIR env var).
126
132
  """
127
133
  repo_path = os.path.abspath(repo_path)
128
- db_path = os.path.join(repo_path, ".diffcontext_cache.db")
134
+ db_path = _resolve_cache_path(repo_path, cache_dir)
129
135
 
130
136
  files = find_python_files(repo_path, include)
131
137
 
@@ -258,6 +264,7 @@ def index_repository(
258
264
  index._lang_graphs = lang_graphs # None on graph-cache hit (lazy)
259
265
  index._warn_state = warn_state
260
266
  index._include = include # scanner dirs to keep despite defaults
267
+ index._cache_dir = cache_dir # cache location override for update_index
261
268
  return index
262
269
 
263
270
 
@@ -442,7 +449,8 @@ def update_index(index: RepositoryIndex, changed_files: List[str]) -> Repository
442
449
  "index_repository()."
443
450
  )
444
451
  repo_path = index._repo_path
445
- db_path = os.path.join(repo_path, ".diffcontext_cache.db")
452
+ cache_dir = getattr(index, "_cache_dir", None)
453
+ db_path = _resolve_cache_path(repo_path, cache_dir)
446
454
 
447
455
  _ensure_trees(index)
448
456
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffcontext
3
- Version: 0.5.4
3
+ Version: 0.5.5
4
4
  Summary: Static-analysis-powered repository context compiler for LLMs
5
5
  Author-email: Trakshan Mishra <trakshanmishra477@gmail.com>
6
6
  License: MIT
@@ -43,6 +43,7 @@ diffcontext/verify/cases.py
43
43
  diffcontext/verify/history.py
44
44
  diffcontext/verify/sufficiency.py
45
45
  tests/test_cache.py
46
+ tests/test_cache_fallback.py
46
47
  tests/test_compiler_meta.py
47
48
  tests/test_core.py
48
49
  tests/test_cutoff.py
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tests/test_cache_fallback.py — Tests for the cache path cascade.
4
+
5
+ The cascade: repo_path/.diffcontext_cache.db → XDG cache → :memory:.
6
+ Ensures DiffContext works on read-only repos (Glama sandbox, CI checkouts,
7
+ containers with read-only rootfs, repos the user doesn't own).
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+ import sys
13
+
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
15
+
16
+ from diffcontext.cache import _resolve_cache_path
17
+ from diffcontext.pipeline import index_repository
18
+
19
+ FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tests", "fixtures")
20
+ SIMPLE = os.path.join(FIXTURES, "simple_repo")
21
+
22
+
23
+ class TestResolveCachePath:
24
+ def test_explicit_cache_dir_takes_priority(self, tmp_path):
25
+ """--cache-dir / env var overrides everything."""
26
+ path = _resolve_cache_path("/some/repo", cache_dir=str(tmp_path))
27
+ assert path == os.path.join(str(tmp_path), ".diffcontext_cache.db")
28
+
29
+ def test_env_var_takes_priority(self, tmp_path, monkeypatch):
30
+ """DIFFCONTEXT_CACHE_DIR env var is used when no explicit override."""
31
+ monkeypatch.setenv("DIFFCONTEXT_CACHE_DIR", str(tmp_path))
32
+ path = _resolve_cache_path("/some/repo")
33
+ assert path == os.path.join(str(tmp_path), ".diffcontext_cache.db")
34
+
35
+ def test_readonly_repo_falls_back_to_xdg(self, tmp_path, monkeypatch):
36
+ """Read-only repo dir falls back to XDG cache."""
37
+ monkeypatch.delenv("DIFFCONTEXT_CACHE_DIR", raising=False)
38
+ xdg = tmp_path / "xdg"
39
+ monkeypatch.setenv("XDG_CACHE_HOME", str(xdg))
40
+
41
+ # Make repo dir read-only
42
+ ro_repo = tmp_path / "ro_repo"
43
+ ro_repo.mkdir()
44
+ (ro_repo / "main.py").write_text("def f():\n return 1\n")
45
+ os.chmod(str(ro_repo), 0o555) # read-only
46
+
47
+ path = _resolve_cache_path(str(ro_repo))
48
+ assert ".cache" in path or "xdg" in path
49
+ assert path != os.path.join(str(ro_repo), ".diffcontext_cache.db")
50
+
51
+ os.chmod(str(ro_repo), 0o755) # cleanup
52
+
53
+ def test_memory_fallback_when_all_disk_fails(self, monkeypatch):
54
+ """When repo is read-only AND XDG cache is not writable, fall to :memory:."""
55
+ monkeypatch.delenv("DIFFCONTEXT_CACHE_DIR", raising=False)
56
+ monkeypatch.setenv("XDG_CACHE_HOME", "/nonexistent/path/that/does/not/exist")
57
+
58
+ path = _resolve_cache_path("/also/nonexistent")
59
+ assert path == ":memory:", f"expected :memory:, got {path}"
60
+
61
+
62
+ class TestReadOnlyRepoIndexing:
63
+ def test_readonly_repo_still_indexes(self, tmp_path):
64
+ """The Glama sandbox / CI checkout use case: repo is read-only."""
65
+ # Copy the fixture to a temp dir, make it read-only
66
+ ro = tmp_path / "ro_repo"
67
+ shutil.copytree(SIMPLE, str(ro))
68
+ # Remove any existing cache
69
+ cache = ro / ".diffcontext_cache.db"
70
+ if cache.exists():
71
+ cache.unlink()
72
+
73
+ # Make the entire repo read-only
74
+ for root, dirs, files in os.walk(str(ro)):
75
+ os.chmod(root, 0o555)
76
+ for f in files:
77
+ os.chmod(os.path.join(root, f), 0o444)
78
+
79
+ # This must not raise — the cascade falls back to XDG or :memory:
80
+ idx = index_repository(str(ro))
81
+ assert len(idx.symbols) > 0, "indexing must succeed on read-only repo"
82
+
83
+ # Restore permissions for cleanup
84
+ for root, dirs, files in os.walk(str(ro)):
85
+ os.chmod(root, 0o755)
86
+ for f in files:
87
+ os.chmod(os.path.join(root, f), 0o644)
88
+
89
+ def test_env_var_override_honored(self, tmp_path, monkeypatch):
90
+ """DIFFCONTEXT_CACHE_DIR forces the cache location."""
91
+ cache_dir = tmp_path / "custom_cache"
92
+ monkeypatch.setenv("DIFFCONTEXT_CACHE_DIR", str(cache_dir))
93
+
94
+ idx = index_repository(SIMPLE)
95
+ assert len(idx.symbols) > 0
96
+ assert cache_dir.exists(), "custom cache dir must be created"
97
+ assert (cache_dir / ".diffcontext_cache.db").exists(), "cache db must be in the custom dir"
98
+
99
+ def test_memory_cache_works_when_both_fail(self, tmp_path, monkeypatch):
100
+ """When both repo and XDG are unwritable, :memory: still indexes."""
101
+ ro = tmp_path / "ro_repo"
102
+ shutil.copytree(SIMPLE, str(ro))
103
+ cache = ro / ".diffcontext_cache.db"
104
+ if cache.exists():
105
+ cache.unlink()
106
+
107
+ for root, dirs, files in os.walk(str(ro)):
108
+ os.chmod(root, 0o555)
109
+ for f in files:
110
+ os.chmod(os.path.join(root, f), 0o444)
111
+
112
+ monkeypatch.delenv("DIFFCONTEXT_CACHE_DIR", raising=False)
113
+ monkeypatch.setenv("XDG_CACHE_HOME", "/nonexistent")
114
+
115
+ idx = index_repository(str(ro))
116
+ assert len(idx.symbols) > 0, "indexing must succeed even with :memory: cache"
117
+
118
+ for root, dirs, files in os.walk(str(ro)):
119
+ os.chmod(root, 0o755)
120
+ for f in files:
121
+ os.chmod(os.path.join(root, f), 0o644)
File without changes
File without changes
File without changes
File without changes