#!/usr/bin/env python3 from __future__ import annotations import argparse import os import shutil from pathlib import Path from gitignore_parser import handle_negation, rule_from_pattern def _load_gitignore_rules(gitignore_path: Path) -> list: """ Parse a single .gitignore into gitignore_parser IgnoreRule objects. We intentionally use gitignore_parser's internal rule representation so we can combine multiple .gitignore files (root + subfolders) while keeping correct precedence (later rules override earlier ones). """ base_dir = gitignore_path.parent try: lines = gitignore_path.read_text(encoding="utf-8").splitlines() except OSError: return [] rules = [] for raw in lines: line = raw.strip() if not line or line.startswith("#"): continue rules.append(rule_from_pattern(line, base_path=str(base_dir))) return rules def _is_ignored(abs_path: Path, rules: list) -> bool: # gitignore_parser expects paths as strings; absolute paths are safest here. return bool(handle_negation(str(abs_path), rules)) def _copy_repo_respecting_gitignores(repo_root: Path, out_dir: Path) -> None: repo_root = repo_root.resolve() out_dir = out_dir.resolve() # If out_dir is inside repo_root, compute its rel path so we can prune traversal into it. out_rel: Path | None = None try: out_rel = out_dir.relative_to(repo_root) except ValueError: out_rel = None # Map directory -> cumulative rules that apply inside that directory. rules_for_dir: dict[Path, list] = {} for current_dir, dirnames, filenames in os.walk(repo_root, topdown=True): current_path = Path(current_dir) try: rel_dir = current_path.resolve().relative_to(repo_root) except ValueError: continue # Hard excludes if rel_dir == Path(".git") or Path(".git") in rel_dir.parents: dirnames[:] = [] continue if out_rel is not None and (rel_dir == out_rel or out_rel in rel_dir.parents): dirnames[:] = [] continue # Build cumulative rules for this directory: parent rules + local .gitignore rules parent_rel = rel_dir.parent if rel_dir != Path(".") else None parent_rules = rules_for_dir.get(parent_rel, []) if parent_rel is not None else [] local_rules = [] local_gitignore = current_path / ".gitignore" if local_gitignore.is_file(): local_rules = _load_gitignore_rules(local_gitignore) current_rules = [*parent_rules, *local_rules] rules_for_dir[rel_dir] = current_rules # Filter directories in-place to control traversal kept_dirs: list[str] = [] for d in dirnames: rel = rel_dir / d if rel == Path(".git") or Path(".git") in rel.parents: continue if out_rel is not None and (rel == out_rel or out_rel in rel.parents): continue abs_path = (repo_root / rel).resolve() if _is_ignored(abs_path, current_rules): continue kept_dirs.append(d) dirnames[:] = kept_dirs # Ensure destination dir exists (out_dir / rel_dir).mkdir(parents=True, exist_ok=True) # Copy files for f in filenames: rel_file = rel_dir / f abs_file = (repo_root / rel_file).resolve() if _is_ignored(abs_file, current_rules): continue src = repo_root / rel_file dst = out_dir / rel_file dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) def main() -> None: parser = argparse.ArgumentParser( description="Build a release directory by copying all files except those ignored by .gitignore files." ) parser.add_argument("--out-dir", default="out", help="Output directory.") parser.add_argument("--clean", action="store_true", help="Delete output directory before building.") args = parser.parse_args() repo_root = Path.cwd() out_dir = (repo_root / args.out_dir).resolve() if args.clean and out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir(parents=True, exist_ok=True) _copy_repo_respecting_gitignores(repo_root=repo_root, out_dir=out_dir) print(f"Release directory built at: {out_dir}") if __name__ == "__main__": main()