""" File Graph Generator Creates a tree-like visualization of files with their content, respecting .gitignore patterns with proper tree rendering. """ import argparse import fnmatch import logging import os import sys from dataclasses import dataclass, field from pathlib import Path from typing import Iterator, List, Optional # ───────────────────────────── Logging Setup ────────────────────────────── def setup_logging(level: int = logging.INFO) -> None: """Configure structured logging to stderr (stdout is reserved for output).""" logging.basicConfig( stream=sys.stderr, level=level, format="%(asctime)s | %(levelname)-8s | %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger("file_graph") # ─────────────────────────── Gitignore Engine ───────────────────────────── @dataclass class GitignoreRule: """A single parsed gitignore rule.""" pattern: str is_negation: bool = False is_dir_only: bool = False is_anchored: bool = False is_relative: bool = False # contains / (not at start) class GitignoreParser: """Parse a .gitignore file into ordered rules.""" @staticmethod def parse(path: Path) -> List[GitignoreRule]: rules = [] if not path.exists(): return rules try: with path.open("r", encoding="utf-8", errors="ignore") as f: for line in f: line = line.rstrip("\n\r") # Skip empty lines and comments if not line or line.startswith("#"): continue is_negation = line.startswith("!") if is_negation: line = line[1:] # Handle escaped ! and # if line.startswith("\\!") or line.startswith("\\#"): line = line[1:] line = line.rstrip() if not line: continue is_dir_only = line.endswith("/") if is_dir_only: line = line[:-1] is_anchored = line.startswith("/") if is_anchored: line = line[1:] is_relative = "/" in line rules.append( GitignoreRule( pattern=line, is_negation=is_negation, is_dir_only=is_dir_only, is_anchored=is_anchored, is_relative=is_relative, ) ) except OSError as e: logger.warning(f"Could not read {path}: {e}") return rules class GitignoreChecker: """Check if paths should be ignored, respecting per-directory .gitignore files.""" def __init__(self, root: Path): self.root = root.resolve() self._cache: dict[Path, List[GitignoreRule]] = {} self._skip_dirs = { ".git", "__pycache__", ".pytest_cache", ".mypy_cache", ".tox", ".venv", "venv", } self._skip_files = { "file_graph_generator.py", "file_graph_generator_v2.py", "file_graph_generator_v3.py", "file_tree.txt", } def _load(self, directory: Path) -> List[GitignoreRule]: """Load rules for a directory (cached).""" directory = directory.resolve() if directory not in self._cache: gitignore_path = directory / ".gitignore" if gitignore_path.exists(): logger.debug(f"Parsing gitignore: {gitignore_path}") self._cache[directory] = GitignoreParser.parse(gitignore_path) return self._cache[directory] def _match_rule( self, rule: GitignoreRule, name: str, rel_path: str, is_dir: bool ) -> bool: """Check if a single rule matches.""" if rule.is_dir_only and not is_dir: return False # Anchored pattern: match only at .gitignore level if rule.is_anchored: return fnmatch.fnmatch(rel_path, rule.pattern) or fnmatch.fnmatch( rel_path, rule.pattern + "/" ) # Relative path pattern (contains /): match against rel_path if rule.is_relative: return fnmatch.fnmatch(rel_path, rule.pattern) or fnmatch.fnmatch( rel_path, rule.pattern + "/" ) # Basename pattern: match name at any level return fnmatch.fnmatch(name, rule.pattern) def is_ignored(self, path: Path, is_dir: bool) -> bool: """Check if path should be ignored, walking up to root.""" name = path.name # Fast-path: always skip common VCS/build dirs and script/output files if is_dir and name in self._skip_dirs: logger.debug(f"Skipping common dir: {name}") return True if not is_dir and name in self._skip_files: logger.debug(f"Skipping script/output file: {name}") return True # Walk from current directory up to root current = path.parent.resolve() while True: rules = self._load(current) if rules: rel_path = path.relative_to(current).as_posix() ignored = False for rule in rules: if self._match_rule(rule, name, rel_path, is_dir): if rule.is_negation: ignored = False else: ignored = True if ignored: logger.debug(f"Ignored by gitignore: {path}") return True if current == self.root: break current = current.parent return False # ──────────────────────────── File Reading ──────────────────────────────── MAX_CONTENT_BYTES = 50 * 100 # ~50 lines avg def is_binary(path: Path) -> bool: """Fast binary detection using first 8KB chunk.""" try: with path.open("rb") as f: chunk = f.read(8192) if not chunk: return False if b"\x00" in chunk: return True if chunk[:2] in (b"\xff\xfe", b"\xfe\xff", b"\x1f\x8b"): return True return False except OSError: return True def read_content(path: Path, max_lines: int = 50) -> Iterator[str]: """Yield content lines with minimal memory footprint.""" if is_binary(path): yield "[binary file — content skipped]" return try: with path.open("r", encoding="utf-8", errors="replace") as f: for i, line in enumerate(f): if i >= max_lines: remaining = sum(1 for _ in f) yield f"... ({remaining} more lines)" return yield line.rstrip("\n") except OSError as e: yield f"[Error reading file: {e}]" # ─────────────────────────── Tree Generator ─────────────────────────────── def get_visible_children(path: Path, checker: GitignoreChecker) -> List[os.DirEntry]: """Get sorted, filtered children of a directory.""" try: entries = list(os.scandir(path)) except PermissionError: logger.warning(f"Permission denied: {path}") return [] except OSError as e: logger.error(f"Error scanning {path}: {e}") return [] visible = [] for entry in entries: # Skip .gitignore files entirely if entry.name == ".gitignore": continue child_path = Path(entry.path) is_dir = entry.is_dir() if not checker.is_ignored(child_path, is_dir): visible.append(entry) # Sort: dirs first, then files, both alphabetically (case-insensitive) visible.sort(key=lambda e: (not e.is_dir(), e.name.lower())) return visible def build_tree_lines( path: Path, checker: GitignoreChecker, prefix: str = "", max_lines: int = 50, max_depth: Optional[int] = None, current_depth: int = 0, ) -> Iterator[str]: """Recursively yield tree lines. Correctly handles is_last filtering.""" if path.is_dir(): if max_depth is not None and current_depth >= max_depth: return children = get_visible_children(path, checker) count = len(children) for i, entry in enumerate(children): is_last = i == count - 1 connector = "└── " if is_last else "├── " name = entry.name if entry.is_dir(): yield prefix + connector + name + "/" extension = " " if is_last else "│ " yield from build_tree_lines( Path(entry.path), checker, prefix + extension, max_lines, max_depth, current_depth + 1, ) else: yield prefix + connector + name content_prefix = prefix + (" " if is_last else "│ ") + " " for content_line in read_content(Path(entry.path), max_lines): yield content_prefix + content_line # ─────────────────────────────── Main ───────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser( description="File tree generator with gitignore support", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s # Scan current directory %(prog)s ./src -o tree.txt # Scan ./src, write to tree.txt %(prog)s --max-depth 3 # Limit depth to 3 levels %(prog)s --max-lines 20 # Show only 20 lines per file %(prog)s -v # Verbose logging %(prog)s --no-gitignore # Ignore all .gitignore files """, ) parser.add_argument( "directory", nargs="?", default=".", help="Root directory to scan" ) parser.add_argument("-o", "--output", default="file_tree.txt", help="Output file") parser.add_argument( "--max-depth", type=int, default=None, help="Maximum directory depth" ) parser.add_argument( "--max-lines", type=int, default=50, help="Max lines to show per file" ) parser.add_argument( "--no-gitignore", action="store_true", help="Disable .gitignore processing" ) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable debug logging" ) args = parser.parse_args() setup_logging(logging.DEBUG if args.verbose else logging.INFO) root = Path(args.directory).resolve() if not root.is_dir(): logger.error(f"Not a directory: {root}") sys.exit(1) checker = GitignoreChecker(root) if args.no_gitignore: # Disable by making all paths return empty rules checker._cache = {d: [] for d in checker._cache} # Also override _load to always return empty checker._load = lambda d: [] logger.info("Gitignore processing disabled") try: with open(args.output, "w", encoding="utf-8") as f: # Print root f.write(root.name + "/\n") # Print tree for line in build_tree_lines( root, checker, max_lines=args.max_lines, max_depth=args.max_depth ): f.write(line + "\n") logger.info(f"Output written to: {args.output}") except OSError as e: logger.error(f"Failed to write output: {e}") sys.exit(1) if __name__ == "__main__": main()