#!/usr/bin/env python3 """Dotree — directory tree generator with size info and .gitignore awareness.""" import argparse, os, fnmatch, sys, math from pathlib import Path def color(text, code): return f"\033[{code}m{text}\033[0m" if sys.stdout.isatty() else text def load_gitignore(root): patterns = [] gitignore = Path(root) / ".gitignore" if gitignore.exists(): for line in gitignore.read_text().splitlines(): line = line.strip() if line and not line.startswith("#"): patterns.append(line) return patterns def is_ignored(name, patterns): return any(fnmatch.fnmatch(name, p) or fnmatch.fnmatch(name, f"*/{p}") for p in patterns) def human_size(size): for unit in ("", "K", "M", "G", "T"): if size < 1024: return f"{size:.1f}{unit}" if unit else f"{size}B" size /= 1024 return f"{size:.1f}P" def build_tree(path, prefix="", patterns=None, max_depth=None, depth=0, show_hidden=False, show_size=True): if max_depth is not None and depth > max_depth: return [] path = Path(path) if not path.is_dir(): return [] try: entries = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) except PermissionError: return [(prefix + color("└── [ Permission denied ]", "31"), None)] lines = [] for i, entry in enumerate(entries): if not show_hidden and entry.name.startswith("."): continue if patterns and is_ignored(entry.name, patterns): continue is_last = (i == len(entries) - 1) connector = "└── " if is_last else "├── " name = entry.name if entry.is_dir(): entry_str = color(name, "34;1") + "/" line = prefix + connector + entry_str lines.append((line, None)) ext = " " if is_last else "│ " children = build_tree(entry, prefix + ext, patterns, max_depth, depth + 1, show_hidden, show_size) lines.extend(children) else: size_str = f" ({human_size(entry.stat().st_size)})" if show_size else "" line = prefix + connector + name + color(size_str, "90") lines.append((line, entry.stat().st_size)) return lines def count_files(root, patterns=None, show_hidden=False): total = 0 total_size = 0 for path in Path(root).rglob("*"): try: if not show_hidden and any(p.startswith(".") for p in path.relative_to(root).parts): continue if path.is_file(): if not patterns or not is_ignored(path.name, patterns): total += 1 total_size += path.stat().st_size except PermissionError: pass return total, total_size def main(): parser = argparse.ArgumentParser( description="Dotree — directory tree generator", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" " dotree # Show current directory tree\n" " dotree /path/to/dir # Show specific directory\n" " dotree --depth 2 # Limit depth to 2 levels\n" " dotree --no-size # Hide file sizes\n" " dotree --all # Show hidden files\n" ), ) parser.add_argument("directory", nargs="?", default=".", help="Directory to show (default: .)") parser.add_argument("--depth", type=int, help="Max depth") parser.add_argument("--no-size", action="store_true", help="Hide file sizes") parser.add_argument("--all", "-a", action="store_true", help="Show hidden files") parser.add_argument("--no-gitignore", action="store_true", help="Don't respect .gitignore") parser.add_argument("--version", action="version", version="dotree 1.0.0") args = parser.parse_args() root = Path(args.directory).resolve() if not root.is_dir(): print(f"Error: {root} is not a directory", file=sys.stderr) sys.exit(1) patterns = None if args.no_gitignore else load_gitignore(root) print(color(str(root) + "/", "34;1")) tree = build_tree(root, patterns=patterns, max_depth=args.depth, show_hidden=args.all, show_size=not args.no_size) for line, _ in tree: print(line) total_files, total_size = count_files(root, patterns, args.all) summary = f"\n{total_files} files, {human_size(total_size)}" print(color(summary, "90")) if __name__ == "__main__": main()