codigest 0.3.6__tar.gz → 0.3.8__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.8}/PKG-INFO +1 -1
- {codigest-0.3.6 → codigest-0.3.8}/pyproject.toml +1 -1
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/diff.py +15 -23
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/digest.py +19 -29
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/scan.py +26 -18
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/semdiff.py +16 -24
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/tree.py +20 -11
- codigest-0.3.8/src/codigest/core/cache.py +170 -0
- codigest-0.3.8/src/codigest/core/output.py +50 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/shadow.py +41 -8
- codigest-0.3.8/src/codigest/options.py +31 -0
- {codigest-0.3.6 → codigest-0.3.8}/uv.lock +1 -1
- codigest-0.3.6/src/codigest/core/cache.py +0 -78
- {codigest-0.3.6 → codigest-0.3.8}/.github/workflows/publish.yml +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/.gitignore +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/.python-version +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/README.md +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/__init__.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/__main__.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/clean.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/init.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/status.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/config.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/common.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/git_ops.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/processor.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/prompts.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/resolver.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/scanner.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/semdiff.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/structure.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/tags.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/tokenizer.py +0 -0
- {codigest-0.3.6 → codigest-0.3.8}/src/codigest/main.py +0 -0
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import typer
|
|
2
|
-
import pyperclip
|
|
3
2
|
from pathlib import Path
|
|
4
|
-
from rich.console import Console
|
|
5
3
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
4
|
|
|
5
|
+
from ..options import CopyOption, StdoutOption, ResolveOption, MessageOption
|
|
7
6
|
from ..core import prompts, shadow, tags, common
|
|
7
|
+
from ..core.output import OutputHandler
|
|
8
8
|
|
|
9
9
|
app = typer.Typer()
|
|
10
|
-
console = Console()
|
|
11
|
-
err_console = Console(stderr=True)
|
|
12
10
|
|
|
13
11
|
@app.callback(invoke_without_command=True)
|
|
14
12
|
def handle(
|
|
@@ -20,18 +18,18 @@ def handle(
|
|
|
20
18
|
dir_okay=True,
|
|
21
19
|
resolve_path=True
|
|
22
20
|
),
|
|
23
|
-
copy:
|
|
21
|
+
copy: CopyOption = True,
|
|
24
22
|
save: bool = typer.Option(True, help="Save to .codigest/changes.diff"),
|
|
25
|
-
stdout:
|
|
26
|
-
message:
|
|
27
|
-
|
|
28
|
-
resolve: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports"),
|
|
23
|
+
stdout: StdoutOption = False,
|
|
24
|
+
message: MessageOption = "",
|
|
25
|
+
resolve: ResolveOption = False,
|
|
29
26
|
):
|
|
30
27
|
"""
|
|
31
28
|
[Context Update] Shows changes since the last 'codigest scan'.
|
|
32
29
|
Useful for updating LLM context without re-uploading everything.
|
|
33
30
|
"""
|
|
34
|
-
|
|
31
|
+
handler = OutputHandler(stdout=stdout, copy=copy)
|
|
32
|
+
log_console = handler.log_console
|
|
35
33
|
|
|
36
34
|
# [1] Context Setup
|
|
37
35
|
ctx = common.get_context(target)
|
|
@@ -87,17 +85,11 @@ def handle(
|
|
|
87
85
|
# [4] Output
|
|
88
86
|
log_console.print(f"[bold green]Changes Detected![/bold green] ({len(formatted_diff)} chars)")
|
|
89
87
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
log_console.print("[dim]
|
|
88
|
+
def _save():
|
|
89
|
+
if save:
|
|
90
|
+
out_path = root_path / ".codigest" / "changes.diff"
|
|
91
|
+
out_path.parent.mkdir(exist_ok=True)
|
|
92
|
+
out_path.write_text(formatted_diff, encoding="utf-8")
|
|
93
|
+
log_console.print(f"[dim]Saved to {out_path}[/dim]")
|
|
96
94
|
|
|
97
|
-
|
|
98
|
-
print(formatted_diff)
|
|
99
|
-
elif save:
|
|
100
|
-
out_path = root_path / ".codigest" / "changes.diff"
|
|
101
|
-
out_path.parent.mkdir(exist_ok=True)
|
|
102
|
-
out_path.write_text(formatted_diff, encoding="utf-8")
|
|
103
|
-
log_console.print(f"[dim]Saved to {out_path}[/dim]")
|
|
95
|
+
handler.handle_output(formatted_diff, save_action=_save, item_name="diff")
|
|
@@ -1,29 +1,27 @@
|
|
|
1
1
|
import typer
|
|
2
|
-
import pyperclip
|
|
3
2
|
from pathlib import Path
|
|
4
|
-
from rich.console import Console
|
|
5
3
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
4
|
|
|
5
|
+
from ..options import CopyOption, StdoutOption, ResolveOption, MessageOption
|
|
7
6
|
from ..core import structure, prompts, semdiff, tags, tokenizer, common, cache
|
|
7
|
+
from ..core.output import OutputHandler
|
|
8
8
|
|
|
9
9
|
app = typer.Typer()
|
|
10
|
-
console = Console()
|
|
11
|
-
err_console = Console(stderr=True)
|
|
12
10
|
|
|
13
11
|
@app.callback(invoke_without_command=True)
|
|
14
12
|
def handle(
|
|
15
13
|
target: Path = typer.Argument(Path.cwd(), help="Target directory"),
|
|
16
|
-
copy:
|
|
14
|
+
copy: CopyOption = True,
|
|
17
15
|
save: bool = typer.Option(True, help="Save to .codigest/digest.txt"),
|
|
18
|
-
stdout:
|
|
19
|
-
message:
|
|
20
|
-
|
|
21
|
-
resolve: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports"),
|
|
16
|
+
stdout: StdoutOption = False,
|
|
17
|
+
message: MessageOption = "",
|
|
18
|
+
resolve: ResolveOption = False,
|
|
22
19
|
):
|
|
23
20
|
"""
|
|
24
21
|
[Architectural View] Summarizes the codebase structure (Classes/Functions only).
|
|
25
22
|
"""
|
|
26
|
-
|
|
23
|
+
handler = OutputHandler(stdout=stdout, copy=copy)
|
|
24
|
+
log_console = handler.log_console
|
|
27
25
|
|
|
28
26
|
# [1] Context Setup
|
|
29
27
|
ctx = common.get_context(target)
|
|
@@ -55,17 +53,15 @@ def handle(
|
|
|
55
53
|
except ValueError:
|
|
56
54
|
rel_path = f"[EXTERNAL]/{file_path.name}"
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
summary = None
|
|
60
|
-
if blob_hash:
|
|
61
|
-
summary = content_cache.get_summary(rel_path, blob_hash)
|
|
56
|
+
summary = content_cache.get_summary(rel_path, blob_hash=None, file_path=file_path)
|
|
62
57
|
|
|
63
58
|
if summary is None:
|
|
64
59
|
try:
|
|
60
|
+
blob_hash = cache.get_file_blob_hash(file_path)
|
|
65
61
|
content = file_path.read_text(encoding="utf-8")
|
|
66
62
|
summary = semdiff.summarize(content)
|
|
67
63
|
if summary and blob_hash:
|
|
68
|
-
content_cache.set_summary(rel_path, blob_hash, summary)
|
|
64
|
+
content_cache.set_summary(rel_path, blob_hash, summary, file_path=file_path)
|
|
69
65
|
except Exception:
|
|
70
66
|
continue
|
|
71
67
|
|
|
@@ -94,17 +90,11 @@ def handle(
|
|
|
94
90
|
token_count = tokenizer.estimate_tokens(digest_content)
|
|
95
91
|
log_console.print(f"[bold green]Digest Generated![/bold green] ([bold cyan]~{token_count:,} Tokens[/bold cyan])")
|
|
96
92
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
log_console.print("[dim]
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
print(digest_content)
|
|
106
|
-
elif save:
|
|
107
|
-
out_path = root_path / ".codigest" / "digest.txt"
|
|
108
|
-
out_path.parent.mkdir(exist_ok=True)
|
|
109
|
-
out_path.write_text(digest_content, encoding="utf-8")
|
|
110
|
-
log_console.print(f"[dim]💾 Saved to {out_path}[/dim]")
|
|
93
|
+
def _save():
|
|
94
|
+
if save:
|
|
95
|
+
out_path = root_path / ".codigest" / "digest.txt"
|
|
96
|
+
out_path.parent.mkdir(exist_ok=True)
|
|
97
|
+
out_path.write_text(digest_content, encoding="utf-8")
|
|
98
|
+
log_console.print(f"[dim]💾 Saved to {out_path}[/dim]")
|
|
99
|
+
|
|
100
|
+
handler.handle_output(digest_content, save_action=_save, item_name="digest")
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
import typer
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from rich.console import Console
|
|
5
4
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
5
|
from rich.panel import Panel
|
|
7
6
|
from rich.filesize import decimal
|
|
8
7
|
|
|
8
|
+
from ..options import CopyOption, StdoutOption, ResolveOption, MessageOption, AllOption
|
|
9
9
|
from ..core import structure, tags, prompts, processor, shadow, tokenizer, common, cache
|
|
10
|
+
from ..core.output import OutputHandler
|
|
10
11
|
|
|
11
12
|
app = typer.Typer()
|
|
12
|
-
console = Console()
|
|
13
|
-
err_console = Console(stderr=True)
|
|
14
13
|
|
|
15
14
|
@app.callback(invoke_without_command=True)
|
|
16
15
|
def handle(
|
|
@@ -21,18 +20,20 @@ def handle(
|
|
|
21
20
|
resolve_path=True
|
|
22
21
|
),
|
|
23
22
|
output: str = typer.Option("snapshot.txt", "-o", "--output", help="Output filename inside .codigest/"),
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
copy: CopyOption = True,
|
|
24
|
+
stdout: StdoutOption = False,
|
|
25
|
+
all: AllOption = False,
|
|
26
|
+
message: MessageOption = "",
|
|
27
27
|
line_numbers: bool = typer.Option(False, "--lines", "-l", help="Add line numbers to code blocks"),
|
|
28
28
|
yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation prompt"),
|
|
29
|
-
resolve:
|
|
29
|
+
resolve: ResolveOption = False,
|
|
30
30
|
):
|
|
31
31
|
"""
|
|
32
32
|
Scans the codebase.
|
|
33
33
|
If TARGETS provided, only scans those paths within the project.
|
|
34
34
|
"""
|
|
35
|
-
|
|
35
|
+
handler = OutputHandler(stdout=stdout, copy=copy)
|
|
36
|
+
log_console = handler.log_console
|
|
36
37
|
|
|
37
38
|
# [1] Context Setup (Centralized)
|
|
38
39
|
ctx = common.get_context(targets)
|
|
@@ -123,20 +124,27 @@ def handle(
|
|
|
123
124
|
except ValueError:
|
|
124
125
|
rel_path = f"[EXTERNAL]/{file_path.name}"
|
|
125
126
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
cached_block = content_cache.get_file_block(
|
|
128
|
+
rel_path=rel_path,
|
|
129
|
+
line_numbers=line_numbers,
|
|
130
|
+
file_path=file_path
|
|
131
|
+
)
|
|
130
132
|
|
|
131
133
|
if cached_block is not None:
|
|
132
134
|
file_blocks.append(cached_block)
|
|
133
135
|
else:
|
|
134
136
|
try:
|
|
137
|
+
blob_hash = cache.get_file_blob_hash(file_path)
|
|
135
138
|
content = processor.read_file_content(file_path, add_line_numbers=line_numbers)
|
|
136
139
|
block = tags.file(rel_path, content)
|
|
137
140
|
file_blocks.append(block)
|
|
138
|
-
|
|
139
|
-
|
|
141
|
+
content_cache.set_file_block(
|
|
142
|
+
rel_path=rel_path,
|
|
143
|
+
blob_hash=blob_hash,
|
|
144
|
+
line_numbers=line_numbers,
|
|
145
|
+
block=block,
|
|
146
|
+
file_path=file_path
|
|
147
|
+
)
|
|
140
148
|
except Exception:
|
|
141
149
|
continue
|
|
142
150
|
|
|
@@ -161,9 +169,7 @@ def handle(
|
|
|
161
169
|
except Exception as e:
|
|
162
170
|
log_console.print(f"[yellow][Warning] Failed to update context anchor: {e}[/yellow]")
|
|
163
171
|
|
|
164
|
-
|
|
165
|
-
print(snapshot_content)
|
|
166
|
-
else:
|
|
172
|
+
def _save():
|
|
167
173
|
try:
|
|
168
174
|
output_path.write_text(snapshot_content, encoding="utf-8")
|
|
169
175
|
final_token_count = tokenizer.estimate_tokens(snapshot_content)
|
|
@@ -179,4 +185,6 @@ def handle(
|
|
|
179
185
|
|
|
180
186
|
except Exception as e:
|
|
181
187
|
log_console.print(f"[bold red][Error] Save Failed:[/bold red] {e}")
|
|
182
|
-
raise typer.Exit(1)
|
|
188
|
+
raise typer.Exit(1)
|
|
189
|
+
|
|
190
|
+
handler.handle_output(snapshot_content, save_action=_save)
|
|
@@ -1,30 +1,28 @@
|
|
|
1
1
|
import typer
|
|
2
|
-
import pyperclip
|
|
3
2
|
from pathlib import Path
|
|
4
|
-
from rich.console import Console
|
|
5
3
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
4
|
|
|
5
|
+
from ..options import CopyOption, StdoutOption, ResolveOption, MessageOption
|
|
7
6
|
from ..core import prompts, shadow, semdiff, tags, tokenizer, common
|
|
7
|
+
from ..core.output import OutputHandler
|
|
8
8
|
|
|
9
9
|
app = typer.Typer()
|
|
10
|
-
console = Console()
|
|
11
|
-
err_console = Console(stderr=True)
|
|
12
10
|
|
|
13
11
|
@app.callback(invoke_without_command=True)
|
|
14
12
|
def handle(
|
|
15
13
|
target: Path = typer.Argument(Path.cwd(), help="Target directory"),
|
|
16
|
-
copy:
|
|
14
|
+
copy: CopyOption = True,
|
|
17
15
|
save: bool = typer.Option(True, help="Save to .codigest/semdiff.txt"),
|
|
18
|
-
stdout:
|
|
19
|
-
message:
|
|
20
|
-
|
|
21
|
-
resolve: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports"),
|
|
16
|
+
stdout: StdoutOption = False,
|
|
17
|
+
message: MessageOption = "",
|
|
18
|
+
resolve: ResolveOption = False,
|
|
22
19
|
):
|
|
23
20
|
"""
|
|
24
21
|
[Advanced] Generates a Semantic Diff (AST-based) report.
|
|
25
22
|
Shows ADDED/REMOVED/MODIFIED functions & classes instead of raw text lines.
|
|
26
23
|
"""
|
|
27
|
-
|
|
24
|
+
handler = OutputHandler(stdout=stdout, copy=copy)
|
|
25
|
+
log_console = handler.log_console
|
|
28
26
|
|
|
29
27
|
# [1] Context Setup
|
|
30
28
|
ctx = common.get_context(target)
|
|
@@ -134,17 +132,11 @@ def handle(
|
|
|
134
132
|
token_count = tokenizer.estimate_tokens(final_output)
|
|
135
133
|
log_console.print(f"[bold green]SemDiff Generated![/bold green] ([bold cyan]~{token_count:,} Tokens[/bold cyan])")
|
|
136
134
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
log_console.print("[dim]
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
print(final_output)
|
|
146
|
-
elif save:
|
|
147
|
-
out_path = root_path / ".codigest" / "semdiff.txt"
|
|
148
|
-
out_path.parent.mkdir(exist_ok=True)
|
|
149
|
-
out_path.write_text(final_output, encoding="utf-8")
|
|
150
|
-
log_console.print(f"[dim]Saved to {out_path}[/dim]")
|
|
135
|
+
def _save():
|
|
136
|
+
if save:
|
|
137
|
+
out_path = root_path / ".codigest" / "semdiff.txt"
|
|
138
|
+
out_path.parent.mkdir(exist_ok=True)
|
|
139
|
+
out_path.write_text(final_output, encoding="utf-8")
|
|
140
|
+
log_console.print(f"[dim]Saved to {out_path}[/dim]")
|
|
141
|
+
|
|
142
|
+
handler.handle_output(final_output, save_action=_save, item_name="semdiff")
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import typer
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from rich.console import Console
|
|
4
3
|
from rich.tree import Tree
|
|
5
4
|
from rich.text import Text
|
|
6
5
|
from rich.filesize import decimal
|
|
7
6
|
|
|
8
|
-
from ..
|
|
7
|
+
from ..options import CopyOption, StdoutOption, ResolveOption, AllOption
|
|
8
|
+
from ..core import common, structure
|
|
9
|
+
from ..core.output import OutputHandler
|
|
9
10
|
|
|
10
11
|
app = typer.Typer()
|
|
11
|
-
console = Console()
|
|
12
12
|
|
|
13
13
|
def _build_rich_tree(root_path: Path, files: list[Path]) -> Tree:
|
|
14
14
|
# (기존의 이모티콘 없는 버전 로직 유지)
|
|
@@ -16,7 +16,6 @@ def _build_rich_tree(root_path: Path, files: list[Path]) -> Tree:
|
|
|
16
16
|
dir_nodes = {root_path: tree}
|
|
17
17
|
|
|
18
18
|
for path in files:
|
|
19
|
-
# (중략: 기존 트리 생성 로직)
|
|
20
19
|
# External path 처리 (루트 밖의 파일)
|
|
21
20
|
try:
|
|
22
21
|
relative = path.relative_to(root_path)
|
|
@@ -54,12 +53,17 @@ def _build_rich_tree(root_path: Path, files: list[Path]) -> Tree:
|
|
|
54
53
|
@app.callback(invoke_without_command=True)
|
|
55
54
|
def handle(
|
|
56
55
|
target: Path = typer.Argument(Path.cwd(), help="Target directory"),
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
copy: CopyOption = False,
|
|
57
|
+
stdout: StdoutOption = False,
|
|
58
|
+
all: AllOption = False,
|
|
59
|
+
resolve: ResolveOption = False,
|
|
59
60
|
):
|
|
60
61
|
"""
|
|
61
62
|
[Visual] Print the project directory tree.
|
|
62
63
|
"""
|
|
64
|
+
handler = OutputHandler(stdout=stdout, copy=copy)
|
|
65
|
+
log_console = handler.log_console
|
|
66
|
+
|
|
63
67
|
# 1. Context 생성 (루트 찾기용)
|
|
64
68
|
ctx = common.get_context(target)
|
|
65
69
|
root_path = ctx.root_path
|
|
@@ -69,14 +73,19 @@ def handle(
|
|
|
69
73
|
# [수정] targets=[target]을 명시적으로 전달해야 해당 폴더만 스캔함
|
|
70
74
|
files = ctx.get_target_files(targets=[target], ignore_config=all, resolve_deps=resolve)
|
|
71
75
|
except Exception as e:
|
|
72
|
-
|
|
76
|
+
log_console.print(f"[red][Error] Scan failed:[/red] {e}")
|
|
73
77
|
raise typer.Exit(code=1)
|
|
74
78
|
|
|
75
79
|
if not files:
|
|
76
|
-
|
|
80
|
+
log_console.print("[yellow][Warning] No matching files found.[/yellow]")
|
|
77
81
|
return
|
|
78
82
|
|
|
79
83
|
# 3. Visualize
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
ascii_tree = structure.generate_ascii_tree(files, root_path)
|
|
85
|
+
|
|
86
|
+
def _print_tree_viz():
|
|
87
|
+
tree_viz = _build_rich_tree(root_path, files)
|
|
88
|
+
log_console.print(tree_viz)
|
|
89
|
+
log_console.print(f"\n[dim]Found {len(files)} files.[/dim]")
|
|
90
|
+
|
|
91
|
+
handler.handle_output(ascii_tree, save_action=_print_tree_viz, item_name="tree")
|
|
@@ -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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified Output Handler for CLI commands.
|
|
3
|
+
Manages stderr log console, clipboard copying, and file vs stdout output.
|
|
4
|
+
"""
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
import pyperclip
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
err_console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class OutputHandler:
|
|
15
|
+
stdout: bool = False
|
|
16
|
+
copy: bool = True
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def log_console(self) -> Console:
|
|
20
|
+
"""
|
|
21
|
+
Returns err_console (stderr) when output is directed to stdout,
|
|
22
|
+
ensuring progress/logs do not pollute stdout pipelines.
|
|
23
|
+
"""
|
|
24
|
+
return err_console if self.stdout else console
|
|
25
|
+
|
|
26
|
+
def copy_to_clipboard(self, content: str, item_name: str = "output"):
|
|
27
|
+
"""Copies content to clipboard safely."""
|
|
28
|
+
if not self.copy:
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
pyperclip.copy(content)
|
|
32
|
+
self.log_console.print(f"[dim]📋 Copied {item_name} to clipboard[/dim]")
|
|
33
|
+
except Exception:
|
|
34
|
+
self.log_console.print("[dim]⚠️ Clipboard unavailable (skipped copy)[/dim]")
|
|
35
|
+
|
|
36
|
+
def handle_output(
|
|
37
|
+
self,
|
|
38
|
+
content: str,
|
|
39
|
+
save_action: Optional[Callable[[], None]] = None,
|
|
40
|
+
item_name: str = "output"
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
Executes clipboard copy, stdout print, or file save action cleanly.
|
|
44
|
+
"""
|
|
45
|
+
self.copy_to_clipboard(content, item_name=item_name)
|
|
46
|
+
|
|
47
|
+
if self.stdout:
|
|
48
|
+
print(content)
|
|
49
|
+
elif save_action:
|
|
50
|
+
save_action()
|
|
@@ -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
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized CLI Options & Common Annotated Types.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
# Common CLI Option Types using typing.Annotated
|
|
8
|
+
CopyOption = Annotated[
|
|
9
|
+
bool,
|
|
10
|
+
typer.Option("-c", "--copy/--no-copy", help="Auto-copy output to clipboard")
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
StdoutOption = Annotated[
|
|
14
|
+
bool,
|
|
15
|
+
typer.Option("-s", "--stdout", help="Print output to terminal (stdout) instead of file")
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
ResolveOption = Annotated[
|
|
19
|
+
bool,
|
|
20
|
+
typer.Option("-r", "--resolve", help="Recursively resolve imports")
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
MessageOption = Annotated[
|
|
24
|
+
str,
|
|
25
|
+
typer.Option("-m", "--message", help="Add specific instruction context")
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
AllOption = Annotated[
|
|
29
|
+
bool,
|
|
30
|
+
typer.Option("-a", "--all", help="Ignore config filters")
|
|
31
|
+
]
|
|
@@ -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
|