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.
Files changed (34) hide show
  1. {codigest-0.3.6 → codigest-0.3.8}/PKG-INFO +1 -1
  2. {codigest-0.3.6 → codigest-0.3.8}/pyproject.toml +1 -1
  3. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/diff.py +15 -23
  4. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/digest.py +19 -29
  5. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/scan.py +26 -18
  6. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/semdiff.py +16 -24
  7. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/tree.py +20 -11
  8. codigest-0.3.8/src/codigest/core/cache.py +170 -0
  9. codigest-0.3.8/src/codigest/core/output.py +50 -0
  10. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/shadow.py +41 -8
  11. codigest-0.3.8/src/codigest/options.py +31 -0
  12. {codigest-0.3.6 → codigest-0.3.8}/uv.lock +1 -1
  13. codigest-0.3.6/src/codigest/core/cache.py +0 -78
  14. {codigest-0.3.6 → codigest-0.3.8}/.github/workflows/publish.yml +0 -0
  15. {codigest-0.3.6 → codigest-0.3.8}/.gitignore +0 -0
  16. {codigest-0.3.6 → codigest-0.3.8}/.python-version +0 -0
  17. {codigest-0.3.6 → codigest-0.3.8}/README.md +0 -0
  18. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/__init__.py +0 -0
  19. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/__main__.py +0 -0
  20. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/clean.py +0 -0
  21. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/init.py +0 -0
  22. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/commands/status.py +0 -0
  23. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/config.py +0 -0
  24. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/common.py +0 -0
  25. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/git_ops.py +0 -0
  26. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/processor.py +0 -0
  27. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/prompts.py +0 -0
  28. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/resolver.py +0 -0
  29. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/scanner.py +0 -0
  30. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/semdiff.py +0 -0
  31. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/structure.py +0 -0
  32. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/tags.py +0 -0
  33. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/core/tokenizer.py +0 -0
  34. {codigest-0.3.6 → codigest-0.3.8}/src/codigest/main.py +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.8
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.8"
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"
@@ -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: bool = typer.Option(True, help="Auto-copy to clipboard"),
21
+ copy: CopyOption = True,
24
22
  save: bool = typer.Option(True, help="Save to .codigest/changes.diff"),
25
- stdout: bool = typer.Option(False, "-s", "--stdout", help="Print output to terminal (stdout) instead of file"),
26
- message: str = typer.Option("", "--message", "-m", help="Add specific instruction context"),
27
- # [추가] resolve 옵션
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
- log_console = err_console if stdout else console
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
- if copy:
91
- try:
92
- pyperclip.copy(formatted_diff)
93
- log_console.print("[dim]Clipboard copied[/dim]")
94
- except Exception:
95
- log_console.print("[dim]⚠️ Clipboard unavailable (skipped copy)[/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
- if stdout:
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: bool = typer.Option(True, help="Auto-copy to clipboard"),
14
+ copy: CopyOption = True,
17
15
  save: bool = typer.Option(True, help="Save to .codigest/digest.txt"),
18
- stdout: bool = typer.Option(False, "-s", "--stdout", help="Print output to terminal (stdout) instead of file"),
19
- message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
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
- log_console = err_console if stdout else console
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
- 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)
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
- if copy:
98
- try:
99
- pyperclip.copy(digest_content)
100
- log_console.print("[dim]📋 Copied to clipboard[/dim]")
101
- except Exception:
102
- log_console.print("[dim]⚠️ Clipboard unavailable (skipped copy)[/dim]")
103
-
104
- if stdout:
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
- stdout: bool = typer.Option(False, "-s", "--stdout", help="Print output to terminal (stdout) instead of file"),
25
- all: bool = typer.Option(False, "--all", "-a", help="Ignore config filters"),
26
- message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
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: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports for local files"),
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
- log_console = err_console if stdout else console
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
- 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)
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
- if blob_hash:
139
- content_cache.set_file_block(rel_path, blob_hash, line_numbers, block)
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
- if stdout:
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: bool = typer.Option(True, help="Auto-copy to clipboard"),
14
+ copy: CopyOption = True,
17
15
  save: bool = typer.Option(True, help="Save to .codigest/semdiff.txt"),
18
- stdout: bool = typer.Option(False, "-s", "--stdout", help="Print output to terminal (stdout) instead of file"),
19
- message: str = typer.Option("", "--message", "-m", help="Add specific instruction"),
20
- # [추가] resolve 옵션
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
- log_console = err_console if stdout else console
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
- if copy:
138
- try:
139
- pyperclip.copy(final_output)
140
- log_console.print("[dim]Clipboard copied[/dim]")
141
- except Exception:
142
- log_console.print("[dim]⚠️ Clipboard unavailable (skipped copy)[/dim]")
143
-
144
- if stdout:
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 ..core import common
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
- all: bool = typer.Option(False, "--all", "-a", help="Ignore config filters"),
58
- resolve: bool = typer.Option(False, "-r", "--resolve", help="Recursively resolve imports"),
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
- console.print(f"[red][Error] Scan failed:[/red] {e}")
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
- console.print("[yellow][Warning] No matching files found.[/yellow]")
80
+ log_console.print("[yellow][Warning] No matching files found.[/yellow]")
77
81
  return
78
82
 
79
83
  # 3. Visualize
80
- tree_viz = _build_rich_tree(root_path, files)
81
- console.print(tree_viz)
82
- console.print(f"\n[dim]Found {len(files)} files.[/dim]")
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
- 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
 
@@ -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
+ ]
@@ -16,7 +16,7 @@ wheels = [
16
16
 
17
17
  [[package]]
18
18
  name = "codigest"
19
- version = "0.3.6"
19
+ version = "0.3.8"
20
20
  source = { editable = "." }
21
21
  dependencies = [
22
22
  { name = "loguru" },
@@ -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