|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import argparse |
| 4 | +import collections |
| 5 | +import datetime as dt |
| 6 | +import json |
| 7 | +import os |
| 8 | +import socket |
| 9 | +import sqlite3 |
| 10 | +import subprocess |
| 11 | +from pathlib import Path |
| 12 | + |
| 13 | + |
| 14 | +DB_PATH = Path("/nix/var/nix/db/db.sqlite") |
| 15 | + |
| 16 | + |
| 17 | +def run(*args: str) -> str: |
| 18 | + return subprocess.run(args, check=True, text=True, capture_output=True).stdout |
| 19 | + |
| 20 | + |
| 21 | +def chunks(values: list[str], size: int = 100): |
| 22 | + for index in range(0, len(values), size): |
| 23 | + yield values[index : index + size] |
| 24 | + |
| 25 | + |
| 26 | +def closure(targets: set[str]) -> set[str]: |
| 27 | + result: set[str] = set() |
| 28 | + existing = sorted(target for target in targets if Path(target).exists()) |
| 29 | + for batch in chunks(existing): |
| 30 | + result.update(line for line in run("nix-store", "-qR", *batch).splitlines() if line) |
| 31 | + return result |
| 32 | + |
| 33 | + |
| 34 | +def sizes(paths: set[str]) -> dict[str, int]: |
| 35 | + if not paths: |
| 36 | + return {} |
| 37 | + connection = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) |
| 38 | + try: |
| 39 | + result: dict[str, int] = {} |
| 40 | + ordered = sorted(paths) |
| 41 | + for batch in chunks(ordered, 900): |
| 42 | + placeholders = ",".join("?" for _ in batch) |
| 43 | + query = f"select path, narSize from ValidPaths where path in ({placeholders})" |
| 44 | + result.update({path: int(size or 0) for path, size in connection.execute(query, batch)}) |
| 45 | + return result |
| 46 | + finally: |
| 47 | + connection.close() |
| 48 | + |
| 49 | + |
| 50 | +def human_size(value: int) -> str: |
| 51 | + amount = float(value) |
| 52 | + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): |
| 53 | + if amount < 1024 or unit == "TiB": |
| 54 | + return f"{amount:.2f} {unit}" |
| 55 | + amount /= 1024 |
| 56 | + raise AssertionError("unreachable") |
| 57 | + |
| 58 | + |
| 59 | +def parse_roots() -> list[tuple[str, str]]: |
| 60 | + entries: list[tuple[str, str]] = [] |
| 61 | + for line in run("nix-store", "--gc", "--print-roots").splitlines(): |
| 62 | + if " -> " not in line: |
| 63 | + continue |
| 64 | + source, target = line.split(" -> ", 1) |
| 65 | + source = source.strip('"') |
| 66 | + target = target.strip('"') |
| 67 | + if target.startswith("/nix/store/"): |
| 68 | + entries.append((source, target)) |
| 69 | + return entries |
| 70 | + |
| 71 | + |
| 72 | +def project_for_root(source: str) -> str | None: |
| 73 | + marker = "/.direnv/" |
| 74 | + if marker not in source: |
| 75 | + return None |
| 76 | + return source.split(marker, 1)[0] |
| 77 | + |
| 78 | + |
| 79 | +def top_paths(paths: set[str], path_sizes: dict[str, int], limit: int) -> list[dict]: |
| 80 | + ranked = sorted(paths, key=lambda path: path_sizes.get(path, 0), reverse=True)[:limit] |
| 81 | + return [{"path": path, "nar_size_bytes": path_sizes.get(path, 0)} for path in ranked] |
| 82 | + |
| 83 | + |
| 84 | +def main() -> int: |
| 85 | + parser = argparse.ArgumentParser(description="Audit Nix paths retained by .direnv GC roots.") |
| 86 | + parser.add_argument("--top", type=int, default=30, help="Projects and store paths to show.") |
| 87 | + parser.add_argument("--output", help="JSON artifact path; defaults under ~/.cache/ncdu.") |
| 88 | + args = parser.parse_args() |
| 89 | + if args.top < 1: |
| 90 | + parser.error("--top must be positive") |
| 91 | + if not DB_PATH.is_file(): |
| 92 | + parser.error(f"Nix database not found: {DB_PATH}") |
| 93 | + |
| 94 | + generated_at = dt.datetime.now().astimezone() |
| 95 | + roots = parse_roots() |
| 96 | + grouped: dict[str, list[tuple[str, str]]] = collections.defaultdict(list) |
| 97 | + non_direnv_targets: set[str] = set() |
| 98 | + for source, target in roots: |
| 99 | + project = project_for_root(source) |
| 100 | + if project is None: |
| 101 | + non_direnv_targets.add(target) |
| 102 | + else: |
| 103 | + grouped[project].append((source, target)) |
| 104 | + |
| 105 | + project_closures = { |
| 106 | + project: closure({target for _source, target in entries}) |
| 107 | + for project, entries in sorted(grouped.items()) |
| 108 | + } |
| 109 | + non_direnv_closure = closure(non_direnv_targets) |
| 110 | + all_direnv_closure = set().union(*project_closures.values()) if project_closures else set() |
| 111 | + direnv_only = all_direnv_closure - non_direnv_closure |
| 112 | + |
| 113 | + membership: collections.Counter[str] = collections.Counter() |
| 114 | + for project_paths in project_closures.values(): |
| 115 | + membership.update(project_paths) |
| 116 | + |
| 117 | + all_relevant = all_direnv_closure | non_direnv_closure |
| 118 | + path_sizes = sizes(all_relevant) |
| 119 | + total = lambda paths: sum(path_sizes.get(path, 0) for path in paths) |
| 120 | + |
| 121 | + projects = [] |
| 122 | + for project, entries in grouped.items(): |
| 123 | + project_paths = project_closures[project] |
| 124 | + outside_non_direnv = project_paths - non_direnv_closure |
| 125 | + marginal = {path for path in outside_non_direnv if membership[path] == 1} |
| 126 | + direnv_path = Path(project) / ".direnv" |
| 127 | + mtime = None |
| 128 | + age_days = None |
| 129 | + if direnv_path.exists(): |
| 130 | + timestamp = direnv_path.stat().st_mtime |
| 131 | + mtime = dt.datetime.fromtimestamp(timestamp).astimezone().isoformat() |
| 132 | + age_days = (generated_at.timestamp() - timestamp) / 86400 |
| 133 | + projects.append( |
| 134 | + { |
| 135 | + "project": project, |
| 136 | + "direnv_mtime": mtime, |
| 137 | + "direnv_age_days": age_days, |
| 138 | + "raw_root_count": len(entries), |
| 139 | + "unique_target_count": len({target for _source, target in entries}), |
| 140 | + "closure_path_count": len(project_paths), |
| 141 | + "closure_nar_bytes": total(project_paths), |
| 142 | + "outside_non_direnv_nar_bytes": total(outside_non_direnv), |
| 143 | + "marginal_unique_nar_bytes": total(marginal), |
| 144 | + "roots": [{"source": source, "target": target} for source, target in entries], |
| 145 | + "top_marginal_paths": top_paths(marginal, path_sizes, args.top), |
| 146 | + } |
| 147 | + ) |
| 148 | + projects.sort(key=lambda item: (item["marginal_unique_nar_bytes"], item["closure_nar_bytes"]), reverse=True) |
| 149 | + |
| 150 | + artifact = { |
| 151 | + "format_version": 1, |
| 152 | + "generated_at": generated_at.isoformat(), |
| 153 | + "hostname": socket.gethostname(), |
| 154 | + "measurement": "logical NAR size from the Nix database", |
| 155 | + "raw_gc_root_count": len(roots), |
| 156 | + "raw_direnv_root_count": sum(len(entries) for entries in grouped.values()), |
| 157 | + "unique_direnv_target_count": len({target for entries in grouped.values() for _source, target in entries}), |
| 158 | + "direnv_project_count": len(grouped), |
| 159 | + "all_direnv_closure_nar_bytes": total(all_direnv_closure), |
| 160 | + "collectively_direnv_only_nar_bytes": total(direnv_only), |
| 161 | + "collectively_direnv_only_path_count": len(direnv_only), |
| 162 | + "top_collectively_direnv_only_paths": top_paths(direnv_only, path_sizes, args.top), |
| 163 | + "projects": projects, |
| 164 | + } |
| 165 | + |
| 166 | + out_dir = Path.home() / ".cache" / "ncdu" |
| 167 | + out_dir.mkdir(parents=True, exist_ok=True) |
| 168 | + output = Path(args.output).expanduser() if args.output else out_dir / f"direnv-gc-roots-{generated_at:%Y%m%d-%H%M%S}.json" |
| 169 | + output = output.resolve() |
| 170 | + output.parent.mkdir(parents=True, exist_ok=True) |
| 171 | + temporary = output.with_suffix(output.suffix + ".tmp") |
| 172 | + temporary.write_text(json.dumps(artifact, indent=2) + "\n") |
| 173 | + os.replace(temporary, output) |
| 174 | + latest = out_dir / "latest-direnv-gc-roots.json" |
| 175 | + latest.unlink(missing_ok=True) |
| 176 | + latest.symlink_to(output) |
| 177 | + |
| 178 | + print(f"Direnv GC-root artifact: {output}") |
| 179 | + print(f"Raw direnv roots: {artifact['raw_direnv_root_count']}") |
| 180 | + print(f"Unique direnv targets: {artifact['unique_direnv_target_count']}") |
| 181 | + print(f"Projects: {artifact['direnv_project_count']}") |
| 182 | + print(f"All direnv closures: {human_size(artifact['all_direnv_closure_nar_bytes'])}") |
| 183 | + print(f"Collectively direnv-only: {human_size(artifact['collectively_direnv_only_nar_bytes'])}") |
| 184 | + print() |
| 185 | + print(f"{'MARGINAL':>11} {'CLOSURE':>11} {'AGE(d)':>8} PROJECT") |
| 186 | + for item in projects[: args.top]: |
| 187 | + age = "?" if item["direnv_age_days"] is None else f"{item['direnv_age_days']:.1f}" |
| 188 | + print( |
| 189 | + f"{human_size(item['marginal_unique_nar_bytes']):>11} " |
| 190 | + f"{human_size(item['closure_nar_bytes']):>11} {age:>8} {item['project']}" |
| 191 | + ) |
| 192 | + return 0 |
| 193 | + |
| 194 | + |
| 195 | +if __name__ == "__main__": |
| 196 | + raise SystemExit(main()) |
0 commit comments