369 lines
12 KiB
Python
369 lines
12 KiB
Python
"""
|
|
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()
|