#!/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())