Last active
May 15, 2026 18:11
-
-
Save sini/8a9b24923c7e35c930274c1a847753ac to your computer and use it in GitHub Desktop.
nixos-module-graph: Visualize non-nixpkgs import tree of any NixOS configuration with flake input labeling
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # nixos-module-graph.nix — Visualize the non-nixpkgs import tree of a NixOS configuration | |
| # | |
| # Reads evalModules.graph (available on every nixosSystem result since nixpkgs 24.05+), | |
| # resolves nix store paths back to flake input names, filters out nixpkgs base modules, | |
| # and renders the remaining user-defined + third-party module import tree as indented text. | |
| # | |
| # Usage: | |
| # nix eval --impure --raw --expr '(import /tmp/nixos-module-graph.nix { flakePath = toString /home/sini/Documents/repos/den-configs/slashfiles; host = "evo-x2"; })' | |
| # | |
| # Or edit the defaults below and run: | |
| # nix eval --impure --raw -f nixos-module-graph.nix | |
| # | |
| { | |
| flakePath ? toString /home/sini/Documents/repos/sini/nix-config, | |
| host ? "bitstream", | |
| }: | |
| let | |
| flake = builtins.getFlake flakePath; | |
| graph = flake.nixosConfigurations.${host}.graph; | |
| inputs = flake.inputs; | |
| # --- Store path -> flake input name resolution --- | |
| inputPairs = builtins.concatLists (map (name: | |
| let path = builtins.tryEval (toString (inputs.${name}.outPath or inputs.${name})); | |
| in if path.success then [{ storePath = path.value; inputName = name; }] else [] | |
| ) (builtins.attrNames inputs)) | |
| ++ [{ storePath = toString flake.outPath; inputName = "self"; }]; | |
| resolve = file: | |
| let | |
| matches = builtins.filter (p: | |
| builtins.substring 0 (builtins.stringLength p.storePath) file == p.storePath | |
| ) inputPairs; | |
| sorted = builtins.sort (a: b: | |
| builtins.stringLength a.storePath > builtins.stringLength b.storePath | |
| ) matches; | |
| best = if sorted != [] then builtins.head sorted else null; | |
| in | |
| if best != null then { | |
| input = best.inputName; | |
| rel = builtins.substring (builtins.stringLength best.storePath + 1) 9999 file; | |
| } | |
| else if builtins.match ".*flake\\.nix#(.*)" file != null then { | |
| input = "self"; | |
| rel = builtins.head (builtins.match ".*flake\\.nix#(.*)" file); | |
| } | |
| else { input = "?"; rel = file; }; | |
| # --- Filtering --- | |
| # Filter out all nixpkgs-* inputs (the NixOS base module set). | |
| # These are ~4000+ modules that add noise. Everything else is kept: | |
| # your own modules (self), flake inputs (agenix, disko, etc.), unknowns. | |
| isNixpkgs = input: builtins.match "nixpkgs.*" input != null; | |
| # --- Tree rendering --- | |
| mkIndent = depth: builtins.concatLists (builtins.genList (_: [" " " "]) depth); | |
| indent = depth: builtins.concatStringsSep "" (mkIndent depth); | |
| # Walk the full graph tree. Nixpkgs nodes are skipped but their | |
| # non-nixpkgs children are promoted up to the parent's level. | |
| renderNode = depth: node: | |
| let | |
| r = resolve node.file; | |
| skip = isNixpkgs r.input; | |
| label = if r.rel != "" then "${r.input}:${r.rel}" else r.input; | |
| nixpkgsChildren = builtins.length ( | |
| builtins.filter (c: isNixpkgs (resolve c.file).input) node.imports | |
| ); | |
| nonNixpkgsImports = builtins.filter (c: | |
| ! isNixpkgs (resolve c.file).input | |
| ) node.imports; | |
| nixNote = if nixpkgsChildren > 0 | |
| then " (+${toString nixpkgsChildren} nixpkgs)" | |
| else ""; | |
| childLines = builtins.concatLists ( | |
| map (renderNode (depth + 1)) nonNixpkgsImports | |
| ); | |
| in | |
| if skip then | |
| # Skip this node, but recurse to find non-nixpkgs children | |
| builtins.concatLists (map (renderNode depth) nonNixpkgsImports) | |
| else | |
| ["${indent depth}${label}${nixNote}"] ++ childLines; | |
| lines = builtins.concatLists (map (renderNode 0) graph); | |
| # Remove exact duplicate lines (from deferred module wrapper duplication | |
| # and os->linux forwarding that produces identical entries). | |
| # Use a fold that tracks seen lines by hash to handle special characters. | |
| dedup = list: | |
| let | |
| go = acc: item: | |
| let h = builtins.hashString "sha256" item; | |
| in if acc.seen ? ${h} | |
| then acc | |
| else { seen = acc.seen // { ${h} = true; }; out = acc.out ++ [item]; }; | |
| in (builtins.foldl' go { seen = {}; out = []; } list).out; | |
| in builtins.concatStringsSep "\n" (dedup lines) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment