diffcontext 0.5.4__tar.gz → 0.5.6__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.
- {diffcontext-0.5.4 → diffcontext-0.5.6}/PKG-INFO +1 -1
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/__init__.py +1 -1
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/cache.py +96 -16
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/cli/__init__.py +17 -2
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/parser.py +2 -2
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/pipeline.py +11 -3
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext.egg-info/PKG-INFO +1 -1
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext.egg-info/SOURCES.txt +1 -0
- diffcontext-0.5.6/tests/test_cache_fallback.py +150 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/LICENSE +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/README.md +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/_warn_once.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/context/__init__.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/context/compiler.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/context/selector.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/diff/__init__.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/diff/git_diff.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/diff/state_manager.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/graph_builder.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/history.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/impact/__init__.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/impact/blast_radius.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/impact/scoring.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/impact/traversal.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/impact/visualizer.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/languages/__init__.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/languages/typescript.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/lexical.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/mcp_server.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/models.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/py.typed +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/rerank/__init__.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/rerank/features.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/rerank/model.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/resolver.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/scanner.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/symbols.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/verify/__init__.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/verify/cases.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/verify/history.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext/verify/sufficiency.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext.egg-info/dependency_links.txt +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext.egg-info/entry_points.txt +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext.egg-info/requires.txt +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/diffcontext.egg-info/top_level.txt +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/pyproject.toml +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/setup.cfg +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_cache.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_compiler_meta.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_core.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_cutoff.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_determinism.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_downstream_eval.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_git_diff.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_harness_api.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_history.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_incremental.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_index_scoping.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_lexical.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_mcp_server.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_rerank.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_semantic_ablation.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_semantic_audit.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_semantic_gap.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_semantic_metrics.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_semantic_mine.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_service_clone.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_src_layout.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_stats.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_token_budget.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_typescript_adapter.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_verify.py +0 -0
- {diffcontext-0.5.4 → diffcontext-0.5.6}/tests/test_version.py +0 -0
|
@@ -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.
|
|
35
|
+
__version__ = "0.5.6"
|
|
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
|
|
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,90 @@ 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
|
+
return os.path.join(cache_dir, ".diffcontext_cache.db")
|
|
78
|
+
except OSError:
|
|
79
|
+
logger.warning(
|
|
80
|
+
"cache: --cache-dir %s is not writable; falling through cascade",
|
|
81
|
+
cache_dir,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# 2. Env var override
|
|
85
|
+
env_cache_dir = os.environ.get("DIFFCONTEXT_CACHE_DIR")
|
|
86
|
+
if env_cache_dir:
|
|
87
|
+
try:
|
|
88
|
+
os.makedirs(env_cache_dir, exist_ok=True)
|
|
89
|
+
return os.path.join(env_cache_dir, ".diffcontext_cache.db")
|
|
90
|
+
except OSError:
|
|
91
|
+
logger.warning(
|
|
92
|
+
"cache: DIFFCONTEXT_CACHE_DIR=%s is not writable; falling through cascade",
|
|
93
|
+
env_cache_dir,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# 3. In-repo path (current behaviour, keeps cache locality)
|
|
97
|
+
if repo_path:
|
|
98
|
+
in_repo = os.path.join(repo_path, ".diffcontext_cache.db")
|
|
99
|
+
# Try to create/test writability. If it works, use it.
|
|
100
|
+
try:
|
|
101
|
+
# Touch to test writability (file may already exist)
|
|
102
|
+
fd = os.open(in_repo, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
|
|
103
|
+
os.close(fd)
|
|
104
|
+
return in_repo
|
|
105
|
+
except OSError:
|
|
106
|
+
logger.debug("cache: repo_path not writable (%s), falling back", in_repo)
|
|
107
|
+
|
|
108
|
+
# 4. XDG cache dir
|
|
109
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
|
110
|
+
# Hash the abs repo path so different repos don't collide
|
|
111
|
+
repo_hash = hashlib.sha256(os.path.abspath(repo_path or "").encode()).hexdigest()[:16]
|
|
112
|
+
cache_subdir = os.path.join(xdg_cache, "diffcontext")
|
|
113
|
+
xdg_path = os.path.join(cache_subdir, f"{repo_hash}.db")
|
|
114
|
+
try:
|
|
115
|
+
os.makedirs(cache_subdir, exist_ok=True)
|
|
116
|
+
fd = os.open(xdg_path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
|
|
117
|
+
os.close(fd)
|
|
118
|
+
logger.debug("cache: using XDG cache at %s", xdg_path)
|
|
119
|
+
return xdg_path
|
|
120
|
+
except OSError:
|
|
121
|
+
logger.debug("cache: XDG cache dir not writable (%s), falling back to :memory:", xdg_path)
|
|
122
|
+
|
|
123
|
+
# 5. In-memory (always works, but no persistence)
|
|
124
|
+
logger.debug("cache: all disk locations failed, using :memory:")
|
|
125
|
+
return ":memory:"
|
|
126
|
+
|
|
127
|
+
|
|
43
128
|
class SymbolCache:
|
|
44
129
|
"""
|
|
45
130
|
Persistent SQLite cache for parsed AST symbols and the call graph.
|
|
46
131
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
across processes on its own via WAL).
|
|
132
|
+
The cache path is resolved via _resolve_cache_path(). On read-only
|
|
133
|
+
repos, the cascade falls back to XDG cache or :memory: — the call
|
|
134
|
+
never fails because of cache.
|
|
51
135
|
"""
|
|
52
136
|
|
|
53
|
-
def __init__(self, db_path: str = "
|
|
137
|
+
def __init__(self, db_path: str = ":memory:"):
|
|
54
138
|
import threading
|
|
55
139
|
self.db_path = db_path
|
|
56
140
|
self._conn = None
|
|
@@ -61,10 +145,6 @@ class SymbolCache:
|
|
|
61
145
|
if self._conn is None:
|
|
62
146
|
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
63
147
|
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
148
|
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
69
149
|
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
70
150
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
|
@@ -0,0 +1,150 @@
|
|
|
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)
|
|
122
|
+
|
|
123
|
+
def test_unwritable_env_var_falls_through(self, tmp_path, monkeypatch):
|
|
124
|
+
"""DIFFCONTEXT_CACHE_DIR pointing to an unwritable path must NOT crash —
|
|
125
|
+
the cascade continues to tiers 3-5 instead of returning the bad path.
|
|
126
|
+
|
|
127
|
+
Previously, tiers 1/2 swallowed the OSError from makedirs but returned
|
|
128
|
+
the unusable path anyway, short-circuiting the cascade. This test pins
|
|
129
|
+
the fix: an explicit override that can't be honoured warns and degrades,
|
|
130
|
+
not crashes.
|
|
131
|
+
"""
|
|
132
|
+
monkeypatch.setenv("DIFFCONTEXT_CACHE_DIR", "/nonexistent/unwritable/path")
|
|
133
|
+
monkeypatch.setenv("XDG_CACHE_HOME", "/also/nonexistent")
|
|
134
|
+
|
|
135
|
+
idx = index_repository(SIMPLE)
|
|
136
|
+
assert len(idx.symbols) > 0, (
|
|
137
|
+
"unwritable DIFFCONTEXT_CACHE_DIR must fall through to :memory:, "
|
|
138
|
+
"not crash with OperationalError"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def test_unwritable_cache_dir_flag_falls_through(self, tmp_path, monkeypatch):
|
|
142
|
+
"""--cache-dir pointing to an unwritable path must NOT crash either."""
|
|
143
|
+
monkeypatch.delenv("DIFFCONTEXT_CACHE_DIR", raising=False)
|
|
144
|
+
monkeypatch.setenv("XDG_CACHE_HOME", "/also/nonexistent")
|
|
145
|
+
|
|
146
|
+
idx = index_repository(SIMPLE, cache_dir="/nonexistent/unwritable/path")
|
|
147
|
+
assert len(idx.symbols) > 0, (
|
|
148
|
+
"unwritable --cache-dir must fall through to :memory:, "
|
|
149
|
+
"not crash with OperationalError"
|
|
150
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|