Last active
April 19, 2025 16:13
-
-
Save ph20/18f93e89117b9b511618310dcd50047f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Add (or remove) a “# <relative‑path>” comment on the first line of selected files, | |
while skipping everything ignored by .gitignore. | |
New in v2 | |
────────── | |
• Respects .gitignore automatically | |
• Supports Dockerfile, Makefile, *.sh and .gitlab-ci.yml | |
""" | |
from __future__ import annotations | |
import argparse | |
import os | |
import subprocess | |
import sys | |
from pathlib import Path | |
from typing import Iterable, Optional | |
# ────────────────────────────────────────────────────────────────────────────── | |
# Helpers for .gitignore handling | |
# ────────────────────────────────────────────────────────────────────────────── | |
try: | |
import pathspec # type: ignore | |
except ModuleNotFoundError: # pragma: no cover | |
pathspec = None # will fall back to `git check-ignore` | |
def load_gitignore(project_root: Path): | |
""" | |
Return a callable `is_ignored(path: Path) -> bool` that says whether *path* | |
is excluded by .gitignore / .git/info/exclude. | |
Works with either: | |
• pathspec library ➜ faster | |
• `git check-ignore` ➜ always available if git ≥ 1.9 is installed | |
""" | |
# Fast path: use pathspec | |
if pathspec: | |
patterns: list[str] = [] | |
# Collect patterns from all standard ignore files | |
for ignore_file in (".gitignore", ".git/info/exclude"): | |
p = project_root / ignore_file | |
if p.exists(): | |
patterns.extend(p.read_text().splitlines()) | |
spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns) | |
def _is_ignored(p: Path) -> bool: | |
try: | |
rel = p.relative_to(project_root) | |
except ValueError: | |
rel = p | |
return spec.match_file(rel.as_posix()) | |
return _is_ignored | |
# Fallback: shell out to git for every file (slower but dependency‑free) | |
def _is_ignored(p: Path) -> bool: | |
try: | |
subprocess.run( | |
["git", "-C", str(project_root), "check-ignore", "-q", str(p)], | |
check=True, | |
stdout=subprocess.DEVNULL, | |
stderr=subprocess.DEVNULL, | |
) | |
return True | |
except subprocess.CalledProcessError: | |
return False | |
return _is_ignored | |
# ────────────────────────────────────────────────────────────────────────────── | |
# Core processing | |
# ────────────────────────────────────────────────────────────────────────────── | |
EXTENSIONS = { | |
".py", | |
".tf", | |
".tfvars", | |
".sh", | |
} # case‑sensitive | |
EXACT_FILENAMES = { | |
"Dockerfile", | |
"Makefile", | |
".gitlab-ci.yml", | |
} | |
def should_process(filename: str) -> bool: | |
return filename in EXACT_FILENAMES or any(filename.endswith(ext) for ext in EXTENSIONS) | |
def prepend_or_strip_comment( | |
file_path: Path, | |
rel_path: str, | |
revert: bool, | |
): | |
content = file_path.read_text(encoding="utf‑8") | |
if revert: | |
lines = content.split("\n", 1) | |
if len(lines) > 1 and lines[0].startswith("#"): | |
looks_like_path = "/" in lines[0] or file_path.name in lines[0] | |
if looks_like_path: | |
file_path.write_text(lines[1], encoding="utf‑8") | |
print(f"Reverted : {file_path}") | |
return | |
print(f"Skipped : {file_path} (no path comment)") | |
else: | |
if content.startswith(f"#{rel_path}\n"): | |
print(f"Skipped : {file_path} (already processed)") | |
return | |
file_path.write_text(f"#{rel_path}\n{content}", encoding="utf‑8") | |
print(f"Processed: {file_path}") | |
def process_directory( | |
project_root: Path, | |
target_dir: Path, | |
revert: bool = False, | |
respect_gitignore: bool = True, | |
): | |
is_ignored: Optional[callable[[Path], bool]] = None | |
if respect_gitignore and (project_root / ".git").is_dir(): | |
is_ignored = load_gitignore(project_root) | |
for dirpath, _, filenames in os.walk(target_dir): | |
for fname in filenames: | |
if not should_process(fname): | |
continue | |
fpath = Path(dirpath) / fname | |
# Skip git‑ignored files | |
if is_ignored and is_ignored(fpath): | |
print(f"Skipped : {fpath} (gitignored)") | |
continue | |
rel = os.path.relpath(fpath, project_root) | |
prepend_or_strip_comment(fpath, rel, revert) | |
# ────────────────────────────────────────────────────────────────────────────── | |
# CLI | |
# ────────────────────────────────────────────────────────────────────────────── | |
def parse_args(argv: Iterable[str]) -> argparse.Namespace: | |
parser = argparse.ArgumentParser( | |
description="Add/strip a first‑line path comment on code files.", | |
) | |
parser.add_argument("project_root", type=Path, help="Project’s top‑level directory.") | |
parser.add_argument("target_dir", type=Path, help="Sub‑directory to walk.") | |
parser.add_argument( | |
"--revert", | |
action="store_true", | |
help="Remove path comments instead of adding them.", | |
) | |
parser.add_argument( | |
"--no‑gitignore", | |
dest="respect_gitignore", | |
action="store_false", | |
help="Process files even if they are listed in .gitignore.", | |
) | |
return parser.parse_args(argv) | |
def main(argv: Optional[list[str]] = None): | |
args = parse_args(argv or sys.argv[1:]) | |
if not args.project_root.is_dir(): | |
sys.exit(f"Error: {args.project_root!s} is not a directory") | |
if not args.target_dir.is_dir(): | |
sys.exit(f"Error: {args.target_dir!s} is not a directory") | |
process_directory( | |
project_root=args.project_root.resolve(), | |
target_dir=args.target_dir.resolve(), | |
revert=args.revert, | |
respect_gitignore=args.respect_gitignore, | |
) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment