File size: 4,484 Bytes
0f8b3a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
#!/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()