diffctx 1.8.1__cp310-abi3-win_amd64.whl
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.
- diffctx/__init__.py +100 -0
- diffctx/__main__.py +17 -0
- diffctx/_diffctx.pyd +0 -0
- diffctx/cli.py +491 -0
- diffctx/clipboard.py +81 -0
- diffctx/diffctx/__init__.py +7 -0
- diffctx/diffctx/graph_analytics.py +59 -0
- diffctx/diffctx/graph_export.py +46 -0
- diffctx/diffctx/pipeline.py +108 -0
- diffctx/diffctx/project_graph.py +27 -0
- diffctx/ignore.py +345 -0
- diffctx/logger.py +32 -0
- diffctx/main.py +397 -0
- diffctx/mcp/README.md +104 -0
- diffctx/mcp/__init__.py +0 -0
- diffctx/mcp/__main__.py +19 -0
- diffctx/mcp/formatting.py +9 -0
- diffctx/mcp/security.py +35 -0
- diffctx/mcp/server.py +244 -0
- diffctx/py.typed +0 -0
- diffctx/tokens.py +49 -0
- diffctx/tree.py +279 -0
- diffctx/version.py +1 -0
- diffctx/writer.py +415 -0
- diffctx-1.8.1.dist-info/METADATA +373 -0
- diffctx-1.8.1.dist-info/RECORD +30 -0
- diffctx-1.8.1.dist-info/WHEEL +4 -0
- diffctx-1.8.1.dist-info/entry_points.txt +3 -0
- diffctx-1.8.1.dist-info/licenses/LICENSE +201 -0
- diffctx-1.8.1.dist-info/sboms/diffctx.cyclonedx.json +4647 -0
diffctx/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .diffctx import build_diff_context
|
|
9
|
+
from .ignore import get_ignore_specs, get_whitelist_spec
|
|
10
|
+
from .main import run
|
|
11
|
+
from .tree import TreeBuildContext, build_tree
|
|
12
|
+
from .version import __version__
|
|
13
|
+
from .writer import write_tree_json, write_tree_markdown, write_tree_text, write_tree_yaml
|
|
14
|
+
|
|
15
|
+
logging.getLogger("diffctx").addHandler(logging.NullHandler())
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"build_diff_context",
|
|
20
|
+
"map_directory",
|
|
21
|
+
"run",
|
|
22
|
+
"to_json",
|
|
23
|
+
"to_markdown",
|
|
24
|
+
"to_text",
|
|
25
|
+
"to_yaml",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _root_display_name(user_path: str | Path, resolved: Path) -> str:
|
|
30
|
+
original_name = Path(user_path).name
|
|
31
|
+
if original_name:
|
|
32
|
+
return original_name
|
|
33
|
+
return str(resolved)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_path_if_exists(path: str | Path | None, label: str) -> Path | None:
|
|
37
|
+
if path is None:
|
|
38
|
+
return None
|
|
39
|
+
resolved = Path(path).resolve()
|
|
40
|
+
if not resolved.is_file():
|
|
41
|
+
raise FileNotFoundError(f"{label} '{path}' does not exist")
|
|
42
|
+
return resolved
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def map_directory(
|
|
46
|
+
path: str | Path,
|
|
47
|
+
*,
|
|
48
|
+
max_depth: int | None = None,
|
|
49
|
+
no_content: bool = False,
|
|
50
|
+
max_file_bytes: int | None = None,
|
|
51
|
+
ignore_file: str | Path | None = None,
|
|
52
|
+
no_default_ignores: bool = False,
|
|
53
|
+
whitelist_file: str | Path | None = None,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
root_dir = Path(path).resolve()
|
|
56
|
+
if not root_dir.is_dir():
|
|
57
|
+
raise ValueError(f"'{path}' is not a directory")
|
|
58
|
+
|
|
59
|
+
ignore_path = _resolve_path_if_exists(ignore_file, "Ignore file")
|
|
60
|
+
whitelist_path = _resolve_path_if_exists(whitelist_file, "Whitelist file")
|
|
61
|
+
|
|
62
|
+
ctx = TreeBuildContext(
|
|
63
|
+
base_dir=root_dir,
|
|
64
|
+
combined_spec=get_ignore_specs(root_dir, ignore_path, no_default_ignores, None),
|
|
65
|
+
output_file=None,
|
|
66
|
+
max_depth=max_depth,
|
|
67
|
+
no_content=no_content,
|
|
68
|
+
max_file_bytes=max_file_bytes,
|
|
69
|
+
whitelist_spec=get_whitelist_spec(whitelist_path, root_dir),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
"name": _root_display_name(path, root_dir),
|
|
74
|
+
"type": "directory",
|
|
75
|
+
"children": build_tree(root_dir, ctx),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def to_yaml(tree: dict[str, Any]) -> str:
|
|
80
|
+
buf = io.StringIO()
|
|
81
|
+
write_tree_yaml(buf, tree)
|
|
82
|
+
return buf.getvalue()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def to_json(tree: dict[str, Any]) -> str:
|
|
86
|
+
buf = io.StringIO()
|
|
87
|
+
write_tree_json(buf, tree)
|
|
88
|
+
return buf.getvalue()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def to_text(tree: dict[str, Any]) -> str:
|
|
92
|
+
buf = io.StringIO()
|
|
93
|
+
write_tree_text(buf, tree)
|
|
94
|
+
return buf.getvalue()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def to_markdown(tree: dict[str, Any]) -> str:
|
|
98
|
+
buf = io.StringIO()
|
|
99
|
+
write_tree_markdown(buf, tree)
|
|
100
|
+
return buf.getvalue()
|
diffctx/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
# Initialize logging early to avoid Python 3.13 issues with argparse
|
|
5
|
+
# This ensures logging's internal state is fully set up before argparse runs
|
|
6
|
+
try:
|
|
7
|
+
logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.ERROR)
|
|
8
|
+
# Force initialization of logging internals
|
|
9
|
+
logging.getLogger()
|
|
10
|
+
except (OSError, ValueError) as e:
|
|
11
|
+
# If logging initialization fails, warn but continue
|
|
12
|
+
print(f"Warning: logging init failed: {e}", file=sys.stderr)
|
|
13
|
+
|
|
14
|
+
from diffctx.main import main
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
diffctx/_diffctx.pyd
ADDED
|
Binary file
|
diffctx/cli.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
from .version import __version__
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_MAX_FILE_BYTES = 256 * 1024 # 256 KB
|
|
15
|
+
_DEFAULT_ALPHA = 0.60
|
|
16
|
+
_DEFAULT_TAU = 0.08
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Unset:
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return "<unset>"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_UNSET: _Unset = _Unset()
|
|
25
|
+
_DIFF_SENTINEL = "__DIFFCTX_DIFF_BARE__"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _exit_error(message: str) -> NoReturn:
|
|
29
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_max_depth(max_depth: int | None) -> None:
|
|
34
|
+
if max_depth is not None and max_depth < 0:
|
|
35
|
+
_exit_error(f"--max-depth must be non-negative, got {max_depth}")
|
|
36
|
+
if max_depth == 0:
|
|
37
|
+
print("Warning: --max-depth 0 produces empty tree (root only, no children)", file=sys.stderr)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validate_max_file_bytes(max_file_bytes: int, no_file_size_limit: bool) -> int | None:
|
|
41
|
+
if no_file_size_limit:
|
|
42
|
+
return None
|
|
43
|
+
if max_file_bytes < 0:
|
|
44
|
+
_exit_error(f"--max-file-bytes must be non-negative, got {max_file_bytes}")
|
|
45
|
+
if max_file_bytes == 0:
|
|
46
|
+
_exit_error("--max-file-bytes 0 is ambiguous. Use --no-file-size-limit to include all files regardless of size")
|
|
47
|
+
return max_file_bytes
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_budget(budget: int | None) -> None:
|
|
51
|
+
if budget is not None and budget < -1:
|
|
52
|
+
_exit_error(f"--budget must be >= -1 (0=auto, -1=unlimited), got {budget}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_alpha(alpha: float) -> None:
|
|
56
|
+
if not (0 < alpha < 1):
|
|
57
|
+
_exit_error(f"--alpha must be between 0 and 1 (exclusive), got {alpha}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_tau(tau: float) -> None:
|
|
61
|
+
if tau < 0:
|
|
62
|
+
_exit_error(f"--tau must be non-negative, got {tau}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_root_dir(directory: str) -> Path:
|
|
66
|
+
try:
|
|
67
|
+
root_dir = Path(directory).resolve(strict=True)
|
|
68
|
+
if not root_dir.is_dir():
|
|
69
|
+
_exit_error(f"'{root_dir}' is not a directory")
|
|
70
|
+
return root_dir
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
_exit_error(f"Directory '{directory}' does not exist")
|
|
73
|
+
except OSError as e:
|
|
74
|
+
_exit_error(f"Cannot access '{directory}': {e}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_glob_pattern(pattern: str) -> list[str]:
|
|
78
|
+
import glob as globmod
|
|
79
|
+
|
|
80
|
+
matches = sorted(globmod.glob(pattern, recursive=True))
|
|
81
|
+
if matches:
|
|
82
|
+
return matches
|
|
83
|
+
try:
|
|
84
|
+
p = Path(pattern).resolve(strict=True)
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
_exit_error(f"No matches for '{pattern}'")
|
|
87
|
+
except OSError as e:
|
|
88
|
+
_exit_error(f"Cannot access '{pattern}': {e}")
|
|
89
|
+
return [str(p)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _classify_resolved(resolved: Path, dirs: list[Path], files: list[Path]) -> None:
|
|
93
|
+
if resolved.is_dir():
|
|
94
|
+
dirs.append(resolved)
|
|
95
|
+
elif resolved.is_file():
|
|
96
|
+
files.append(resolved)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _expand_paths(raw_paths: list[str]) -> tuple[list[Path], list[Path]]:
|
|
100
|
+
dirs: list[Path] = []
|
|
101
|
+
files: list[Path] = []
|
|
102
|
+
seen: set[Path] = set()
|
|
103
|
+
for pattern in raw_paths:
|
|
104
|
+
for m in _resolve_glob_pattern(pattern):
|
|
105
|
+
try:
|
|
106
|
+
resolved = Path(m).resolve()
|
|
107
|
+
except OSError as e:
|
|
108
|
+
_exit_error(f"Cannot access '{m}': {e}")
|
|
109
|
+
if resolved in seen:
|
|
110
|
+
continue
|
|
111
|
+
seen.add(resolved)
|
|
112
|
+
_classify_resolved(resolved, dirs, files)
|
|
113
|
+
return dirs, files
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_output_file(output_file_arg: str | None, save: bool, output_format: str) -> tuple[Path | None, bool]:
|
|
117
|
+
if save and output_file_arg is not None:
|
|
118
|
+
_exit_error("--save and -o/--output-file are mutually exclusive")
|
|
119
|
+
|
|
120
|
+
if save:
|
|
121
|
+
ext = "yaml" if output_format == "yaml" else output_format
|
|
122
|
+
return Path(f"tree.{ext}").resolve(), False
|
|
123
|
+
|
|
124
|
+
if output_file_arg is None:
|
|
125
|
+
return None, False
|
|
126
|
+
if output_file_arg == "-":
|
|
127
|
+
return None, True
|
|
128
|
+
|
|
129
|
+
output_file = Path(output_file_arg).resolve()
|
|
130
|
+
if output_file.is_dir():
|
|
131
|
+
_exit_error(f"'{output_file_arg}' is a directory, not a file")
|
|
132
|
+
return output_file, False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _find_in_diffctx_dir(arg: str, root_dir: Path, extra_exts: tuple[str, ...]) -> Path | None:
|
|
136
|
+
if Path(arg).parent != Path("."):
|
|
137
|
+
return None
|
|
138
|
+
stem = Path(arg).stem if Path(arg).suffix else arg
|
|
139
|
+
base = root_dir / ".diffctx"
|
|
140
|
+
for name in (arg, *(f"{stem}{ext}" for ext in extra_exts if f"{stem}{ext}" != arg)):
|
|
141
|
+
candidate = base / name
|
|
142
|
+
if candidate.is_file():
|
|
143
|
+
return candidate
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _resolve_config_file(file_arg: str | None, root_dir: Path, extensions: tuple[str, ...], label: str) -> Path | None:
|
|
148
|
+
if not file_arg:
|
|
149
|
+
return None
|
|
150
|
+
found = _find_in_diffctx_dir(file_arg, root_dir, extensions)
|
|
151
|
+
if found:
|
|
152
|
+
return found
|
|
153
|
+
resolved = Path(file_arg).resolve()
|
|
154
|
+
if not resolved.is_file():
|
|
155
|
+
_exit_error(f"{label} file '{file_arg}' does not exist")
|
|
156
|
+
return resolved
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_ignore_file(ignore_file_arg: str | None, root_dir: Path) -> Path | None:
|
|
160
|
+
return _resolve_config_file(ignore_file_arg, root_dir, (".ignore", ".txt"), "Ignore")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve_whitelist_file(whitelist_file_arg: str | None, root_dir: Path) -> Path | None:
|
|
164
|
+
return _resolve_config_file(whitelist_file_arg, root_dir, (".whitelist", ".txt"), "Whitelist")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class GraphArgs:
|
|
169
|
+
format: str = "mermaid"
|
|
170
|
+
summary: bool = False
|
|
171
|
+
level: str = "directory"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class ParsedArgs:
|
|
176
|
+
root_dir: Path
|
|
177
|
+
ignore_file: Path | None
|
|
178
|
+
whitelist_file: Path | None
|
|
179
|
+
output_file: Path | None
|
|
180
|
+
no_default_ignores: bool
|
|
181
|
+
verbosity: int | str
|
|
182
|
+
output_format: str
|
|
183
|
+
max_depth: int | None
|
|
184
|
+
no_content: bool
|
|
185
|
+
max_file_bytes: int | None
|
|
186
|
+
copy: bool
|
|
187
|
+
force_stdout: bool
|
|
188
|
+
quiet: bool = False
|
|
189
|
+
diff_range: str | None = None
|
|
190
|
+
budget: int | None = None
|
|
191
|
+
alpha: float = _DEFAULT_ALPHA
|
|
192
|
+
tau: float = _DEFAULT_TAU
|
|
193
|
+
scoring: str = "ego"
|
|
194
|
+
full_diff: bool = False
|
|
195
|
+
command: str | None = None
|
|
196
|
+
graph: GraphArgs | None = None
|
|
197
|
+
extra_dirs: list[Path] | None = None
|
|
198
|
+
extra_files: list[Path] | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
DEFAULT_IGNORES_HELP = """
|
|
202
|
+
Default ignored patterns (use --no-default-ignores to disable built-in patterns;
|
|
203
|
+
project-level .gitignore and .diffctx/ignore still apply):
|
|
204
|
+
.git/, .svn/, .hg/ Version control directories
|
|
205
|
+
__pycache__/, *.py[cod], *.so, venv/, .venv/, .tox/, .nox/ Python
|
|
206
|
+
node_modules/, .npm/ JavaScript/Node
|
|
207
|
+
package-lock.json, yarn.lock, pnpm-lock.yaml JS lock files
|
|
208
|
+
Pipfile.lock, poetry.lock, Cargo.lock, Gemfile.lock Other lock files
|
|
209
|
+
target/, .gradle/ Java/Maven/Gradle
|
|
210
|
+
bin/, obj/ .NET
|
|
211
|
+
vendor/ Go/PHP
|
|
212
|
+
dist/, build/, out/ Generic build output
|
|
213
|
+
.*_cache/ All cache dirs (.pytest_cache, .mypy_cache, etc.)
|
|
214
|
+
.idea/, .vscode/ IDE configurations
|
|
215
|
+
.DS_Store, Thumbs.db OS-specific files
|
|
216
|
+
tree.{yaml,json,md,txt} Default output files (auto-ignored)
|
|
217
|
+
|
|
218
|
+
Ignore files (hierarchical, like git):
|
|
219
|
+
.gitignore Standard git ignore patterns
|
|
220
|
+
.diffctx/ignore diffctx-specific patterns
|
|
221
|
+
|
|
222
|
+
Whitelist files (auto-discovered):
|
|
223
|
+
.diffctx/whitelist Include-only filter
|
|
224
|
+
|
|
225
|
+
Examples:
|
|
226
|
+
diffctx . Map current directory to YAML
|
|
227
|
+
diffctx /path/to/project Map a specific directory
|
|
228
|
+
diffctx . -f json Output as JSON
|
|
229
|
+
diffctx . -f md --save Save as tree.md
|
|
230
|
+
diffctx . --diff HEAD~1 Show context for last commit
|
|
231
|
+
diffctx . -c Copy output to clipboard
|
|
232
|
+
diffctx . --no-content Structure only, no file contents
|
|
233
|
+
|
|
234
|
+
Output routing:
|
|
235
|
+
Default: stdout
|
|
236
|
+
-o FILE: write to FILE
|
|
237
|
+
--save: write to tree.{ext} (e.g., tree.yaml)
|
|
238
|
+
-c: copy to clipboard, suppress stdout
|
|
239
|
+
-c -o FILE: copy to clipboard AND write to FILE
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _build_shared_parser() -> argparse.ArgumentParser:
|
|
244
|
+
shared = argparse.ArgumentParser(add_help=False)
|
|
245
|
+
shared.add_argument("-o", "--output-file", default=None, help="Write output to FILE")
|
|
246
|
+
shared.add_argument("-i", "--ignore", default=None, help="Path to custom ignore file")
|
|
247
|
+
shared.add_argument("-w", "--whitelist", default=None, help="Path to whitelist file (only matching files are included)")
|
|
248
|
+
shared.add_argument(
|
|
249
|
+
"--no-default-ignores",
|
|
250
|
+
action="store_true",
|
|
251
|
+
help="Disable built-in ignore patterns (project .gitignore and .diffctx/ignore still apply)",
|
|
252
|
+
)
|
|
253
|
+
shared.add_argument("-c", "--copy", action="store_true", help="Copy to clipboard")
|
|
254
|
+
shared.add_argument("-q", "--quiet", action="store_true", help="Suppress all non-error output")
|
|
255
|
+
shared.add_argument(
|
|
256
|
+
"--log-level",
|
|
257
|
+
choices=["error", "warning", "info", "debug"],
|
|
258
|
+
default="error",
|
|
259
|
+
help="Log level (default: error)",
|
|
260
|
+
)
|
|
261
|
+
return shared
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _build_graph_parser(prog: str = "diffctx graph") -> argparse.ArgumentParser:
|
|
265
|
+
graph_parser = argparse.ArgumentParser(
|
|
266
|
+
prog=prog,
|
|
267
|
+
description="Build and analyze the project dependency graph",
|
|
268
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
269
|
+
parents=[_build_shared_parser()],
|
|
270
|
+
)
|
|
271
|
+
graph_parser.add_argument("directory", nargs="?", default=".", help="The directory to analyze")
|
|
272
|
+
graph_parser.add_argument(
|
|
273
|
+
"-f",
|
|
274
|
+
"--format",
|
|
275
|
+
choices=["mermaid", "json", "graphml"],
|
|
276
|
+
default="mermaid",
|
|
277
|
+
help="Graph output format (default: mermaid)",
|
|
278
|
+
)
|
|
279
|
+
graph_parser.add_argument(
|
|
280
|
+
"--summary", action="store_true", help="Print graph statistics (cycles, hotspots, coupling metrics)"
|
|
281
|
+
)
|
|
282
|
+
graph_parser.add_argument(
|
|
283
|
+
"--level",
|
|
284
|
+
choices=["fragment", "file", "directory"],
|
|
285
|
+
default="directory",
|
|
286
|
+
help="Granularity level for graph operations (default: directory)",
|
|
287
|
+
)
|
|
288
|
+
return graph_parser
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _build_main_parser(prog: str = "diffctx", version: str = __version__) -> argparse.ArgumentParser:
|
|
292
|
+
parser = argparse.ArgumentParser(
|
|
293
|
+
prog=prog,
|
|
294
|
+
description=(
|
|
295
|
+
"Generate a structured representation of a directory tree (YAML, JSON, text, or Markdown). "
|
|
296
|
+
"Supports diff context mode (--diff) for intelligent code change analysis.\n\n"
|
|
297
|
+
"Subcommands:\n"
|
|
298
|
+
" graph Build and analyze the project dependency graph"
|
|
299
|
+
),
|
|
300
|
+
epilog=DEFAULT_IGNORES_HELP,
|
|
301
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
302
|
+
parents=[_build_shared_parser()],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {version}")
|
|
306
|
+
parser.add_argument("paths", nargs="*", default=["."], help="Directories, files, or glob patterns to analyze")
|
|
307
|
+
parser.add_argument(
|
|
308
|
+
"-f",
|
|
309
|
+
"--format",
|
|
310
|
+
choices=["yaml", "json", "txt", "md"],
|
|
311
|
+
default="yaml",
|
|
312
|
+
help="Output format (default: yaml)",
|
|
313
|
+
)
|
|
314
|
+
parser.add_argument(
|
|
315
|
+
"--save",
|
|
316
|
+
action="store_true",
|
|
317
|
+
help="Save output to tree.{ext} (e.g., tree.yaml, tree.json)",
|
|
318
|
+
)
|
|
319
|
+
parser.add_argument("--max-depth", type=int, default=None, metavar="N", help="Maximum traversal depth")
|
|
320
|
+
parser.add_argument("--no-content", action="store_true", help="Skip file contents (structure only)")
|
|
321
|
+
parser.add_argument(
|
|
322
|
+
"--max-file-bytes",
|
|
323
|
+
type=int,
|
|
324
|
+
default=DEFAULT_MAX_FILE_BYTES,
|
|
325
|
+
metavar="N",
|
|
326
|
+
help=f"Truncate per-file content at N bytes (default: {DEFAULT_MAX_FILE_BYTES // 1024} KB). Use --no-file-size-limit to disable.",
|
|
327
|
+
)
|
|
328
|
+
parser.add_argument(
|
|
329
|
+
"--no-file-size-limit",
|
|
330
|
+
action="store_true",
|
|
331
|
+
help="Include all files regardless of size",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
diff_group = parser.add_argument_group("diff context mode")
|
|
335
|
+
diff_group.add_argument(
|
|
336
|
+
"--diff",
|
|
337
|
+
dest="diff_range",
|
|
338
|
+
nargs="?",
|
|
339
|
+
const=_DIFF_SENTINEL,
|
|
340
|
+
default=None,
|
|
341
|
+
metavar="RANGE",
|
|
342
|
+
help="Git diff range (e.g., HEAD~1..HEAD, main..feature). Bare --diff defaults to HEAD.",
|
|
343
|
+
)
|
|
344
|
+
diff_group.add_argument(
|
|
345
|
+
"--budget",
|
|
346
|
+
type=int,
|
|
347
|
+
default=_UNSET,
|
|
348
|
+
metavar="N",
|
|
349
|
+
help="Token budget: 0=auto (default), -1=unlimited, N=fixed budget",
|
|
350
|
+
)
|
|
351
|
+
diff_group.add_argument(
|
|
352
|
+
"--alpha",
|
|
353
|
+
type=float,
|
|
354
|
+
default=_UNSET,
|
|
355
|
+
metavar="F",
|
|
356
|
+
help="How tightly context clusters around changes, 0-1 (default: 0.60, higher = more focused)",
|
|
357
|
+
)
|
|
358
|
+
diff_group.add_argument(
|
|
359
|
+
"--tau",
|
|
360
|
+
type=float,
|
|
361
|
+
default=_UNSET,
|
|
362
|
+
metavar="F",
|
|
363
|
+
help="Minimum relevance to include a fragment (default: 0.08, lower = more context)",
|
|
364
|
+
)
|
|
365
|
+
diff_group.add_argument(
|
|
366
|
+
"--scoring",
|
|
367
|
+
choices=["ppr", "ego", "bm25"],
|
|
368
|
+
default=_UNSET,
|
|
369
|
+
help="Scoring mode: ego (bounded ego-network expansion, default), ppr (Personalized PageRank), bm25 (lexical fragment retrieval)",
|
|
370
|
+
)
|
|
371
|
+
diff_group.add_argument(
|
|
372
|
+
"--full",
|
|
373
|
+
action="store_true",
|
|
374
|
+
default=False,
|
|
375
|
+
help="Include all changed code (skip smart selection algorithm)",
|
|
376
|
+
)
|
|
377
|
+
return parser
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _warn_diff_only_flags(args: argparse.Namespace) -> None:
|
|
381
|
+
if args.diff_range:
|
|
382
|
+
return
|
|
383
|
+
used = []
|
|
384
|
+
if args.budget is not _UNSET:
|
|
385
|
+
used.append("--budget")
|
|
386
|
+
if args.alpha is not _UNSET:
|
|
387
|
+
used.append("--alpha")
|
|
388
|
+
if args.tau is not _UNSET:
|
|
389
|
+
used.append("--tau")
|
|
390
|
+
if args.full:
|
|
391
|
+
used.append("--full")
|
|
392
|
+
if args.scoring is not _UNSET:
|
|
393
|
+
used.append("--scoring")
|
|
394
|
+
if used:
|
|
395
|
+
flags = ", ".join(used)
|
|
396
|
+
print(f"Warning: diff-mode flags ignored without --diff: {flags}", file=sys.stderr)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _build_graph_parsed_args(args: argparse.Namespace) -> ParsedArgs:
|
|
400
|
+
root_dir = _resolve_root_dir(args.directory)
|
|
401
|
+
output_file_path = Path(args.output_file).resolve() if args.output_file else None
|
|
402
|
+
ignore_file = _resolve_ignore_file(args.ignore, root_dir)
|
|
403
|
+
whitelist_file = _resolve_whitelist_file(args.whitelist, root_dir)
|
|
404
|
+
verbosity = "error" if args.quiet else args.log_level
|
|
405
|
+
|
|
406
|
+
return ParsedArgs(
|
|
407
|
+
root_dir=root_dir,
|
|
408
|
+
ignore_file=ignore_file,
|
|
409
|
+
whitelist_file=whitelist_file,
|
|
410
|
+
output_file=output_file_path,
|
|
411
|
+
no_default_ignores=args.no_default_ignores,
|
|
412
|
+
verbosity=verbosity,
|
|
413
|
+
output_format="yaml",
|
|
414
|
+
max_depth=None,
|
|
415
|
+
no_content=False,
|
|
416
|
+
max_file_bytes=None,
|
|
417
|
+
copy=args.copy,
|
|
418
|
+
force_stdout=False,
|
|
419
|
+
quiet=args.quiet,
|
|
420
|
+
command="graph",
|
|
421
|
+
graph=GraphArgs(
|
|
422
|
+
format=args.format,
|
|
423
|
+
summary=args.summary,
|
|
424
|
+
level=args.level,
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _build_tree_parsed_args(args: argparse.Namespace) -> ParsedArgs:
|
|
430
|
+
_validate_max_depth(args.max_depth)
|
|
431
|
+
max_file_bytes = _validate_max_file_bytes(args.max_file_bytes, args.no_file_size_limit)
|
|
432
|
+
|
|
433
|
+
budget = None if args.budget is _UNSET else args.budget
|
|
434
|
+
alpha = _DEFAULT_ALPHA if args.alpha is _UNSET else args.alpha
|
|
435
|
+
tau = _DEFAULT_TAU if args.tau is _UNSET else args.tau
|
|
436
|
+
scoring = "ego" if args.scoring is _UNSET else args.scoring
|
|
437
|
+
|
|
438
|
+
_validate_budget(budget)
|
|
439
|
+
_validate_alpha(alpha)
|
|
440
|
+
_validate_tau(tau)
|
|
441
|
+
_warn_diff_only_flags(args)
|
|
442
|
+
|
|
443
|
+
diff_range = args.diff_range
|
|
444
|
+
if diff_range == _DIFF_SENTINEL:
|
|
445
|
+
diff_range = "HEAD"
|
|
446
|
+
|
|
447
|
+
dirs, files = _expand_paths(args.paths)
|
|
448
|
+
root_dir = dirs[0] if dirs else Path(".").resolve()
|
|
449
|
+
extra_dirs = dirs or None
|
|
450
|
+
extra_files = files or None
|
|
451
|
+
|
|
452
|
+
output_format = args.format
|
|
453
|
+
output_file, force_stdout = _resolve_output_file(args.output_file, args.save, output_format)
|
|
454
|
+
ignore_file = _resolve_ignore_file(args.ignore, root_dir)
|
|
455
|
+
whitelist_file = _resolve_whitelist_file(args.whitelist, root_dir)
|
|
456
|
+
verbosity = "error" if args.quiet else args.log_level
|
|
457
|
+
|
|
458
|
+
return ParsedArgs(
|
|
459
|
+
root_dir=root_dir,
|
|
460
|
+
ignore_file=ignore_file,
|
|
461
|
+
whitelist_file=whitelist_file,
|
|
462
|
+
output_file=output_file,
|
|
463
|
+
no_default_ignores=args.no_default_ignores,
|
|
464
|
+
verbosity=verbosity,
|
|
465
|
+
output_format=output_format,
|
|
466
|
+
max_depth=args.max_depth,
|
|
467
|
+
no_content=args.no_content,
|
|
468
|
+
max_file_bytes=max_file_bytes,
|
|
469
|
+
copy=args.copy,
|
|
470
|
+
force_stdout=force_stdout,
|
|
471
|
+
quiet=args.quiet,
|
|
472
|
+
diff_range=diff_range,
|
|
473
|
+
budget=budget,
|
|
474
|
+
alpha=alpha,
|
|
475
|
+
tau=tau,
|
|
476
|
+
scoring=scoring,
|
|
477
|
+
full_diff=args.full,
|
|
478
|
+
extra_dirs=extra_dirs,
|
|
479
|
+
extra_files=extra_files,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def parse_args(argv: list[str] | None = None, *, prog: str = "diffctx", version: str = __version__) -> ParsedArgs:
|
|
484
|
+
raw_args = sys.argv[1:] if argv is None else argv
|
|
485
|
+
|
|
486
|
+
if raw_args and raw_args[0] == "graph":
|
|
487
|
+
args = _build_graph_parser(prog=f"{prog} graph").parse_args(raw_args[1:])
|
|
488
|
+
return _build_graph_parsed_args(args)
|
|
489
|
+
|
|
490
|
+
args = _build_main_parser(prog=prog, version=version).parse_args(raw_args)
|
|
491
|
+
return _build_tree_parsed_args(args)
|
diffctx/clipboard.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClipboardError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _detect_darwin_clipboard() -> list[str] | None:
|
|
14
|
+
return ["pbcopy"] if shutil.which("pbcopy") else None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _detect_windows_clipboard() -> list[str] | None:
|
|
18
|
+
return ["clip"] if shutil.which("clip") else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _detect_linux_clipboard() -> list[str] | None:
|
|
22
|
+
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"):
|
|
23
|
+
return ["wl-copy", "--type", "text/plain"]
|
|
24
|
+
if os.environ.get("DISPLAY"):
|
|
25
|
+
if shutil.which("xclip"):
|
|
26
|
+
return ["xclip", "-selection", "clipboard"]
|
|
27
|
+
if shutil.which("xsel"):
|
|
28
|
+
return ["xsel", "--clipboard", "--input"]
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_CLIPBOARD_DETECTORS = {
|
|
33
|
+
"Darwin": _detect_darwin_clipboard,
|
|
34
|
+
"Windows": _detect_windows_clipboard,
|
|
35
|
+
"Linux": _detect_linux_clipboard,
|
|
36
|
+
"FreeBSD": _detect_linux_clipboard,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_INSTALL_HINTS = {
|
|
40
|
+
"Darwin": "pbcopy should be available by default on macOS",
|
|
41
|
+
"Windows": "clip.exe should be available by default on Windows",
|
|
42
|
+
"Linux": "Install wl-copy (Wayland) or xclip/xsel (X11): sudo apt install wl-clipboard or sudo apt install xclip",
|
|
43
|
+
"FreeBSD": "Install xclip or xsel: pkg install xclip",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect_clipboard_command() -> list[str] | None:
|
|
48
|
+
detector = _CLIPBOARD_DETECTORS.get(platform.system())
|
|
49
|
+
return detector() if detector else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def copy_to_clipboard(text: str) -> None:
|
|
53
|
+
cmd = detect_clipboard_command()
|
|
54
|
+
if cmd is None:
|
|
55
|
+
system = platform.system()
|
|
56
|
+
hint = _INSTALL_HINTS.get(system, f"No clipboard support for {system}")
|
|
57
|
+
raise ClipboardError(f"No clipboard tool found. {hint}")
|
|
58
|
+
|
|
59
|
+
encoding = "utf-16le" if platform.system() == "Windows" else "utf-8"
|
|
60
|
+
encoded = text.encode(encoding)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
subprocess.run(
|
|
64
|
+
cmd,
|
|
65
|
+
input=encoded,
|
|
66
|
+
stdout=subprocess.DEVNULL,
|
|
67
|
+
stderr=subprocess.PIPE,
|
|
68
|
+
timeout=5,
|
|
69
|
+
check=True,
|
|
70
|
+
)
|
|
71
|
+
except subprocess.TimeoutExpired as e:
|
|
72
|
+
raise ClipboardError("Clipboard operation timed out") from e
|
|
73
|
+
except subprocess.CalledProcessError as e:
|
|
74
|
+
stderr_msg = e.stderr.decode(errors="replace").strip() if e.stderr else ""
|
|
75
|
+
raise ClipboardError(stderr_msg or f"Command failed with code {e.returncode}") from e
|
|
76
|
+
except OSError as e:
|
|
77
|
+
raise ClipboardError(f"Failed to execute clipboard command: {e}") from e
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clipboard_available() -> bool:
|
|
81
|
+
return detect_clipboard_command() is not None
|