Skip to content

Commit 591e873

Browse files
committed
Audit direnv Nix retention
1 parent 0b0540a commit 591e873

3 files changed

Lines changed: 262 additions & 9 deletions

File tree

dotfiles/agents/skills/disk-space-assessment/SKILL.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@ description: Measure and explain disk usage without deleting data, producing reu
77

88
Build a reproducible picture of disk usage. Stop at findings and proposed actions; use `disk-space-cleanup` when the user authorizes remediation.
99

10-
Read `references/ignore-paths.md` before scanning. Read `references/observed-heavy-hitters.md` when triaging this machine or interpreting familiar paths.
10+
Read `references/ignore-paths.md` before scanning. Read `references/direnv-gc-roots.md` for Nix development-root attribution. Read `references/observed-heavy-hitters.md` when triaging this machine or interpreting familiar paths.
1111

1212
## Required Artifact Contract
1313

1414
Always create a reusable, timestamped `ncdu` export for every filesystem or major root assessed. Never make an interactive `ncdu` session, transient `/tmp` export, or `du` output the only record of an assessment.
1515

16-
Use the local wrapper:
16+
Always scan `/` with privilege. An unprivileged root scan is incomplete by design because it cannot measure private service state. Also scan each separately mounted pressured filesystem because `safe_ncdu -x` does not cross mount boundaries:
1717

1818
```bash
19+
/run/wrappers/bin/sudo -n env HOME=/home/imalison safe_ncdu /
1920
safe_ncdu /home
2021
safe_ncdu /nix/store
21-
sudo -n env HOME=/home/imalison safe_ncdu /
2222
```
2323

2424
`safe_ncdu` writes these durable artifacts under `~/.cache/ncdu/`:
@@ -33,7 +33,7 @@ Keep timestamped files intact for iterative analysis. Report their absolute path
3333
## Workflow
3434

3535
1. Record filesystem pressure and topology.
36-
2. Choose scan roots that cover the pressured filesystem without crossing mounts.
36+
2. Run a privileged `/` scan and choose additional roots for separately mounted filesystems.
3737
3. Create reusable `safe_ncdu` snapshots before any cleanup.
3838
4. Analyze snapshots repeatedly with `top` and `open`; do not rescan for every question.
3939
5. Attribute special stores such as Nix separately.
@@ -52,12 +52,12 @@ Record used/free space and whether `/home`, `/nix`, or other large paths are sep
5252

5353
## 2. Select Scan Roots
5454

55-
Prefer one-filesystem coverage:
55+
Require one-filesystem coverage:
5656

57-
- Scan `/` for root accounting.
57+
- Always scan `/` with `/run/wrappers/bin/sudo -n env HOME=/home/imalison`; never substitute an unprivileged root scan. On NixOS, use the setuid wrapper explicitly because a non-setuid `sudo` store binary may appear earlier on `PATH`.
5858
- Scan separately mounted `/home` and `/nix/store` independently.
5959
- Add a focused root such as `~/Projects` when the first snapshot identifies it as dominant.
60-
- Use privileged root scans when unprivileged results undercount private service state.
60+
- Treat a failed privileged root scan as an explicit coverage gap; do not silently fall back to an unprivileged scan.
6161

6262
Inspect exclusions before a long scan:
6363

@@ -72,14 +72,14 @@ Update `references/ignore-paths.md` and the implementation of `safe_ncdu` togeth
7272
Run scans early enough that cleanup does not destroy the evidence:
7373

7474
```bash
75+
/run/wrappers/bin/sudo -n env HOME=/home/imalison safe_ncdu /
7576
safe_ncdu /home
7677
safe_ncdu /nix/store
77-
sudo -n env HOME=/home/imalison safe_ncdu /
7878
```
7979

8080
If `safe_ncdu` is unavailable, source or run `/srv/dotfiles/dotfiles/lib/functions/safe_ncdu`. If `ncdu` itself is missing, use Nix temporarily rather than substituting a non-reusable interactive scan.
8181

