Skip to content

Instantly share code, notes, and snippets.

@ph20
Last active April 19, 2025 16:13
Show Gist options
  • Save ph20/18f93e89117b9b511618310dcd50047f to your computer and use it in GitHub Desktop.
Save ph20/18f93e89117b9b511618310dcd50047f to your computer and use it in GitHub Desktop.
#!/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