diff --git a/contextzip/cli.py b/contextzip/cli.py index c6ff2a5..eb5b1e2 100644 --- a/contextzip/cli.py +++ b/contextzip/cli.py @@ -15,7 +15,14 @@ from rich import box from contextzip import __version__ from contextzip.detector import detect -from contextzip.filters import build_spec, resolve_files, summarise_exclusions, LARGE_FILE_WARN_BYTES +from contextzip.filters import ( + build_spec, + resolve_files, + resolve_files_from_git, + summarise_exclusions, + LARGE_FILE_WARN_BYTES, +) +from contextzip.git import get_changed_files, GitError, GitChanges from contextzip.packager import create_zip from contextzip.clipboard import handle as clipboard_handle, Tier @@ -59,6 +66,12 @@ console = Console() 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, + 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, @@ -72,6 +85,7 @@ def main( output: str | None, no_clipboard: bool, no_gitignore: bool, + git_changes: bool, verbose: bool, ) -> None: """ @@ -101,35 +115,65 @@ def main( _print_detection(detection) - # ── 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() - ) + # ── Git-changes mode ───────────────────────────────────────────────────── + if git_changes: + with console.status("[cyan]Querying git for changed files…[/]", spinner="dots"): + git_result = get_changed_files(project_dir) - with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"): - spec = build_spec( - rule_modules=detection.rule_modules, - extra_exclude=list(exclude) if exclude else None, - gitignore_path=gitignore_path, + if isinstance(git_result, GitError): + console.print( + Panel.fit( + f"[red]Git error:[/] {git_result.message}", + border_style="red", padding=(0, 2), + ) + ) + raise SystemExit(1) + + _print_git_summary(git_result, project_dir, verbose) + + if git_result.is_empty: + console.print( + "\n[yellow]Nothing to package.[/] " + "No modified, added, or untracked files found — working tree is clean." + ) + return + + with console.status("[cyan]Checking git-changed files…[/]", spinner="dots"): + resolved = resolve_files_from_git( + git_files=git_result.files, + project_dir=project_dir, + ) + + else: + # ── 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() ) - if used_gitignore: - console.print(f" [dim]↳ .gitignore patterns applied[/]") - console.print() + with console.status("[cyan]Building exclusion rules…[/]", spinner="dots"): + spec = build_spec( + rule_modules=detection.rule_modules, + extra_exclude=list(exclude) if exclude else None, + gitignore_path=gitignore_path, + ) - # ── Resolve files ───────────────────────────────────────────────────────── - with console.status("[cyan]Scanning project files…[/]", spinner="dots"): - resolved = resolve_files( - project_dir=project_dir, - spec=spec, - include_only=list(include) if include else None, - ) + if used_gitignore: + console.print(f" [dim]↳ .gitignore patterns applied[/]") + console.print() + + # ── Resolve files ───────────────────────────────────────────────────── + with console.status("[cyan]Scanning project files…[/]", spinner="dots"): + resolved = resolve_files( + project_dir=project_dir, + spec=spec, + include_only=list(include) if include else None, + ) # ── File scan summary ──────────────────────────────────────────────────── - _print_scan_summary(resolved, project_dir, verbose) + _print_scan_summary(resolved, project_dir, verbose, git_mode=git_changes) # ── Warnings: large files ──────────────────────────────────────────────── if resolved.large_files: @@ -263,19 +307,63 @@ def _print_detection(detection) -> None: console.print() -def _print_scan_summary(resolved, project_dir: Path, verbose: bool) -> None: +def _print_git_summary(changes: GitChanges, project_dir: Path, verbose: bool) -> None: + """Print a panel summarising the git-changed files.""" + 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("Untracked", f"[cyan]{len(changes.untracked)}[/]") + if changes.deleted: + table.add_row("Deleted (skipped)", f"[dim]{len(changes.deleted)}[/]") + if changes.submodules: + table.add_row("Submodules (skipped)", f"[dim]{len(changes.submodules)}[/]") + table.add_row("To be included", f"[bold green]{len(changes.files)}[/]") + + console.print( + Panel( + table, + title="[bold]Git Changes[/]", + border_style="magenta", padding=(0, 1), + ) + ) + console.print() + + if verbose and changes.files: + console.print("[bold]Git-changed files:[/]") + for category, paths, colour in ( + ("Staged", changes.staged, "green"), + ("Unstaged", changes.unstaged, "yellow"), + ("Untracked", changes.untracked, "cyan"), + ): + for rel in paths: + console.print(f" [{colour}]✓[/] [dim]{rel}[/] [dim]({category})[/]") + console.print() + + +def _print_scan_summary( + resolved, + project_dir: Path, + verbose: bool, + *, + git_mode: bool = False, +) -> None: 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)) table.add_column(style="dim") table.add_column() - table.add_row("Files scanned", str(total + len(resolved.skipped))) + + if not git_mode: + 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)})[/]", ) - table.add_row("Excluded", f"[red]{len(resolved.excluded)}[/]") + if not git_mode: + table.add_row("Excluded", f"[red]{len(resolved.excluded)}[/]") if resolved.skipped: table.add_row("Skipped", f"[yellow]{len(resolved.skipped)}[/]") console.print(table) @@ -288,7 +376,7 @@ def _print_scan_summary(resolved, project_dir: Path, verbose: bool) -> None: rel = p.relative_to(project_dir).as_posix() console.print(f" [green]✓[/] {rel} [dim]{size_str}[/]") - if resolved.excluded: + if not git_mode and resolved.excluded: console.print() buckets = summarise_exclusions(resolved.excluded, project_dir) console.print("[bold]Top excluded directories / files:[/]") diff --git a/contextzip/filters.py b/contextzip/filters.py index aa93b2b..b859d92 100644 --- a/contextzip/filters.py +++ b/contextzip/filters.py @@ -8,6 +8,11 @@ Phase 5 hardening: - --include prefix matching is exact (src/ won't match src2/) - Binary-looking files (null bytes) are flagged but still included - Unreadable files are skipped with a warning rather than silently dropped + +Git mode: + - resolve_files_from_git() accepts a GitChanges result and builds a + ResolveResult from only the modified/added/untracked files reported + by git, while still running all size and binary checks. """ from __future__ import annotations @@ -171,6 +176,72 @@ def resolve_files( return result +def resolve_files_from_git( + git_files: list[Path], + project_dir: Path, +) -> ResolveResult: + """ + Build a :class:`ResolveResult` from a pre-selected list of files reported + by git (modified, added, untracked). + + Unlike :func:`resolve_files`, this function does **not** apply exclusion + specs — the caller has already determined which files to include via + ``git status``. Size and binary checks are still performed so that the + usual warnings appear in the CLI output. + + Parameters + ---------- + git_files: + Absolute paths of files to include, as returned by + :attr:`GitChanges.files`. + project_dir: + Absolute path to the project root (used for relative-path guards). + """ + result = ResolveResult() + + for abs_path in sorted(git_files): + + # ── Resolve symlinks safely ────────────────────────────────────────── + if abs_path.is_symlink(): + try: + real = abs_path.resolve(strict=True) + if not real.is_file(): + continue + abs_path = real + except (OSError, RuntimeError): + result.skipped.append((abs_path, "dangling symlink")) + continue + + if not abs_path.is_file(): + continue + + # ── Guard: must be under project_dir ──────────────────────────────── + try: + abs_path.relative_to(project_dir) + except ValueError: + result.skipped.append((abs_path, "outside project tree")) + continue + + # ── Readability / stat check ───────────────────────────────────────── + try: + file_size = abs_path.stat().st_size + except OSError as e: + result.skipped.append((abs_path, f"stat failed: {e}")) + continue + + # ── Large file warning ─────────────────────────────────────────────── + if file_size >= LARGE_FILE_WARN_BYTES: + result.large_files.append((abs_path, file_size)) + + # ── Binary file detection ──────────────────────────────────────────── + if _is_binary(abs_path): + result.binary_files.append(abs_path) + + result.included.append(abs_path) + + return result + + def summarise_exclusions(excluded: list[Path], project_dir: Path) -> dict[str, int]: """Group excluded files by top-level directory / filename, sorted by count.""" buckets: dict[str, int] = {} diff --git a/contextzip/git.py b/contextzip/git.py new file mode 100644 index 0000000..1aeb3f8 --- /dev/null +++ b/contextzip/git.py @@ -0,0 +1,244 @@ +""" +git.py — Detects git-modified files in a project directory. + +Handles every edge case: + - Not a git repository + - Git not installed / not on PATH + - No changes at all (clean working tree) + - Deleted files (skipped — can't zip what doesn't exist) + - Renamed files (new name included, old name skipped) + - Both staged and unstaged changes collected + - Untracked files included (new files not yet committed) + - Submodule entries skipped + - Files outside the project tree skipped +""" + +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Status codes from `git status --porcelain` +# --------------------------------------------------------------------------- +# +# Format is two characters: XY where X = staged, Y = unstaged +# +# M = modified +# A = added / new file staged +# D = deleted +# R = renamed (line format: "R old -> new") +# C = copied +# U = updated but unmerged +# ? = untracked (both X and Y will be '?') +# ! = ignored (both X and Y will be '!') +# +# We include: M, A, R, C, U, ? +# We skip: D (deleted — file doesn't exist), ! (ignored) + +_INCLUDE_CODES = {"M", "A", "R", "C", "U", "?"} + + +# --------------------------------------------------------------------------- +# Result / error models +# --------------------------------------------------------------------------- + +class GitErrorKind(Enum): + 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 + + +@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) + + @property + def is_empty(self) -> bool: + return not self.files + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def get_changed_files(project_dir: Path) -> GitChanges | GitError: + """ + Run ``git status --porcelain`` in *project_dir* and return a + :class:`GitChanges` with all modified/added/untracked files, + or a :class:`GitError` describing what went wrong. + """ + + # ── Guard: git must be on PATH ─────────────────────────────────────────── + if not shutil.which("git"): + return GitError( + kind=GitErrorKind.GIT_NOT_FOUND, + message=( + "git is not installed or not on your PATH.\n" + "Install git from https://git-scm.com and try again." + ), + ) + + # ── Guard: must be inside a git repository ─────────────────────────────── + try: + check = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=project_dir, + capture_output=True, + text=True, + timeout=8, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as e: + return GitError( + kind=GitErrorKind.GIT_NOT_FOUND, + message=f"Could not run git: {e}", + ) + + if check.returncode != 0: + return GitError( + kind=GitErrorKind.NOT_A_REPO, + message=( + f"'{project_dir}' is not inside a git repository.\n" + "Run 'git init' first, or use contextzip without --git-changes." + ), + ) + + # ── Get the repo root (may differ from project_dir in a monorepo) ──────── + root_proc = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=project_dir, + capture_output=True, + text=True, + timeout=8, + ) + repo_root = Path(root_proc.stdout.strip()) if root_proc.returncode == 0 else project_dir + + # ── Run git status --porcelain ─────────────────────────────────────────── + try: + proc = subprocess.run( + ["git", "status", "--porcelain", "-u", "--no-renames"], + cwd=project_dir, + capture_output=True, + text=True, + timeout=15, + ) + except subprocess.TimeoutExpired: + return GitError( + kind=GitErrorKind.COMMAND_ERROR, + message="git status timed out — repository may be very large.", + ) + + if proc.returncode != 0: + stderr = proc.stderr.strip() + return GitError( + kind=GitErrorKind.COMMAND_ERROR, + message=f"git status failed: {stderr or 'unknown error'}", + ) + + # ── Parse the output ───────────────────────────────────────────────────── + return _parse_porcelain(proc.stdout, repo_root, project_dir) + + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + +def _parse_porcelain( + output: str, + repo_root: Path, + project_dir: Path, +) -> GitChanges: + """ + Parse ``git status --porcelain`` output into a :class:`GitChanges`. + + Each line is ``XY filename`` where X=staged status, Y=unstaged status. + With ``--no-renames`` renames appear as D + A pairs, so we handle them + naturally without needing to parse the "old -> new" arrow format. + """ + result = GitChanges() + + for raw_line in output.splitlines(): + if not raw_line or len(raw_line) < 4: + continue + + 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 + + # Skip ignored files + if x == "!" and y == "!": + continue + + abs_path = (repo_root / rel_path).resolve() + + # ── Submodule detection — git marks them as commits not files ───────── + if _is_submodule(abs_path): + result.submodules.append(rel_path) + continue + + # ── Deleted files — can't zip them ─────────────────────────────────── + if x == "D" or y == "D": + result.deleted.append(rel_path) + continue + + # ── Skip if not a regular file (directories, sockets, etc.) ────────── + if not abs_path.is_file(): + continue + + # ── Skip if outside the project_dir we're operating on ─────────────── + try: + abs_path.relative_to(project_dir) + except ValueError: + continue + + # ── Categorise for display ──────────────────────────────────────────── + if x == "?" and y == "?": + result.untracked.append(rel_path) + elif x != " " and x != "?": + result.staged.append(rel_path) + else: + result.unstaged.append(rel_path) + + # Deduplicate — a file can appear staged and unstaged simultaneously + if abs_path not in result.files: + result.files.append(abs_path) + + return result + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _is_submodule(path: Path) -> bool: + """ + A submodule shows up as a directory in the working tree but as a + commit object in git. We detect it by checking if the path is a + directory containing a .git file/dir, which is how git embeds submodules. + """ + if not path.is_dir(): + return False + git_marker = path / ".git" + return git_marker.exists() \ No newline at end of file