146 lines
4.3 KiB
Python
146 lines
4.3 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Create an ASCII file tree while respecting .gitignore when possible.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python tools/filetree.py [output_path]
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import fnmatch
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from typing import Dict, Iterable, List
|
||
|
|
|
||
|
|
|
||
|
|
def find_repo_root(start: str) -> str:
|
||
|
|
cur = os.path.abspath(start)
|
||
|
|
while True:
|
||
|
|
if os.path.isdir(os.path.join(cur, ".git")):
|
||
|
|
return cur
|
||
|
|
parent = os.path.dirname(cur)
|
||
|
|
if parent == cur:
|
||
|
|
return os.path.abspath(start)
|
||
|
|
cur = parent
|
||
|
|
|
||
|
|
|
||
|
|
def git_available() -> bool:
|
||
|
|
try:
|
||
|
|
subprocess.run(["git", "--version"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def list_files_via_git(root: str) -> List[str]:
|
||
|
|
result = subprocess.run(
|
||
|
|
["git", "-C", root, "ls-files", "--others", "--cached", "--exclude-standard"],
|
||
|
|
check=True,
|
||
|
|
stdout=subprocess.PIPE,
|
||
|
|
stderr=subprocess.DEVNULL,
|
||
|
|
text=True,
|
||
|
|
)
|
||
|
|
files = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||
|
|
return files
|
||
|
|
|
||
|
|
|
||
|
|
def parse_gitignore(root: str) -> List[str]:
|
||
|
|
path = os.path.join(root, ".gitignore")
|
||
|
|
if not os.path.isfile(path):
|
||
|
|
return []
|
||
|
|
patterns: List[str] = []
|
||
|
|
with open(path, "r", encoding="utf-8") as f:
|
||
|
|
for raw in f:
|
||
|
|
line = raw.strip()
|
||
|
|
if not line or line.startswith("#"):
|
||
|
|
continue
|
||
|
|
patterns.append(line)
|
||
|
|
return patterns
|
||
|
|
|
||
|
|
|
||
|
|
def is_ignored(path_rel: str, is_dir: bool, patterns: List[str]) -> bool:
|
||
|
|
# Minimal .gitignore matching: supports *, ?, **, leading /, trailing /
|
||
|
|
path_rel = path_rel.replace(os.sep, "/")
|
||
|
|
ignored = False
|
||
|
|
for pat in patterns:
|
||
|
|
negate = pat.startswith("!")
|
||
|
|
if negate:
|
||
|
|
pat = pat[1:]
|
||
|
|
if pat.startswith("/"):
|
||
|
|
pat = pat[1:]
|
||
|
|
if pat.endswith("/") and not is_dir:
|
||
|
|
continue
|
||
|
|
pat = pat.rstrip("/")
|
||
|
|
if fnmatch.fnmatch(path_rel, pat) or fnmatch.fnmatch(os.path.basename(path_rel), pat):
|
||
|
|
ignored = not negate
|
||
|
|
return ignored
|
||
|
|
|
||
|
|
|
||
|
|
def list_files_with_fallback(root: str) -> List[str]:
|
||
|
|
patterns = parse_gitignore(root)
|
||
|
|
files: List[str] = []
|
||
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
||
|
|
rel_dir = os.path.relpath(dirpath, root)
|
||
|
|
if rel_dir == ".":
|
||
|
|
rel_dir = ""
|
||
|
|
# Prune ignored directories early
|
||
|
|
pruned = []
|
||
|
|
for d in dirnames:
|
||
|
|
rel_path = os.path.join(rel_dir, d) if rel_dir else d
|
||
|
|
if is_ignored(rel_path, True, patterns):
|
||
|
|
continue
|
||
|
|
pruned.append(d)
|
||
|
|
dirnames[:] = pruned
|
||
|
|
for name in filenames:
|
||
|
|
rel_path = os.path.join(rel_dir, name) if rel_dir else name
|
||
|
|
if is_ignored(rel_path, False, patterns):
|
||
|
|
continue
|
||
|
|
files.append(rel_path.replace(os.sep, "/"))
|
||
|
|
return files
|
||
|
|
|
||
|
|
|
||
|
|
def build_tree(paths: Iterable[str]) -> Dict[str, dict]:
|
||
|
|
root: Dict[str, dict] = {}
|
||
|
|
for path in paths:
|
||
|
|
parts = [p for p in path.split("/") if p]
|
||
|
|
node = root
|
||
|
|
for part in parts:
|
||
|
|
node = node.setdefault(part, {})
|
||
|
|
return root
|
||
|
|
|
||
|
|
|
||
|
|
def render_tree(node: Dict[str, dict], prefix: str = "") -> List[str]:
|
||
|
|
lines: List[str] = []
|
||
|
|
entries = sorted(node.keys())
|
||
|
|
for i, name in enumerate(entries):
|
||
|
|
is_last = i == len(entries) - 1
|
||
|
|
connector = "\\-- " if is_last else "|-- "
|
||
|
|
lines.append(f"{prefix}{connector}{name}")
|
||
|
|
child = node[name]
|
||
|
|
if child:
|
||
|
|
extension = " " if is_last else "| "
|
||
|
|
lines.extend(render_tree(child, prefix + extension))
|
||
|
|
return lines
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
out_path = sys.argv[1] if len(sys.argv) > 1 else "FILETREE.txt"
|
||
|
|
root = find_repo_root(os.getcwd())
|
||
|
|
if os.path.isdir(os.path.join(root, ".git")) and git_available():
|
||
|
|
files = list_files_via_git(root)
|
||
|
|
else:
|
||
|
|
files = list_files_with_fallback(root)
|
||
|
|
|
||
|
|
tree = build_tree(files)
|
||
|
|
lines = ["."]
|
||
|
|
lines.extend(render_tree(tree))
|
||
|
|
out_abs = os.path.join(root, out_path)
|
||
|
|
with open(out_abs, "w", encoding="utf-8") as f:
|
||
|
|
f.write("\n".join(lines) + "\n")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|