mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
(formatting) : format code with ruff formatter.
This commit is contained in:
@@ -1 +1 @@
|
||||
"""contextzip.ai — AI-powered file selection for --prompt mode."""
|
||||
"""contextzip.ai — AI-powered file selection for --prompt mode."""
|
||||
|
||||
+13
-14
@@ -14,10 +14,10 @@ Design principles:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
_HTTPX_AVAILABLE = True
|
||||
except ImportError:
|
||||
_HTTPX_AVAILABLE = False
|
||||
@@ -45,6 +45,7 @@ _TIMEOUT_SECONDS = 30
|
||||
# Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GeminiError(Exception):
|
||||
"""Raised when the Gemini API call fails for any reason."""
|
||||
|
||||
@@ -61,11 +62,12 @@ class GeminiRateLimitError(GeminiError):
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def select_files(
|
||||
*,
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
file_tree: list[tuple[str, int]], # [(rel_path, size_bytes), ...]
|
||||
file_tree: list[tuple[str, int]], # [(rel_path, size_bytes), ...]
|
||||
ecosystem: str,
|
||||
model: str = DEFAULT_MODEL,
|
||||
) -> list[str]:
|
||||
@@ -109,7 +111,7 @@ def select_files(
|
||||
)
|
||||
|
||||
system_prompt = _build_system_prompt()
|
||||
user_message = _build_user_message(prompt, file_tree, ecosystem)
|
||||
user_message = _build_user_message(prompt, file_tree, ecosystem)
|
||||
|
||||
url = _API_URL.format(model=model, key=api_key)
|
||||
|
||||
@@ -121,8 +123,8 @@ def select_files(
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"temperature": 0.0, # deterministic — this is a ranking task
|
||||
"maxOutputTokens": 512, # a list of paths needs very few tokens
|
||||
"temperature": 0.0, # deterministic — this is a ranking task
|
||||
"maxOutputTokens": 512, # a list of paths needs very few tokens
|
||||
"responseMimeType": "application/json",
|
||||
},
|
||||
}
|
||||
@@ -148,8 +150,7 @@ def select_files(
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise GeminiError(
|
||||
f"Gemini API returned HTTP {response.status_code}: "
|
||||
f"{response.text[:200]}"
|
||||
f"Gemini API returned HTTP {response.status_code}: {response.text[:200]}"
|
||||
)
|
||||
|
||||
return _parse_response(response.json(), file_tree)
|
||||
@@ -159,6 +160,7 @@ def select_files(
|
||||
# Prompt construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_system_prompt() -> str:
|
||||
return """\
|
||||
You are a precise file relevance assistant for software projects.
|
||||
@@ -186,10 +188,7 @@ def _build_user_message(
|
||||
file_tree: list[tuple[str, int]],
|
||||
ecosystem: str,
|
||||
) -> str:
|
||||
tree_lines = "\n".join(
|
||||
f"{path} ({_human_size(size)})"
|
||||
for path, size in file_tree
|
||||
)
|
||||
tree_lines = "\n".join(f"{path} ({_human_size(size)})" for path, size in file_tree)
|
||||
return f"""\
|
||||
Framework: {ecosystem}
|
||||
|
||||
@@ -206,6 +205,7 @@ Return only a JSON array of the most relevant file paths for this task.\
|
||||
# Response parsing and validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_response(
|
||||
data: dict,
|
||||
file_tree: list[tuple[str, int]],
|
||||
@@ -220,9 +220,7 @@ def _parse_response(
|
||||
"""
|
||||
# Navigate the Gemini response structure
|
||||
try:
|
||||
text = (
|
||||
data["candidates"][0]["content"]["parts"][0]["text"]
|
||||
)
|
||||
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
||||
except (KeyError, IndexError) as exc:
|
||||
raise GeminiError(f"Unexpected API response structure: {exc}\n{data}")
|
||||
|
||||
@@ -275,6 +273,7 @@ def _parse_response(
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _human_size(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB"):
|
||||
if n < 1024:
|
||||
|
||||
+149
-30
@@ -36,50 +36,167 @@ from pathlib import Path
|
||||
MAX_FILES = 8
|
||||
|
||||
# Words that carry no signal — filtered out before scoring
|
||||
_STOPWORDS = frozenset({
|
||||
"i", "a", "an", "the", "to", "in", "on", "at", "of", "for",
|
||||
"and", "or", "but", "is", "it", "be", "do", "my", "this",
|
||||
"that", "with", "from", "want", "need", "make", "update",
|
||||
"change", "fix", "add", "get", "use", "set", "new", "old",
|
||||
"file", "code", "function", "method", "class", "variable",
|
||||
})
|
||||
_STOPWORDS = frozenset(
|
||||
{
|
||||
"i",
|
||||
"a",
|
||||
"an",
|
||||
"the",
|
||||
"to",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"of",
|
||||
"for",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"is",
|
||||
"it",
|
||||
"be",
|
||||
"do",
|
||||
"my",
|
||||
"this",
|
||||
"that",
|
||||
"with",
|
||||
"from",
|
||||
"want",
|
||||
"need",
|
||||
"make",
|
||||
"update",
|
||||
"change",
|
||||
"fix",
|
||||
"add",
|
||||
"get",
|
||||
"use",
|
||||
"set",
|
||||
"new",
|
||||
"old",
|
||||
"file",
|
||||
"code",
|
||||
"function",
|
||||
"method",
|
||||
"class",
|
||||
"variable",
|
||||
}
|
||||
)
|
||||
|
||||
# Directory names that suggest source code worth including
|
||||
_SOURCE_DIRS = frozenset({
|
||||
"src", "app", "lib", "libs", "components", "component",
|
||||
"utils", "util", "helpers", "helper", "routes", "route",
|
||||
"pages", "page", "server", "api", "services", "service",
|
||||
"hooks", "hook", "store", "stores", "context", "contexts",
|
||||
"middleware", "handlers", "handler", "controllers", "controller",
|
||||
"views", "view", "models", "model", "core", "common", "shared",
|
||||
})
|
||||
_SOURCE_DIRS = frozenset(
|
||||
{
|
||||
"src",
|
||||
"app",
|
||||
"lib",
|
||||
"libs",
|
||||
"components",
|
||||
"component",
|
||||
"utils",
|
||||
"util",
|
||||
"helpers",
|
||||
"helper",
|
||||
"routes",
|
||||
"route",
|
||||
"pages",
|
||||
"page",
|
||||
"server",
|
||||
"api",
|
||||
"services",
|
||||
"service",
|
||||
"hooks",
|
||||
"hook",
|
||||
"store",
|
||||
"stores",
|
||||
"context",
|
||||
"contexts",
|
||||
"middleware",
|
||||
"handlers",
|
||||
"handler",
|
||||
"controllers",
|
||||
"controller",
|
||||
"views",
|
||||
"view",
|
||||
"models",
|
||||
"model",
|
||||
"core",
|
||||
"common",
|
||||
"shared",
|
||||
}
|
||||
)
|
||||
|
||||
# Directory names that suggest low relevance for most coding tasks
|
||||
_NOISE_DIRS = frozenset({
|
||||
"test", "tests", "__tests__", "spec", "specs",
|
||||
"docs", "doc", "documentation", "examples", "example",
|
||||
"fixtures", "mocks", "mock", "stubs", "stub",
|
||||
"scripts", "bin", "dist", "build", "out", "coverage",
|
||||
})
|
||||
_NOISE_DIRS = frozenset(
|
||||
{
|
||||
"test",
|
||||
"tests",
|
||||
"__tests__",
|
||||
"spec",
|
||||
"specs",
|
||||
"docs",
|
||||
"doc",
|
||||
"documentation",
|
||||
"examples",
|
||||
"example",
|
||||
"fixtures",
|
||||
"mocks",
|
||||
"mock",
|
||||
"stubs",
|
||||
"stub",
|
||||
"scripts",
|
||||
"bin",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
"coverage",
|
||||
}
|
||||
)
|
||||
|
||||
# Extensions that suggest runnable/editable source code
|
||||
_CODE_EXTENSIONS = frozenset({
|
||||
".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".rb",
|
||||
".java", ".kt", ".swift", ".cs", ".cpp", ".c", ".h",
|
||||
".vue", ".svelte", ".astro",
|
||||
})
|
||||
_CODE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".py",
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".go",
|
||||
".rs",
|
||||
".rb",
|
||||
".java",
|
||||
".kt",
|
||||
".swift",
|
||||
".cs",
|
||||
".cpp",
|
||||
".c",
|
||||
".h",
|
||||
".vue",
|
||||
".svelte",
|
||||
".astro",
|
||||
}
|
||||
)
|
||||
|
||||
# Extensions that are config-like (lower relevance unless prompt mentions them)
|
||||
_CONFIG_EXTENSIONS = frozenset({
|
||||
".json", ".toml", ".yaml", ".yml", ".ini", ".cfg", ".env",
|
||||
".md", ".txt", ".lock", ".sum",
|
||||
})
|
||||
_CONFIG_EXTENSIONS = frozenset(
|
||||
{
|
||||
".json",
|
||||
".toml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".ini",
|
||||
".cfg",
|
||||
".env",
|
||||
".md",
|
||||
".txt",
|
||||
".lock",
|
||||
".sum",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def select_files(
|
||||
*,
|
||||
prompt: str,
|
||||
@@ -124,6 +241,7 @@ def select_files(
|
||||
# Scoring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _score(rel_path: str, tokens: set[str], size_bytes: int) -> float:
|
||||
"""Compute a relevance score for a single file path."""
|
||||
path = Path(rel_path)
|
||||
@@ -183,6 +301,7 @@ def _score(rel_path: str, tokens: set[str], size_bytes: int) -> float:
|
||||
# Text helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
"""
|
||||
Extract meaningful lowercase tokens from *text*.
|
||||
|
||||
@@ -12,11 +12,11 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from contextzip.ai.gemini import select_files, GeminiError, GeminiRateLimitError
|
||||
from contextzip.ai.gemini import select_files, GeminiRateLimitError
|
||||
from contextzip.filters import ResolveResult
|
||||
|
||||
# Sentinels — tell the CLI which selection path was taken
|
||||
USED_GEMINI = "gemini"
|
||||
USED_GEMINI = "gemini"
|
||||
USED_HEURISTIC = "heuristic"
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ USED_HEURISTIC = "heuristic"
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ai_select(
|
||||
*,
|
||||
resolved: ResolveResult,
|
||||
@@ -66,14 +67,9 @@ def ai_select(
|
||||
|
||||
# Map relative path strings back to absolute Path objects
|
||||
rel_to_abs: dict[str, Path] = {
|
||||
p.relative_to(project_dir).as_posix(): p
|
||||
for p in resolved.included
|
||||
p.relative_to(project_dir).as_posix(): p for p in resolved.included
|
||||
}
|
||||
selected_paths = [
|
||||
rel_to_abs[rel]
|
||||
for rel in selected_rel
|
||||
if rel in rel_to_abs
|
||||
]
|
||||
selected_paths = [rel_to_abs[rel] for rel in selected_rel if rel in rel_to_abs]
|
||||
|
||||
prompt_txt = _build_prompt_txt(prompt, selected_rel, ecosystem, method)
|
||||
|
||||
@@ -89,6 +85,7 @@ def build_prompt_only_txt(prompt: str, ecosystem: str) -> str:
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_file_tree(
|
||||
included: list[Path],
|
||||
project_dir: Path,
|
||||
@@ -128,7 +125,6 @@ def _build_prompt_txt(
|
||||
Any AI tool that receives the ZIP immediately sees the task description,
|
||||
the framework, and exactly which files were selected and why.
|
||||
"""
|
||||
from contextzip import __version__
|
||||
|
||||
selector_label = (
|
||||
"contextzip AI (Gemini)"
|
||||
|
||||
+117
-71
@@ -37,7 +37,13 @@ from rich.table import Table
|
||||
from rich import box
|
||||
|
||||
from contextzip import __version__
|
||||
from contextzip.config import get_api_key, save_api_key, delete_api_key, config_path, diagnose_api_key
|
||||
from contextzip.config import (
|
||||
get_api_key,
|
||||
save_api_key,
|
||||
delete_api_key,
|
||||
config_path,
|
||||
diagnose_api_key,
|
||||
)
|
||||
from contextzip.detector import detect
|
||||
from contextzip.filters import (
|
||||
build_spec,
|
||||
@@ -59,6 +65,7 @@ console = Console()
|
||||
# Defined once so the main command and every subcommand declare exactly the
|
||||
# same flags without duplicating help strings.
|
||||
|
||||
|
||||
def _modifier_options(f):
|
||||
"""
|
||||
Attach all run-modifier flags to a command.
|
||||
@@ -69,8 +76,10 @@ def _modifier_options(f):
|
||||
"""
|
||||
decorators = [
|
||||
click.option(
|
||||
"--prompt", "-p",
|
||||
default=None, metavar="TEXT",
|
||||
"--prompt",
|
||||
"-p",
|
||||
default=None,
|
||||
metavar="TEXT",
|
||||
help=(
|
||||
"Describe your task in natural language. contextzip uses Gemini AI "
|
||||
"to select only the files relevant to that task. "
|
||||
@@ -78,36 +87,45 @@ def _modifier_options(f):
|
||||
),
|
||||
),
|
||||
click.option(
|
||||
"--dry-run", "-n",
|
||||
is_flag=True, default=False,
|
||||
"--dry-run",
|
||||
"-n",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Show what would be included without creating the ZIP.",
|
||||
),
|
||||
click.option(
|
||||
"--output", "-o",
|
||||
default=None, metavar="FILE",
|
||||
"--output",
|
||||
"-o",
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="Output ZIP path. Bypasses .contextzip/ workspace — writes directly to FILE.",
|
||||
),
|
||||
click.option(
|
||||
"--no-clipboard",
|
||||
is_flag=True, default=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip clipboard / folder-open step after creating the ZIP.",
|
||||
),
|
||||
click.option(
|
||||
"--no-gitignore",
|
||||
is_flag=True, default=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Ignore the project's .gitignore file (use only built-in rules).",
|
||||
),
|
||||
click.option(
|
||||
"--git-changes",
|
||||
is_flag=True, default=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Only include files that git reports as modified, added, or untracked. "
|
||||
"Requires the project to be inside a git repository."
|
||||
),
|
||||
),
|
||||
click.option(
|
||||
"--verbose", "-v",
|
||||
is_flag=True, default=False,
|
||||
"--verbose",
|
||||
"-v",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Show every included and excluded file.",
|
||||
),
|
||||
]
|
||||
@@ -120,21 +138,26 @@ def _modifier_options(f):
|
||||
# CLI group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@click.group(
|
||||
invoke_without_command=True,
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
)
|
||||
@click.option(
|
||||
"--include", "-i",
|
||||
multiple=True, metavar="PATH",
|
||||
"--include",
|
||||
"-i",
|
||||
multiple=True,
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Only include files under these paths (relative to project root). "
|
||||
"Repeatable: --include src --include app | or use: contextzip include src app"
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--exclude", "-e",
|
||||
multiple=True, metavar="PATTERN",
|
||||
"--exclude",
|
||||
"-e",
|
||||
multiple=True,
|
||||
metavar="PATTERN",
|
||||
help=(
|
||||
"Extra exclusion patterns on top of auto-rules (gitignore syntax). "
|
||||
"Repeatable: -e '*.log' -e CHANGELOG.md | or use: contextzip exclude CHANGELOG.md *.log"
|
||||
@@ -191,6 +214,7 @@ def main(
|
||||
# Subcommand: exclude
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@main.command("exclude")
|
||||
@click.argument("patterns", nargs=-1, required=True, metavar="PATTERN…")
|
||||
@_modifier_options
|
||||
@@ -234,6 +258,7 @@ def cmd_exclude(
|
||||
# Subcommand: include
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@main.command("include")
|
||||
@click.argument("paths", nargs=-1, required=True, metavar="PATH…")
|
||||
@_modifier_options
|
||||
@@ -276,6 +301,7 @@ def cmd_include(
|
||||
# Core execution logic (shared by main command + all subcommands)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(
|
||||
*,
|
||||
extra_exclude: list[str] | None,
|
||||
@@ -301,7 +327,8 @@ def _run(
|
||||
Panel.fit(
|
||||
f"[bold cyan]contextzip[/] [dim]v{__version__}[/]\n"
|
||||
f"[dim]Project:[/] [white]{project_dir}[/]",
|
||||
border_style="cyan", padding=(0, 2),
|
||||
border_style="cyan",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
@@ -321,7 +348,8 @@ def _run(
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[red]Git error:[/] {git_result.message}",
|
||||
border_style="red", padding=(0, 2),
|
||||
border_style="red",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
raise SystemExit(1)
|
||||
@@ -345,14 +373,11 @@ def _run(
|
||||
# ── Build exclusion spec ─────────────────────────────────────────────
|
||||
gitignore_path = None if no_gitignore else (project_dir / ".gitignore")
|
||||
used_gitignore = (
|
||||
not no_gitignore
|
||||
and gitignore_path is not None
|
||||
and gitignore_path.is_file()
|
||||
not no_gitignore and gitignore_path is not None and gitignore_path.is_file()
|
||||
)
|
||||
|
||||
normalized_exclude = (
|
||||
[_normalize_pattern(p) for p in extra_exclude]
|
||||
if extra_exclude else []
|
||||
[_normalize_pattern(p) for p in extra_exclude] if extra_exclude else []
|
||||
)
|
||||
|
||||
with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"):
|
||||
@@ -363,7 +388,7 @@ def _run(
|
||||
)
|
||||
|
||||
if used_gitignore:
|
||||
console.print(f" [dim]↳ .gitignore patterns applied[/]")
|
||||
console.print("[dim]↳ .gitignore patterns applied[/]")
|
||||
console.print()
|
||||
|
||||
# ── Resolve files ─────────────────────────────────────────────────────
|
||||
@@ -387,7 +412,9 @@ def _run(
|
||||
)
|
||||
for p, size in resolved.large_files[:5]:
|
||||
rel = p.relative_to(project_dir).as_posix()
|
||||
console.print(f" [yellow]·[/] [dim]{rel}[/] [yellow]{_human_size(size)}[/]")
|
||||
console.print(
|
||||
f" [yellow]·[/] [dim]{rel}[/] [yellow]{_human_size(size)}[/]"
|
||||
)
|
||||
if len(resolved.large_files) > 5:
|
||||
console.print(f" [dim]… and {len(resolved.large_files) - 5} more[/]")
|
||||
console.print(
|
||||
@@ -446,7 +473,8 @@ def _run(
|
||||
Panel.fit(
|
||||
"[yellow]Dry run — no ZIP created.[/]\n"
|
||||
"[dim]Remove --dry-run to produce the archive.[/]",
|
||||
border_style="yellow", padding=(0, 2),
|
||||
border_style="yellow",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -528,7 +556,6 @@ def _run(
|
||||
_print_clipboard_result(cb)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API key onboarding
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -546,6 +573,7 @@ def _open_browser_silent(url: str) -> None:
|
||||
to /dev/null to keep the terminal clean.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["xdg-open", url],
|
||||
@@ -590,9 +618,7 @@ def _onboard_api_key() -> str | None:
|
||||
)
|
||||
if open_browser:
|
||||
_open_browser_silent(_GEMINI_KEY_URL)
|
||||
console.print(
|
||||
" [dim]Browser opened. Generate a key, then come back here.[/]"
|
||||
)
|
||||
console.print(" [dim]Browser opened. Generate a key, then come back here.[/]")
|
||||
|
||||
console.print()
|
||||
console.print(" [dim]─────────────────────────────────────────────[/]")
|
||||
@@ -642,6 +668,7 @@ def _onboard_api_key() -> str | None:
|
||||
# AI selection helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_ai_selection(
|
||||
*,
|
||||
resolved,
|
||||
@@ -674,7 +701,8 @@ def _run_ai_selection(
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[red]Gemini error:[/] {exc}",
|
||||
border_style="red", padding=(0, 2),
|
||||
border_style="red",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
raise SystemExit(1)
|
||||
@@ -720,7 +748,8 @@ def _run_ai_selection_preview(
|
||||
console.print(
|
||||
Panel.fit(
|
||||
f"[red]Gemini error:[/] {exc}",
|
||||
border_style="red", padding=(0, 2),
|
||||
border_style="red",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
raise SystemExit(1)
|
||||
@@ -737,7 +766,8 @@ def _run_ai_selection_preview(
|
||||
Panel.fit(
|
||||
"[yellow]Dry run — no ZIP created.[/]\n"
|
||||
"[dim]Remove --dry-run to package these files.[/]",
|
||||
border_style="yellow", padding=(0, 2),
|
||||
border_style="yellow",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -746,15 +776,18 @@ def _run_ai_selection_preview(
|
||||
# Subcommand: config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@main.command("config")
|
||||
@click.option(
|
||||
"--reset-key",
|
||||
is_flag=True, default=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Clear the stored Gemini API key and re-run the setup prompt.",
|
||||
)
|
||||
@click.option(
|
||||
"--show-key-path",
|
||||
is_flag=True, default=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print the path to the config file and exit.",
|
||||
)
|
||||
def cmd_config(reset_key: bool, show_key_path: bool) -> None:
|
||||
@@ -774,8 +807,7 @@ def cmd_config(reset_key: bool, show_key_path: bool) -> None:
|
||||
removed = delete_api_key()
|
||||
if removed:
|
||||
console.print(
|
||||
"\n [green]✓[/] API key removed from "
|
||||
f"[dim]{config_path()}[/]"
|
||||
f"\n [green]✓[/] API key removed from [dim]{config_path()}[/]"
|
||||
)
|
||||
else:
|
||||
console.print("\n [dim]No API key was stored.[/]")
|
||||
@@ -795,23 +827,29 @@ def cmd_config(reset_key: bool, show_key_path: bool) -> None:
|
||||
|
||||
if key:
|
||||
masked = key[:8] + "…" + key[-4:] if len(key) > 12 else "****"
|
||||
source = "[dim](from environment variable)[/]" if from_env else f"[dim]({config_path()})[/]"
|
||||
source = (
|
||||
"[dim](from environment variable)[/]"
|
||||
if from_env
|
||||
else f"[dim]({config_path()})[/]"
|
||||
)
|
||||
console.print(
|
||||
Panel(
|
||||
f" [dim]Gemini API key:[/] [green]{masked}[/] {source}\n\n"
|
||||
" [dim]Run [cyan]contextzip config --reset-key[/] to change it.[/]",
|
||||
title="[bold]contextzip config[/]",
|
||||
border_style="cyan", padding=(0, 2),
|
||||
border_style="cyan",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
Panel(
|
||||
" No Gemini API key configured.\n\n"
|
||||
" Run [cyan]contextzip --prompt \"your task\"[/] to set one up,\n"
|
||||
' Run [cyan]contextzip --prompt "your task"[/] to set one up,\n'
|
||||
" or [cyan]contextzip config --reset-key[/] to go through setup now.",
|
||||
title="[bold]contextzip config[/]",
|
||||
border_style="yellow", padding=(0, 2),
|
||||
border_style="yellow",
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
@@ -821,6 +859,7 @@ def cmd_config(reset_key: bool, show_key_path: bool) -> None:
|
||||
# Normalisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _normalize_pattern(p: str) -> str:
|
||||
"""
|
||||
Canonicalise a user-supplied exclusion pattern.
|
||||
@@ -848,20 +887,22 @@ def _normalize_pattern(p: str) -> str:
|
||||
# Display helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _print_detection(detection) -> None:
|
||||
if detection.is_unknown:
|
||||
ecosystem_line = "[yellow]Unknown[/] — applying base rules only"
|
||||
else:
|
||||
colours = {
|
||||
"Next.js": "bright_blue", "Node.js": "green",
|
||||
"Python": "yellow", "Django": "green",
|
||||
"FastAPI": "cyan", "Rust": "red",
|
||||
"Go": "cyan", "Ruby": "red",
|
||||
"Next.js": "bright_blue",
|
||||
"Node.js": "green",
|
||||
"Python": "yellow",
|
||||
"Django": "green",
|
||||
"FastAPI": "cyan",
|
||||
"Rust": "red",
|
||||
"Go": "cyan",
|
||||
"Ruby": "red",
|
||||
}
|
||||
parts = [
|
||||
f"[{colours.get(n, 'white')}]{n}[/]"
|
||||
for n in detection.ecosystems
|
||||
]
|
||||
parts = [f"[{colours.get(n, 'white')}]{n}[/]" for n in detection.ecosystems]
|
||||
ecosystem_line = " [dim]+[/] ".join(parts)
|
||||
|
||||
conf_colour = {"high": "green", "medium": "yellow", "low": "dim"}.get(
|
||||
@@ -873,7 +914,8 @@ def _print_detection(detection) -> None:
|
||||
f" [dim]Confidence:[/] [{conf_colour}]{detection.confidence}[/]\n"
|
||||
f" [dim]Rules :[/] [dim]{', '.join(detection.rule_modules)}[/]",
|
||||
title="[bold]Detection[/]",
|
||||
border_style="blue", padding=(0, 1),
|
||||
border_style="blue",
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
@@ -884,8 +926,8 @@ def _print_git_summary(changes: GitChanges, project_dir: Path, verbose: bool) ->
|
||||
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
|
||||
table.add_column(style="dim")
|
||||
table.add_column()
|
||||
table.add_row("Staged", f"[green]{len(changes.staged)}[/]")
|
||||
table.add_row("Unstaged", f"[yellow]{len(changes.unstaged)}[/]")
|
||||
table.add_row("Staged", f"[green]{len(changes.staged)}[/]")
|
||||
table.add_row("Unstaged", f"[yellow]{len(changes.unstaged)}[/]")
|
||||
table.add_row("Untracked", f"[cyan]{len(changes.untracked)}[/]")
|
||||
if changes.deleted:
|
||||
table.add_row("Deleted (skipped)", f"[dim]{len(changes.deleted)}[/]")
|
||||
@@ -897,7 +939,8 @@ def _print_git_summary(changes: GitChanges, project_dir: Path, verbose: bool) ->
|
||||
Panel(
|
||||
table,
|
||||
title="[bold]Git Changes[/]",
|
||||
border_style="magenta", padding=(0, 1),
|
||||
border_style="magenta",
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
@@ -905,8 +948,8 @@ def _print_git_summary(changes: GitChanges, project_dir: Path, verbose: bool) ->
|
||||
if verbose and changes.files:
|
||||
console.print("[bold]Git-changed files:[/]")
|
||||
for category, paths, colour in (
|
||||
("Staged", changes.staged, "green"),
|
||||
("Unstaged", changes.unstaged, "yellow"),
|
||||
("Staged", changes.staged, "green"),
|
||||
("Unstaged", changes.unstaged, "yellow"),
|
||||
("Untracked", changes.untracked, "cyan"),
|
||||
):
|
||||
for rel in paths:
|
||||
@@ -921,7 +964,7 @@ def _print_scan_summary(
|
||||
*,
|
||||
git_mode: bool = False,
|
||||
) -> None:
|
||||
total = len(resolved.included) + len(resolved.excluded)
|
||||
total = len(resolved.included) + len(resolved.excluded)
|
||||
included_size = sum(p.stat().st_size for p in resolved.included if p.exists())
|
||||
|
||||
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
|
||||
@@ -929,15 +972,15 @@ def _print_scan_summary(
|
||||
table.add_column()
|
||||
|
||||
if not git_mode:
|
||||
table.add_row("Files scanned", str(total + len(resolved.skipped)))
|
||||
table.add_row("Files scanned", str(total + len(resolved.skipped)))
|
||||
table.add_row(
|
||||
"To be included",
|
||||
f"[green]{len(resolved.included)}[/] [dim]({_human_size(included_size)})[/]",
|
||||
)
|
||||
if not git_mode:
|
||||
table.add_row("Excluded", f"[red]{len(resolved.excluded)}[/]")
|
||||
table.add_row("Excluded", f"[red]{len(resolved.excluded)}[/]")
|
||||
if resolved.skipped:
|
||||
table.add_row("Skipped", f"[yellow]{len(resolved.skipped)}[/]")
|
||||
table.add_row("Skipped", f"[yellow]{len(resolved.skipped)}[/]")
|
||||
console.print(table)
|
||||
|
||||
if verbose and resolved.included:
|
||||
@@ -945,7 +988,7 @@ def _print_scan_summary(
|
||||
console.print("[bold]Included files:[/]")
|
||||
for p in resolved.included:
|
||||
size_str = _human_size(p.stat().st_size) if p.exists() else "?"
|
||||
rel = p.relative_to(project_dir).as_posix()
|
||||
rel = p.relative_to(project_dir).as_posix()
|
||||
console.print(f" [green]✓[/] {rel} [dim]{size_str}[/]")
|
||||
|
||||
if not git_mode and resolved.excluded:
|
||||
@@ -961,7 +1004,7 @@ def _print_scan_summary(
|
||||
|
||||
def _print_package_result(result) -> None:
|
||||
ratio_colour = "green" if result.compression_ratio >= 0.3 else "yellow"
|
||||
size_detail = (
|
||||
size_detail = (
|
||||
"[dim](ZIP overhead on tiny project)[/]"
|
||||
if result.grew
|
||||
else f"[{ratio_colour}](↓ {result.compression_pct} smaller)[/]"
|
||||
@@ -970,19 +1013,20 @@ def _print_package_result(result) -> None:
|
||||
table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
|
||||
table.add_column(style="dim", min_width=18)
|
||||
table.add_column()
|
||||
table.add_row("Files packed", f"[green]{result.file_count}[/]")
|
||||
table.add_row("Original size", _human_size(result.uncompressed_bytes))
|
||||
table.add_row("Files packed", f"[green]{result.file_count}[/]")
|
||||
table.add_row("Original size", _human_size(result.uncompressed_bytes))
|
||||
table.add_row(
|
||||
"Compressed size",
|
||||
f"[bold]{_human_size(result.compressed_bytes)}[/] {size_detail}",
|
||||
)
|
||||
table.add_row("Saved to", f"[cyan]{result.zip_path}[/]")
|
||||
table.add_row("Saved to", f"[cyan]{result.zip_path}[/]")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
table,
|
||||
title="[bold green]✓ ZIP created[/]",
|
||||
border_style="green", padding=(0, 1),
|
||||
border_style="green",
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1009,8 +1053,9 @@ def _print_ai_selection(
|
||||
console.print(
|
||||
Panel(
|
||||
table,
|
||||
title=f"[bold cyan]AI Selected — [dim]\"{prompt}\"[/][/]",
|
||||
border_style="cyan", padding=(0, 1),
|
||||
title=f'[bold cyan]AI Selected — [dim]"{prompt}"[/][/]',
|
||||
border_style="cyan",
|
||||
padding=(0, 1),
|
||||
)
|
||||
)
|
||||
console.print(
|
||||
@@ -1022,16 +1067,17 @@ def _print_ai_selection(
|
||||
|
||||
def _print_clipboard_result(cb) -> None:
|
||||
tier_style = {
|
||||
Tier.FILE_ON_CLIPBOARD: ("green", "✓ Ready to paste"),
|
||||
Tier.FOLDER_OPENED: ("yellow", "✓ Folder opened"),
|
||||
Tier.PATH_ONLY: ("dim", "↳ Manual copy needed"),
|
||||
Tier.FILE_ON_CLIPBOARD: ("green", "✓ Ready to paste"),
|
||||
Tier.FOLDER_OPENED: ("yellow", "✓ Folder opened"),
|
||||
Tier.PATH_ONLY: ("dim", "↳ Manual copy needed"),
|
||||
}
|
||||
border, title = tier_style.get(cb.tier, ("dim", "Clipboard"))
|
||||
console.print(
|
||||
Panel.fit(
|
||||
cb.message,
|
||||
title=f"[bold {border}]{title}[/]",
|
||||
border_style=border, padding=(0, 2),
|
||||
border_style=border,
|
||||
padding=(0, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+15
-10
@@ -33,16 +33,17 @@ from pathlib import Path
|
||||
# Result model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Tier(Enum):
|
||||
FILE_ON_CLIPBOARD = 1 # actual file object on clipboard — paste into browser
|
||||
FOLDER_OPENED = 2 # folder opened / file highlighted in file manager
|
||||
PATH_ONLY = 3 # nothing automatic; path printed for manual copy
|
||||
FILE_ON_CLIPBOARD = 1 # actual file object on clipboard — paste into browser
|
||||
FOLDER_OPENED = 2 # folder opened / file highlighted in file manager
|
||||
PATH_ONLY = 3 # nothing automatic; path printed for manual copy
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClipboardResult:
|
||||
tier: Tier
|
||||
message: str # human-readable outcome line
|
||||
message: str # human-readable outcome line
|
||||
success: bool = True # False only on unexpected errors worth surfacing
|
||||
|
||||
|
||||
@@ -50,6 +51,7 @@ class ClipboardResult:
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def handle(zip_path: Path) -> ClipboardResult:
|
||||
"""
|
||||
Attempt to put *zip_path* on the clipboard using the best available tier.
|
||||
@@ -89,6 +91,7 @@ def handle(zip_path: Path) -> ClipboardResult:
|
||||
# Tier 1 — file object on clipboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tier1_macos(zip_path: Path) -> ClipboardResult | None:
|
||||
"""
|
||||
Use osascript to ask Finder to copy the file to the clipboard.
|
||||
@@ -103,7 +106,9 @@ def _tier1_macos(zip_path: Path) -> ClipboardResult | None:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return ClipboardResult(
|
||||
@@ -145,12 +150,14 @@ def _tier1_linux(zip_path: Path) -> ClipboardResult | None:
|
||||
# Tier 2 — open folder / highlight file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tier2_macos(zip_path: Path) -> ClipboardResult | None:
|
||||
"""open -R reveals and selects the file in Finder."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["open", "-R", str(zip_path)],
|
||||
capture_output=True, timeout=6,
|
||||
capture_output=True,
|
||||
timeout=6,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return ClipboardResult(
|
||||
@@ -218,11 +225,9 @@ def _tier2_windows(zip_path: Path) -> ClipboardResult | None:
|
||||
# Tier 3 — path only (always succeeds)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tier3(zip_path: Path) -> ClipboardResult:
|
||||
return ClipboardResult(
|
||||
tier=Tier.PATH_ONLY,
|
||||
message=(
|
||||
f"📄 Copy this path and open it manually:\n"
|
||||
f" [cyan]{zip_path}[/]"
|
||||
),
|
||||
message=(f"📄 Copy this path and open it manually:\n [cyan]{zip_path}[/]"),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
# Config directory resolution (no platformdirs dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _config_dir() -> Path:
|
||||
"""
|
||||
Return the platform-appropriate config directory for contextzip.
|
||||
@@ -47,6 +48,7 @@ _CONFIG_FILE = _config_dir() / "config.json"
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_api_key() -> str | None:
|
||||
"""
|
||||
Return the stored Gemini API key, or None if not configured.
|
||||
@@ -148,6 +150,7 @@ def config_path() -> Path:
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
"""Read and parse the config file. Returns {} if missing or malformed."""
|
||||
if not _CONFIG_FILE.is_file():
|
||||
|
||||
+12
-13
@@ -17,13 +17,14 @@ from typing import Callable
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionResult:
|
||||
"""Everything the detector knows about a project directory."""
|
||||
|
||||
ecosystems: list[str] = field(default_factory=list) # e.g. ["Node.js", "Next.js"]
|
||||
ecosystems: list[str] = field(default_factory=list) # e.g. ["Node.js", "Next.js"]
|
||||
rule_modules: list[str] = field(default_factory=list) # e.g. ["base", "node"]
|
||||
confidence: str = "low" # "low" | "medium" | "high"
|
||||
confidence: str = "low" # "low" | "medium" | "high"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
@@ -40,13 +41,15 @@ class DetectionResult:
|
||||
# Detection rules — ordered from most specific to least specific
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Rule:
|
||||
"""A single detection rule."""
|
||||
name: str # Human-readable ecosystem name e.g. "Next.js"
|
||||
module: str # Rule module key e.g. "node"
|
||||
|
||||
name: str # Human-readable ecosystem name e.g. "Next.js"
|
||||
module: str # Rule module key e.g. "node"
|
||||
check: Callable[[Path], bool]
|
||||
weight: int = 1 # Higher = stronger signal
|
||||
weight: int = 1 # Higher = stronger signal
|
||||
|
||||
|
||||
_RULES: list[_Rule] = [
|
||||
@@ -62,7 +65,6 @@ _RULES: list[_Rule] = [
|
||||
),
|
||||
weight=3,
|
||||
),
|
||||
|
||||
# ── Node.js / generic JS+TS ───────────────────────────────────────────
|
||||
_Rule(
|
||||
name="Node.js",
|
||||
@@ -70,7 +72,6 @@ _RULES: list[_Rule] = [
|
||||
check=lambda p: _file_exists(p, "package.json"),
|
||||
weight=2,
|
||||
),
|
||||
|
||||
# ── Django (must come before generic Python) ──────────────────────────
|
||||
_Rule(
|
||||
name="Django",
|
||||
@@ -78,7 +79,6 @@ _RULES: list[_Rule] = [
|
||||
check=lambda p: _file_exists(p, "manage.py") or _dep_present_py(p, "django"),
|
||||
weight=3,
|
||||
),
|
||||
|
||||
# ── FastAPI ───────────────────────────────────────────────────────────
|
||||
_Rule(
|
||||
name="FastAPI",
|
||||
@@ -86,7 +86,6 @@ _RULES: list[_Rule] = [
|
||||
check=lambda p: _dep_present_py(p, "fastapi"),
|
||||
weight=3,
|
||||
),
|
||||
|
||||
# ── Generic Python ────────────────────────────────────────────────────
|
||||
_Rule(
|
||||
name="Python",
|
||||
@@ -100,7 +99,6 @@ _RULES: list[_Rule] = [
|
||||
),
|
||||
weight=2,
|
||||
),
|
||||
|
||||
# ── Rust ──────────────────────────────────────────────────────────────
|
||||
_Rule(
|
||||
name="Rust",
|
||||
@@ -108,7 +106,6 @@ _RULES: list[_Rule] = [
|
||||
check=lambda p: _file_exists(p, "Cargo.toml"),
|
||||
weight=3,
|
||||
),
|
||||
|
||||
# ── Go ────────────────────────────────────────────────────────────────
|
||||
_Rule(
|
||||
name="Go",
|
||||
@@ -116,7 +113,6 @@ _RULES: list[_Rule] = [
|
||||
check=lambda p: _file_exists(p, "go.mod"),
|
||||
weight=3,
|
||||
),
|
||||
|
||||
# ── Ruby ─────────────────────────────────────────────────────────────
|
||||
_Rule(
|
||||
name="Ruby",
|
||||
@@ -131,6 +127,7 @@ _RULES: list[_Rule] = [
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def detect(project_dir: Path) -> DetectionResult:
|
||||
"""
|
||||
Analyse *project_dir* and return a :class:`DetectionResult`.
|
||||
@@ -173,6 +170,7 @@ def detect(project_dir: Path) -> DetectionResult:
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _file_exists(project_dir: Path, filename: str) -> bool:
|
||||
return (project_dir / filename).exists()
|
||||
|
||||
@@ -184,6 +182,7 @@ def _dep_present(project_dir: Path, dep: str) -> bool:
|
||||
return False
|
||||
try:
|
||||
import json
|
||||
|
||||
data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
|
||||
all_deps = {
|
||||
**data.get("dependencies", {}),
|
||||
@@ -215,4 +214,4 @@ def _dep_present_py(project_dir: Path, dep: str) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
+18
-15
@@ -30,16 +30,16 @@ import pathspec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RULE_REGISTRY: dict[str, str] = {
|
||||
"base": "contextzip.rules.base",
|
||||
"node": "contextzip.rules.node",
|
||||
"base": "contextzip.rules.base",
|
||||
"node": "contextzip.rules.node",
|
||||
"python": "contextzip.rules.python",
|
||||
"rust": "contextzip.rules.rust",
|
||||
"go": "contextzip.rules.go",
|
||||
"ruby": "contextzip.rules.ruby",
|
||||
"rust": "contextzip.rules.rust",
|
||||
"go": "contextzip.rules.go",
|
||||
"ruby": "contextzip.rules.ruby",
|
||||
}
|
||||
|
||||
# Files larger than this trigger a warning (but are still included)
|
||||
LARGE_FILE_WARN_BYTES = 1 * 1024 * 1024 # 1 MB
|
||||
LARGE_FILE_WARN_BYTES = 1 * 1024 * 1024 # 1 MB
|
||||
|
||||
# Peek this many bytes to detect binary files
|
||||
_BINARY_PEEK = 512
|
||||
@@ -49,12 +49,13 @@ _BINARY_PEEK = 512
|
||||
# Result model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolveResult:
|
||||
included: list[Path] = field(default_factory=list)
|
||||
excluded: list[Path] = field(default_factory=list)
|
||||
skipped: list[tuple[Path, str]] = field(default_factory=list) # (path, reason)
|
||||
large_files: list[tuple[Path, int]] = field(default_factory=list) # (path, bytes)
|
||||
included: list[Path] = field(default_factory=list)
|
||||
excluded: list[Path] = field(default_factory=list)
|
||||
skipped: list[tuple[Path, str]] = field(default_factory=list) # (path, reason)
|
||||
large_files: list[tuple[Path, int]] = field(default_factory=list) # (path, bytes)
|
||||
binary_files: list[Path] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -62,6 +63,7 @@ class ResolveResult:
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_spec(
|
||||
rule_modules: list[str],
|
||||
extra_exclude: list[str] | None = None,
|
||||
@@ -86,7 +88,9 @@ def build_spec(
|
||||
# Respect the project's own .gitignore if present
|
||||
if gitignore_path and gitignore_path.is_file():
|
||||
try:
|
||||
lines = gitignore_path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
lines = gitignore_path.read_text(
|
||||
encoding="utf-8", errors="ignore"
|
||||
).splitlines()
|
||||
# Strip comments and blank lines; pathspec handles the rest
|
||||
patterns.extend(l for l in lines if l.strip() and not l.startswith("#"))
|
||||
except OSError:
|
||||
@@ -111,13 +115,12 @@ def resolve_files(
|
||||
result = ResolveResult()
|
||||
|
||||
for abs_path in sorted(project_dir.rglob("*")):
|
||||
|
||||
# ── Resolve symlinks safely ──────────────────────────────────────────
|
||||
if abs_path.is_symlink():
|
||||
try:
|
||||
real = abs_path.resolve(strict=True)
|
||||
if not real.is_file():
|
||||
continue # symlink to a dir or missing — skip silently
|
||||
continue # symlink to a dir or missing — skip silently
|
||||
abs_path = real
|
||||
except (OSError, RuntimeError):
|
||||
# Dangling symlink or resolution loop
|
||||
@@ -205,7 +208,6 @@ def resolve_files_from_git(
|
||||
result = ResolveResult()
|
||||
|
||||
for abs_path in sorted(git_files):
|
||||
|
||||
# ── Resolve symlinks safely ──────────────────────────────────────────
|
||||
if abs_path.is_symlink():
|
||||
try:
|
||||
@@ -270,6 +272,7 @@ def summarise_exclusions(excluded: list[Path], project_dir: Path) -> dict[str, i
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _any_parent_excluded(rel: Path, spec: pathspec.PathSpec) -> bool:
|
||||
"""True if any directory component of *rel* is matched by *spec*."""
|
||||
parts = rel.parts[:-1]
|
||||
@@ -304,4 +307,4 @@ def _is_binary(path: Path) -> bool:
|
||||
chunk = fh.read(_BINARY_PEEK)
|
||||
return b"\x00" in chunk
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
+21
-14
@@ -47,27 +47,29 @@ _INCLUDE_CODES = {"M", "A", "R", "C", "U", "?"}
|
||||
# Result / error models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GitErrorKind(Enum):
|
||||
NOT_A_REPO = "not_a_repo"
|
||||
NOT_A_REPO = "not_a_repo"
|
||||
GIT_NOT_FOUND = "git_not_found"
|
||||
COMMAND_ERROR = "command_error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitError:
|
||||
kind: GitErrorKind
|
||||
message: str # human-readable, ready to print
|
||||
kind: GitErrorKind
|
||||
message: str # human-readable, ready to print
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitChanges:
|
||||
"""Successful result from :func:`get_changed_files`."""
|
||||
files: list[Path] = field(default_factory=list) # absolute paths
|
||||
staged: list[str] = field(default_factory=list) # rel paths (display)
|
||||
unstaged: list[str] = field(default_factory=list) # rel paths (display)
|
||||
untracked: list[str] = field(default_factory=list) # rel paths (display)
|
||||
deleted: list[str] = field(default_factory=list) # rel paths (skipped)
|
||||
submodules: list[str] = field(default_factory=list) # rel paths (skipped)
|
||||
|
||||
files: list[Path] = field(default_factory=list) # absolute paths
|
||||
staged: list[str] = field(default_factory=list) # rel paths (display)
|
||||
unstaged: list[str] = field(default_factory=list) # rel paths (display)
|
||||
untracked: list[str] = field(default_factory=list) # rel paths (display)
|
||||
deleted: list[str] = field(default_factory=list) # rel paths (skipped)
|
||||
submodules: list[str] = field(default_factory=list) # rel paths (skipped)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
@@ -78,6 +80,7 @@ class GitChanges:
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_changed_files(project_dir: Path) -> GitChanges | GitError:
|
||||
"""
|
||||
Run ``git status --porcelain`` in *project_dir* and return a
|
||||
@@ -127,7 +130,9 @@ def get_changed_files(project_dir: Path) -> GitChanges | GitError:
|
||||
text=True,
|
||||
timeout=8,
|
||||
)
|
||||
repo_root = Path(root_proc.stdout.strip()) if root_proc.returncode == 0 else project_dir
|
||||
repo_root = (
|
||||
Path(root_proc.stdout.strip()) if root_proc.returncode == 0 else project_dir
|
||||
)
|
||||
|
||||
# ── Run git status --porcelain ───────────────────────────────────────────
|
||||
try:
|
||||
@@ -159,6 +164,7 @@ def get_changed_files(project_dir: Path) -> GitChanges | GitError:
|
||||
# Parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_porcelain(
|
||||
output: str,
|
||||
repo_root: Path,
|
||||
@@ -177,15 +183,15 @@ def _parse_porcelain(
|
||||
if not raw_line or len(raw_line) < 4:
|
||||
continue
|
||||
|
||||
xy = raw_line[:2] # e.g. "M ", " M", "??", "A "
|
||||
xy = raw_line[:2] # e.g. "M ", " M", "??", "A "
|
||||
rel_path = raw_line[3:].strip() # path relative to repo root
|
||||
|
||||
# Strip surrounding quotes git adds for paths with spaces/special chars
|
||||
if rel_path.startswith('"') and rel_path.endswith('"'):
|
||||
rel_path = rel_path[1:-1]
|
||||
|
||||
x = xy[0] # staged status
|
||||
y = xy[1] # unstaged status
|
||||
x = xy[0] # staged status
|
||||
y = xy[1] # unstaged status
|
||||
|
||||
# Skip ignored files
|
||||
if x == "!" and y == "!":
|
||||
@@ -232,6 +238,7 @@ def _parse_porcelain(
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_submodule(path: Path) -> bool:
|
||||
"""
|
||||
A submodule shows up as a directory in the working tree but as a
|
||||
@@ -241,4 +248,4 @@ def _is_submodule(path: Path) -> bool:
|
||||
if not path.is_dir():
|
||||
return False
|
||||
git_marker = path / ".git"
|
||||
return git_marker.exists()
|
||||
return git_marker.exists()
|
||||
|
||||
+13
-11
@@ -43,13 +43,14 @@ _GITIGNORE_ENTRY = ".contextzip/"
|
||||
# Result model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackageResult:
|
||||
zip_path: Path
|
||||
file_count: int
|
||||
uncompressed_bytes: int
|
||||
compressed_bytes: int
|
||||
skipped_in_zip: list[tuple[Path, str]] = field(default_factory=list)
|
||||
zip_path: Path
|
||||
file_count: int
|
||||
uncompressed_bytes: int
|
||||
compressed_bytes: int
|
||||
skipped_in_zip: list[tuple[Path, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def compression_ratio(self) -> float:
|
||||
@@ -70,6 +71,7 @@ class PackageResult:
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_zip(
|
||||
resolve_result: ResolveResult,
|
||||
project_dir: Path,
|
||||
@@ -119,14 +121,12 @@ def create_zip(
|
||||
console=console,
|
||||
transient=True,
|
||||
) as progress:
|
||||
|
||||
total_bytes = sum(
|
||||
p.stat().st_size for p in included if p.is_file()
|
||||
)
|
||||
total_bytes = sum(p.stat().st_size for p in included if p.is_file())
|
||||
task = progress.add_task("Compressing…", total=max(total_bytes, 1))
|
||||
|
||||
with zipfile.ZipFile(
|
||||
zip_path, "w",
|
||||
zip_path,
|
||||
"w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6,
|
||||
) as zf:
|
||||
@@ -176,6 +176,7 @@ def create_zip(
|
||||
# Workspace helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_git_root(start: Path) -> Path | None:
|
||||
"""
|
||||
Walk up the directory tree from *start* looking for a .git directory.
|
||||
@@ -279,6 +280,7 @@ def _workspace_output_path(
|
||||
# Legacy helper (kept for any internal callers that may reference it)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
|
||||
return safe[:48] or "project"
|
||||
return safe[:48] or "project"
|
||||
|
||||
@@ -7,12 +7,10 @@ PATTERNS = [
|
||||
".git/",
|
||||
".svn/",
|
||||
".hg/",
|
||||
|
||||
# OS artifacts
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"desktop.ini",
|
||||
|
||||
# Editor & IDE
|
||||
".vscode/",
|
||||
".idea/",
|
||||
@@ -20,12 +18,10 @@ PATTERNS = [
|
||||
"*.swo",
|
||||
"*.swn",
|
||||
".vim/",
|
||||
|
||||
# Logs
|
||||
"*.log",
|
||||
"logs/",
|
||||
"log/",
|
||||
|
||||
# Temp & cache
|
||||
".cache/",
|
||||
"tmp/",
|
||||
@@ -33,7 +29,6 @@ PATTERNS = [
|
||||
"*.tmp",
|
||||
"*.bak",
|
||||
"*.orig",
|
||||
|
||||
# Secrets & env
|
||||
".env",
|
||||
".env.*",
|
||||
@@ -41,7 +36,6 @@ PATTERNS = [
|
||||
"*.key",
|
||||
"*.p12",
|
||||
"*.pfx",
|
||||
|
||||
# Binaries & media (rarely useful for AI context)
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
@@ -60,10 +54,8 @@ PATTERNS = [
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
|
||||
# contextzip workspace directory — always excluded regardless of .gitignore state
|
||||
".contextzip/",
|
||||
|
||||
# GitHub / repo governance (not useful as AI context)
|
||||
"CHANGELOG.md",
|
||||
"CHANGELOG",
|
||||
@@ -77,4 +69,4 @@ PATTERNS = [
|
||||
".github/ISSUE_TEMPLATE/",
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
".github/PULL_REQUEST_TEMPLATE/",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -24,6 +24,6 @@ PATTERNS = [
|
||||
".yarn/",
|
||||
"storybook-static/",
|
||||
".storybook/",
|
||||
"*.d.ts", # generated type declarations
|
||||
"*.d.ts", # generated type declarations
|
||||
"tsconfig.tsbuildinfo",
|
||||
]
|
||||
|
||||
@@ -26,11 +26,11 @@ PATTERNS = [
|
||||
".coverage",
|
||||
"*.sqlite3",
|
||||
"*.db",
|
||||
"site/", # mkdocs build output
|
||||
"site/", # mkdocs build output
|
||||
".tox/",
|
||||
"poetry.lock",
|
||||
"uv.lock",
|
||||
"migrations/", # Django migrations (auto-generated)
|
||||
"staticfiles/", # Django collected static
|
||||
"media/", # Django uploaded media
|
||||
"migrations/", # Django migrations (auto-generated)
|
||||
"staticfiles/", # Django collected static
|
||||
"media/", # Django uploaded media
|
||||
]
|
||||
|
||||
@@ -7,35 +7,28 @@ PATTERNS = [
|
||||
"vendor/bundle/",
|
||||
".bundle/",
|
||||
"Gemfile.lock",
|
||||
|
||||
# Ruby version managers
|
||||
".ruby-version",
|
||||
".ruby-gemset",
|
||||
".rvmrc",
|
||||
|
||||
# Built gems
|
||||
"*.gem",
|
||||
"pkg/",
|
||||
|
||||
# Rails-specific
|
||||
"tmp/", # runtime temp (also in base, fine to repeat)
|
||||
"log/", # request logs (also in base)
|
||||
"public/assets/", # precompiled assets (sprockets / propshaft)
|
||||
"tmp/", # runtime temp (also in base, fine to repeat)
|
||||
"log/", # request logs (also in base)
|
||||
"public/assets/", # precompiled assets (sprockets / propshaft)
|
||||
"public/packs/", # webpacker output
|
||||
"storage/", # ActiveStorage blobs
|
||||
"storage/", # ActiveStorage blobs
|
||||
".byebug_history",
|
||||
|
||||
# Test coverage
|
||||
"coverage/",
|
||||
|
||||
# Documentation
|
||||
"doc/",
|
||||
"rdoc/",
|
||||
|
||||
# Spring (Rails app preloader)
|
||||
".spring/",
|
||||
|
||||
# Compiled C extensions
|
||||
"*.bundle",
|
||||
"*.so", # also in base, explicit here for clarity
|
||||
]
|
||||
"*.so", # also in base, explicit here for clarity
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ Rust (Cargo) exclusion rules.
|
||||
|
||||
PATTERNS = [
|
||||
"target/",
|
||||
"Cargo.lock", # exclude for libraries; keep for binaries — we exclude for AI context
|
||||
"Cargo.lock", # exclude for libraries; keep for binaries — we exclude for AI context
|
||||
"*.rlib",
|
||||
"*.rmeta",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user