codigest 0.3.6__tar.gz → 0.3.7__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.
- {codigest-0.3.6 → codigest-0.3.7}/PKG-INFO +1 -1
- {codigest-0.3.6 → codigest-0.3.7}/pyproject.toml +1 -1
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/digest.py +3 -5
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/scan.py +13 -6
- codigest-0.3.7/src/codigest/core/cache.py +170 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/shadow.py +41 -8
- codigest-0.3.6/src/codigest/core/cache.py +0 -78
- {codigest-0.3.6 → codigest-0.3.7}/.github/workflows/publish.yml +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/.gitignore +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/.python-version +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/README.md +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/__init__.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/__main__.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/clean.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/diff.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/init.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/semdiff.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/status.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/tree.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/config.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/common.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/git_ops.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/processor.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/prompts.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/resolver.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/scanner.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/semdiff.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/structure.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/tags.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/tokenizer.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/src/codigest/main.py +0 -0
- {codigest-0.3.6 → codigest-0.3.7}/uv.lock +0 -0
|
@@ -55,17 +55,15 @@ def handle(
|
|
|
55
55
|
except ValueError:
|
|
56
56
|
rel_path = f"[EXTERNAL]/{file_path.name}"
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
summary = None
|
|
60
|
-
if blob_hash:
|
|
61
|
-
summary = content_cache.get_summary(rel_path, blob_hash)
|
|
58
|
+
summary = content_cache.get_summary(rel_path, blob_hash=None, file_path=file_path)
|
|
62
59
|
|
|
63
60
|
if summary is None:
|
|
64
61
|
try:
|
|
62
|
+
blob_hash = cache.get_file_blob_hash(file_path)
|
|
65
63
|
content = file_path.read_text(encoding="utf-8")
|
|
66
64
|
summary = semdiff.summarize(content)
|
|
67
65
|
if summary and blob_hash:
|
|
68
|
-
content_cache.set_summary(rel_path, blob_hash, summary)
|
|
66
|
+
content_cache.set_summary(rel_path, blob_hash, summary, file_path=file_path)
|
|
69
67
|
except Exception:
|
|
70
68
|
continue
|
|
71
69
|
|
|
@@ -123,20 +123,27 @@ def handle(
|
|
|
123
123
|
except ValueError:
|
|
124
124
|
rel_path = f"[EXTERNAL]/{file_path.name}"
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
cached_block = content_cache.get_file_block(
|
|
127
|
+
rel_path=rel_path,
|
|
128
|
+
line_numbers=line_numbers,
|
|
129
|
+
file_path=file_path
|
|
130
|
+
)
|
|
130
131
|
|
|
131
132
|
if cached_block is not None:
|
|
132
133
|
file_blocks.append(cached_block)
|
|
133
134
|
else:
|
|
134
135
|
try:
|
|
136
|
+
blob_hash = cache.get_file_blob_hash(file_path)
|
|
135
137
|
content = processor.read_file_content(file_path, add_line_numbers=line_numbers)
|
|
136
138
|
block = tags.file(rel_path, content)
|
|
137
139
|
file_blocks.append(block)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
content_cache.set_file_block(
|
|
141
|
+
rel_path=rel_path,
|
|
142
|
+
blob_hash=blob_hash,
|
|
143
|
+
line_numbers=line_numbers,
|
|
144
|
+
block=block,
|
|
145
|
+
file_path=file_path
|
|
146
|
+
)
|
|
140
147
|
except Exception:
|
|
141
148
|
continue
|
|
142
149
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git Blob Hash-based Content & AST Cache.
|
|
3
|
+
Provides fast caching for scan snapshot blocks and digest AST summaries.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import hashlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Dict, Any
|
|
9
|
+
|
|
10
|
+
def compute_git_blob_hash(data: bytes) -> str:
|
|
11
|
+
"""Computes Git-compatible SHA-1 blob hash for raw bytes."""
|
|
12
|
+
header = f"blob {len(data)}\0".encode("utf-8")
|
|
13
|
+
return hashlib.sha1(header + data).hexdigest()
|
|
14
|
+
|
|
15
|
+
def get_file_blob_hash(file_path: Path) -> Optional[str]:
|
|
16
|
+
"""Reads file and computes its Git blob hash."""
|
|
17
|
+
try:
|
|
18
|
+
data = file_path.read_bytes()
|
|
19
|
+
return compute_git_blob_hash(data)
|
|
20
|
+
except Exception:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
class ContentCache:
|
|
24
|
+
def __init__(self, root_path: Path):
|
|
25
|
+
self.root_path = root_path
|
|
26
|
+
self.cache_file = root_path / ".codigest" / "cache.json"
|
|
27
|
+
self._data: Dict[str, Any] = {}
|
|
28
|
+
self.hits = 0
|
|
29
|
+
self.misses = 0
|
|
30
|
+
self._load()
|
|
31
|
+
|
|
32
|
+
def _load(self):
|
|
33
|
+
if self.cache_file.exists():
|
|
34
|
+
try:
|
|
35
|
+
with open(self.cache_file, "r", encoding="utf-8") as f:
|
|
36
|
+
self._data = json.load(f)
|
|
37
|
+
except Exception:
|
|
38
|
+
self._data = {}
|
|
39
|
+
else:
|
|
40
|
+
self._data = {}
|
|
41
|
+
|
|
42
|
+
def save(self):
|
|
43
|
+
try:
|
|
44
|
+
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
with open(self.cache_file, "w", encoding="utf-8") as f:
|
|
46
|
+
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def get_file_block(
|
|
51
|
+
self,
|
|
52
|
+
rel_path: str,
|
|
53
|
+
blob_hash: Optional[str] = None,
|
|
54
|
+
line_numbers: bool = False,
|
|
55
|
+
file_path: Optional[Path] = None
|
|
56
|
+
) -> Optional[str]:
|
|
57
|
+
# [1] Fast path: Stat check if file_path is provided
|
|
58
|
+
if file_path is not None:
|
|
59
|
+
try:
|
|
60
|
+
stat = file_path.stat()
|
|
61
|
+
entry = self._data.get("files", {}).get(rel_path)
|
|
62
|
+
if entry and entry.get("mtime_ns") == stat.st_mtime_ns and entry.get("size") == stat.st_size:
|
|
63
|
+
block = entry.get("blocks", {}).get(str(line_numbers))
|
|
64
|
+
if block is not None:
|
|
65
|
+
self.hits += 1
|
|
66
|
+
return block
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# [2] Hash path (if blob_hash available)
|
|
71
|
+
if blob_hash:
|
|
72
|
+
entry = self._data.get("files", {}).get(rel_path)
|
|
73
|
+
if entry and entry.get("blob_hash") == blob_hash:
|
|
74
|
+
block = entry.get("blocks", {}).get(str(line_numbers))
|
|
75
|
+
if block is not None:
|
|
76
|
+
self.hits += 1
|
|
77
|
+
if file_path is not None:
|
|
78
|
+
try:
|
|
79
|
+
stat = file_path.stat()
|
|
80
|
+
entry["mtime_ns"] = stat.st_mtime_ns
|
|
81
|
+
entry["size"] = stat.st_size
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return block
|
|
85
|
+
|
|
86
|
+
# Backward compatibility check for old flat cache keys
|
|
87
|
+
cache_key = f"{rel_path}:{blob_hash}:lines={line_numbers}"
|
|
88
|
+
val = self._data.get("file_blocks", {}).get(cache_key)
|
|
89
|
+
if val is not None:
|
|
90
|
+
self.hits += 1
|
|
91
|
+
return val
|
|
92
|
+
|
|
93
|
+
self.misses += 1
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def set_file_block(
|
|
97
|
+
self,
|
|
98
|
+
rel_path: str,
|
|
99
|
+
blob_hash: Optional[str],
|
|
100
|
+
line_numbers: bool,
|
|
101
|
+
block: str,
|
|
102
|
+
file_path: Optional[Path] = None
|
|
103
|
+
):
|
|
104
|
+
if "files" not in self._data:
|
|
105
|
+
self._data["files"] = {}
|
|
106
|
+
|
|
107
|
+
if rel_path not in self._data["files"]:
|
|
108
|
+
self._data["files"][rel_path] = {"blocks": {}}
|
|
109
|
+
|
|
110
|
+
entry = self._data["files"][rel_path]
|
|
111
|
+
if blob_hash:
|
|
112
|
+
entry["blob_hash"] = blob_hash
|
|
113
|
+
|
|
114
|
+
if file_path is not None:
|
|
115
|
+
try:
|
|
116
|
+
stat = file_path.stat()
|
|
117
|
+
entry["mtime_ns"] = stat.st_mtime_ns
|
|
118
|
+
entry["size"] = stat.st_size
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
entry.setdefault("blocks", {})[str(line_numbers)] = block
|
|
123
|
+
|
|
124
|
+
# Legacy format support
|
|
125
|
+
if blob_hash:
|
|
126
|
+
if "file_blocks" not in self._data:
|
|
127
|
+
self._data["file_blocks"] = {}
|
|
128
|
+
cache_key = f"{rel_path}:{blob_hash}:lines={line_numbers}"
|
|
129
|
+
self._data["file_blocks"][cache_key] = block
|
|
130
|
+
|
|
131
|
+
def get_summary(self, rel_path: str, blob_hash: str, file_path: Optional[Path] = None) -> Optional[str]:
|
|
132
|
+
if file_path is not None:
|
|
133
|
+
try:
|
|
134
|
+
stat = file_path.stat()
|
|
135
|
+
entry = self._data.get("summaries_meta", {}).get(rel_path)
|
|
136
|
+
if entry and entry.get("mtime_ns") == stat.st_mtime_ns and entry.get("size") == stat.st_size:
|
|
137
|
+
summary = entry.get("summary")
|
|
138
|
+
if summary is not None:
|
|
139
|
+
self.hits += 1
|
|
140
|
+
return summary
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
cache_key = f"{rel_path}:{blob_hash}"
|
|
145
|
+
val = self._data.get("summaries", {}).get(cache_key)
|
|
146
|
+
if val is not None:
|
|
147
|
+
self.hits += 1
|
|
148
|
+
else:
|
|
149
|
+
self.misses += 1
|
|
150
|
+
return val
|
|
151
|
+
|
|
152
|
+
def set_summary(self, rel_path: str, blob_hash: str, summary: str, file_path: Optional[Path] = None):
|
|
153
|
+
if "summaries" not in self._data:
|
|
154
|
+
self._data["summaries"] = {}
|
|
155
|
+
cache_key = f"{rel_path}:{blob_hash}"
|
|
156
|
+
self._data["summaries"][cache_key] = summary
|
|
157
|
+
|
|
158
|
+
if file_path is not None:
|
|
159
|
+
if "summaries_meta" not in self._data:
|
|
160
|
+
self._data["summaries_meta"] = {}
|
|
161
|
+
try:
|
|
162
|
+
stat = file_path.stat()
|
|
163
|
+
self._data["summaries_meta"][rel_path] = {
|
|
164
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
165
|
+
"size": stat.st_size,
|
|
166
|
+
"blob_hash": blob_hash,
|
|
167
|
+
"summary": summary
|
|
168
|
+
}
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
@@ -40,6 +40,40 @@ class ContextAnchor:
|
|
|
40
40
|
|
|
41
41
|
return (result.stdout or "").strip()
|
|
42
42
|
|
|
43
|
+
def has_changes(self, current_files: list[Path]) -> bool:
|
|
44
|
+
"""Fast check to determine if any file was modified/added/deleted since last anchor baseline."""
|
|
45
|
+
if not self.git_dir.exists() or not (self.git_dir / "HEAD").exists() or not self.anchor_dir.exists():
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
current_rel_paths = set()
|
|
49
|
+
for src in current_files:
|
|
50
|
+
if ".git" in src.parts or ".codigest" in src.parts:
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
rel = src.relative_to(self.root)
|
|
54
|
+
current_rel_paths.add(rel)
|
|
55
|
+
dest = self.anchor_dir / rel
|
|
56
|
+
if not dest.exists():
|
|
57
|
+
return True
|
|
58
|
+
src_stat = src.stat()
|
|
59
|
+
dest_stat = dest.stat()
|
|
60
|
+
# Use size + 1ms mtime tolerance for filesystem precision differences
|
|
61
|
+
if src_stat.st_size != dest_stat.st_size or (src_stat.st_mtime_ns - dest_stat.st_mtime_ns) > 1_000_000:
|
|
62
|
+
return True
|
|
63
|
+
except Exception:
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
for anchor_file in self.anchor_dir.rglob("*"):
|
|
67
|
+
if anchor_file.is_file() and ".shadow_git" not in anchor_file.parts and anchor_file.name != ".gitignore":
|
|
68
|
+
try:
|
|
69
|
+
rel = anchor_file.relative_to(self.anchor_dir)
|
|
70
|
+
if rel not in current_rel_paths:
|
|
71
|
+
return True
|
|
72
|
+
except Exception:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
return False
|
|
76
|
+
|
|
43
77
|
def update(self, source_files: list[Path]):
|
|
44
78
|
self.anchor_dir.mkdir(parents=True, exist_ok=True)
|
|
45
79
|
(self.anchor_dir / ".gitignore").write_text(".shadow_git\n", encoding="utf-8")
|
|
@@ -56,13 +90,9 @@ class ContextAnchor:
|
|
|
56
90
|
self._run_git(["config", "core.autocrlf", "false"])
|
|
57
91
|
self._run_git(["config", "gc.auto", "0"])
|
|
58
92
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if item.is_dir():
|
|
63
|
-
shutil.rmtree(item)
|
|
64
|
-
else:
|
|
65
|
-
item.unlink()
|
|
93
|
+
# Quick check: if nothing changed since last anchor, skip work
|
|
94
|
+
if self.git_dir.exists() and (self.git_dir / "HEAD").exists() and not self.has_changes(source_files):
|
|
95
|
+
return
|
|
66
96
|
|
|
67
97
|
source_rel_paths = set()
|
|
68
98
|
|
|
@@ -81,7 +111,7 @@ class ContextAnchor:
|
|
|
81
111
|
src_stat = src.stat()
|
|
82
112
|
dest_stat = dest.stat()
|
|
83
113
|
|
|
84
|
-
if src_stat.st_size == dest_stat.st_size and dest_stat.
|
|
114
|
+
if src_stat.st_size == dest_stat.st_size and dest_stat.st_mtime_ns >= src_stat.st_mtime_ns:
|
|
85
115
|
should_copy = False
|
|
86
116
|
|
|
87
117
|
source_rel_paths.add(rel)
|
|
@@ -115,6 +145,9 @@ class ContextAnchor:
|
|
|
115
145
|
if not self.git_dir.exists():
|
|
116
146
|
return ""
|
|
117
147
|
|
|
148
|
+
if not self.has_changes(current_files):
|
|
149
|
+
return ""
|
|
150
|
+
|
|
118
151
|
temp_current = self.root / ".codigest" / "temp_diff_current"
|
|
119
152
|
temp_baseline = self.root / ".codigest" / "temp_diff_baseline"
|
|
120
153
|
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Git Blob Hash-based Content & AST Cache.
|
|
3
|
-
Provides fast caching for scan snapshot blocks and digest AST summaries.
|
|
4
|
-
"""
|
|
5
|
-
import json
|
|
6
|
-
import hashlib
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Optional, Dict, Any
|
|
9
|
-
|
|
10
|
-
def compute_git_blob_hash(data: bytes) -> str:
|
|
11
|
-
"""Computes Git-compatible SHA-1 blob hash for raw bytes."""
|
|
12
|
-
header = f"blob {len(data)}\0".encode("utf-8")
|
|
13
|
-
return hashlib.sha1(header + data).hexdigest()
|
|
14
|
-
|
|
15
|
-
def get_file_blob_hash(file_path: Path) -> Optional[str]:
|
|
16
|
-
"""Reads file and computes its Git blob hash."""
|
|
17
|
-
try:
|
|
18
|
-
data = file_path.read_bytes()
|
|
19
|
-
return compute_git_blob_hash(data)
|
|
20
|
-
except Exception:
|
|
21
|
-
return None
|
|
22
|
-
|
|
23
|
-
class ContentCache:
|
|
24
|
-
def __init__(self, root_path: Path):
|
|
25
|
-
self.root_path = root_path
|
|
26
|
-
self.cache_file = root_path / ".codigest" / "cache.json"
|
|
27
|
-
self._data: Dict[str, Any] = {}
|
|
28
|
-
self.hits = 0
|
|
29
|
-
self.misses = 0
|
|
30
|
-
self._load()
|
|
31
|
-
|
|
32
|
-
def _load(self):
|
|
33
|
-
if self.cache_file.exists():
|
|
34
|
-
try:
|
|
35
|
-
with open(self.cache_file, "r", encoding="utf-8") as f:
|
|
36
|
-
self._data = json.load(f)
|
|
37
|
-
except Exception:
|
|
38
|
-
self._data = {}
|
|
39
|
-
else:
|
|
40
|
-
self._data = {}
|
|
41
|
-
|
|
42
|
-
def save(self):
|
|
43
|
-
try:
|
|
44
|
-
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
-
with open(self.cache_file, "w", encoding="utf-8") as f:
|
|
46
|
-
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
|
47
|
-
except Exception:
|
|
48
|
-
pass
|
|
49
|
-
|
|
50
|
-
def get_file_block(self, rel_path: str, blob_hash: str, line_numbers: bool) -> Optional[str]:
|
|
51
|
-
cache_key = f"{rel_path}:{blob_hash}:lines={line_numbers}"
|
|
52
|
-
val = self._data.get("file_blocks", {}).get(cache_key)
|
|
53
|
-
if val is not None:
|
|
54
|
-
self.hits += 1
|
|
55
|
-
else:
|
|
56
|
-
self.misses += 1
|
|
57
|
-
return val
|
|
58
|
-
|
|
59
|
-
def set_file_block(self, rel_path: str, blob_hash: str, line_numbers: bool, block: str):
|
|
60
|
-
if "file_blocks" not in self._data:
|
|
61
|
-
self._data["file_blocks"] = {}
|
|
62
|
-
cache_key = f"{rel_path}:{blob_hash}:lines={line_numbers}"
|
|
63
|
-
self._data["file_blocks"][cache_key] = block
|
|
64
|
-
|
|
65
|
-
def get_summary(self, rel_path: str, blob_hash: str) -> Optional[str]:
|
|
66
|
-
cache_key = f"{rel_path}:{blob_hash}"
|
|
67
|
-
val = self._data.get("summaries", {}).get(cache_key)
|
|
68
|
-
if val is not None:
|
|
69
|
-
self.hits += 1
|
|
70
|
-
else:
|
|
71
|
-
self.misses += 1
|
|
72
|
-
return val
|
|
73
|
-
|
|
74
|
-
def set_summary(self, rel_path: str, blob_hash: str, summary: str):
|
|
75
|
-
if "summaries" not in self._data:
|
|
76
|
-
self._data["summaries"] = {}
|
|
77
|
-
cache_key = f"{rel_path}:{blob_hash}"
|
|
78
|
-
self._data["summaries"][cache_key] = summary
|
|
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
|