mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
Add git-changes support to CLI and implement git file detection
This commit is contained in:
+116
-28
@@ -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:[/]")
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user