Add git-changes support to CLI and implement git file detection

This commit is contained in:
akadeepesh
2026-05-15 20:04:18 +05:30
parent 3f56c77395
commit ce7424b0b3
3 changed files with 431 additions and 28 deletions
+116 -28
View File
@@ -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:[/]")
+71
View File
@@ -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] = {}
+244
View File
@@ -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()