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.
Files changed (32) hide show
  1. {codigest-0.3.6 → codigest-0.3.7}/PKG-INFO +1 -1
  2. {codigest-0.3.6 → codigest-0.3.7}/pyproject.toml +1 -1
  3. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/digest.py +3 -5
  4. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/scan.py +13 -6
  5. codigest-0.3.7/src/codigest/core/cache.py +170 -0
  6. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/shadow.py +41 -8
  7. codigest-0.3.6/src/codigest/core/cache.py +0 -78
  8. {codigest-0.3.6 → codigest-0.3.7}/.github/workflows/publish.yml +0 -0
  9. {codigest-0.3.6 → codigest-0.3.7}/.gitignore +0 -0
  10. {codigest-0.3.6 → codigest-0.3.7}/.python-version +0 -0
  11. {codigest-0.3.6 → codigest-0.3.7}/README.md +0 -0
  12. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/__init__.py +0 -0
  13. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/__main__.py +0 -0
  14. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/clean.py +0 -0
  15. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/diff.py +0 -0
  16. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/init.py +0 -0
  17. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/semdiff.py +0 -0
  18. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/status.py +0 -0
  19. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/commands/tree.py +0 -0
  20. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/config.py +0 -0
  21. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/common.py +0 -0
  22. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/git_ops.py +0 -0
  23. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/processor.py +0 -0
  24. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/prompts.py +0 -0
  25. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/resolver.py +0 -0
  26. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/scanner.py +0 -0
  27. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/semdiff.py +0 -0
  28. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/structure.py +0 -0
  29. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/tags.py +0 -0
  30. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/core/tokenizer.py +0 -0
  31. {codigest-0.3.6 → codigest-0.3.7}/src/codigest/main.py +0 -0
  32. {codigest-0.3.6 → codigest-0.3.7}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codigest
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Human-Centered Codebase Context Manager for LLMs
5
5
  Project-URL: Homepage, https://github.com/SJB777/codigest
6
6
  Project-URL: Repository, https://github.com/SJB777/codigest
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codigest"
3
- version = "0.3.6"
3
+ version = "0.3.7"
4
4
  description = "Human-Centered Codebase Context Manager for LLMs"
5
5
  authors = [{name = "Codigest Team", email = "sngd024@google.com"}]
6
6
  readme = "README.md"
@@ -55,17 +55,15 @@ def handle(
55
55
  except ValueError:
56
56
  rel_path = f"[EXTERNAL]/{file_path.name}"
57
57
 
58
- blob_hash = cache.get_file_blob_hash(file_path)
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
- blob_hash = cache.get_file_blob_hash(file_path)
127
- cached_block = None
128
- if blob_hash:
129
- cached_block = content_cache.get_file_block(rel_path, blob_hash, line_numbers)
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
- if blob_hash:
139
- content_cache.set_file_block(rel_path, blob_hash, line_numbers, block)
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
- for item in self.anchor_dir.iterdir():
60
- if item.name == ".shadow_git": # [변경] 보호할 폴더 이름 변경
61
- continue
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.st_mtime >= src_stat.st_mtime:
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