82-
Do not store the privileged scan in root's home. Set `HOME=/home/imalison` so all artifacts remain together and are available to later sessions.
82+
Do not store the privileged scan in root's home. Set `HOME=/home/imalison` so all artifacts remain together and are available to later sessions. Restore ownership to `imalison:users` if `sudo` creates root-owned snapshot artifacts.
8383

8484
## 4. Analyze Iteratively
8585

@@ -113,6 +113,14 @@ nix-store --gc --print-roots
113113

114114
Use `/srv/dotfiles/dotfiles/lib/functions/find_store_path_gc_roots` and `nix why-depends` to explain why a large path is retained. Inspect `.direnv/flake-profile-*`, `result*` symlinks, system generations, and current/booted system closures. Prefer `nix_store_audit` over an initial `du -sh /nix/store`, which is slow and does not explain retention.
115115

116+
Always create a reusable `.direnv` GC-root audit artifact when direnv roots exist:
117+
118+
```bash
119+
python /srv/dotfiles/dotfiles/agents/skills/disk-space-assessment/scripts/direnv_gc_roots_audit.py --top 30
120+
```
121+
122+
This separates collectively direnv-only paths from paths retained by non-direnv roots and estimates each project's marginal uniquely retained footprint. Read `references/direnv-gc-roots.md` before interpreting or acting on the result.
123+
116124
## Assessment Handoff
117125

118126
Return:
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Auditing `.direnv` Nix GC Roots
2+
3+
Audit direnv roots as a retention graph, not as a list of symlink sizes.
4+
5+
## Required Audit
6+
7+
Run:
8+
9+
```bash
10+
python /srv/dotfiles/dotfiles/agents/skills/disk-space-assessment/scripts/direnv_gc_roots_audit.py --top 30
11+
```
12+
13+
The script writes a timestamped JSON artifact under `~/.cache/ncdu/` and updates `latest-direnv-gc-roots.json`. Preserve the timestamped artifact with the related ncdu snapshots.
14+
15+
## Interpret the Measures
16+
17+
- **Raw roots**: every `.direnv` entry reported by `nix-store --gc --print-roots`; several may point to the same store target.
18+
- **Unique targets**: deduplicated store paths directly referenced by those roots.
19+
- **Project closure**: union of the transitive closures of one project's direnv targets.
20+
- **Collectively direnv-only**: direnv closure paths absent from the union of every observed non-direnv root closure. Removing all direnv roots could make these collectible.
21+
- **Outside non-direnv roots**: one project's closure after subtracting non-direnv-retained paths. This remains an upper bound because other direnv projects may share it.
22+
- **Marginal unique**: paths retained by exactly one direnv project and no non-direnv root. This is the best logical NAR-size estimate of what removing only that project's `.direnv` could make collectible.
23+
24+
Rank projects by marginal unique size, then inspect age, worktree state, active shells/builds, and project importance. Large closure size with near-zero marginal unique size indicates heavy sharing and little immediate benefit from removing that project alone.
25+
26+
## Validate Candidates
27+
28+
For a candidate project:
29+
30+
```bash
31+
find <project>/.direnv -maxdepth 1 -type l -printf '%TY-%Tm-%Td %TH:%TM %p -> %l\n' | sort
32+
git -C <project> status --short
33+
git worktree list --porcelain
34+
ps aux | rg '<project>|direnv|nix develop|nix-shell'
35+
```
36+
37+
Resolve a surprising retained store path with:
38+
39+
```bash
40+
/srv/dotfiles/dotfiles/lib/functions/find_store_path_gc_roots /nix/store/<path>
41+
nix why-depends <profile-or-shell-target> /nix/store/<path>
42+
```
43+
44+
## Limitations
45+
46+
- NAR sizes are logical database sizes, not guaranteed physical bytes reclaimed.
47+
- The graph is a point-in-time view; shells and agents can add or replace roots during the run.
48+
- Nix paths may also be retained by roots created immediately after enumeration.
49+
- Run the audit immediately before and after any cleanup campaign and use actual `df` change as the final measure.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

Comments
 (0)