mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
252 lines
8.3 KiB
Python
252 lines
8.3 KiB
Python
"""
|
|
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
|
|
|
|
|
|
@dataclass
|
|
class GitChanges:
|
|
"""Successful result from :func:`get_changed_files`."""
|
|
|
|
files: list[Path] = field(default_factory=list)
|
|
staged: list[str] = field(default_factory=list)
|
|
unstaged: list[str] = field(default_factory=list)
|
|
untracked: list[str] = field(default_factory=list)
|
|
deleted: list[str] = field(default_factory=list)
|
|
submodules: list[str] = field(default_factory=list)
|
|
|
|
@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]
|
|
rel_path = raw_line[3:].strip()
|
|
|
|
# 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]
|
|
y = xy[1]
|
|
|
|
# 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()
|