no more _2 _3 in your flake.lock.
usage:
cat flake.lock | python nixfollows.py| #!/usr/bin/env python3 | |
| import json | |
| import re | |
| import sys | |
| def safe_nix_id(s): | |
| """确保 Nix 属性名符合规范""" | |
| if re.match(r"^[a-zA-Z_][a-zA-Z0-9_-]*$", s): | |
| return s | |
| return f'"{s}"' | |
| def resolve_ref(ref, nodes, root_key): | |
| """解析 flake.lock 中的路径式引用""" | |
| if isinstance(ref, str): | |
| return ref | |
| if isinstance(ref, list): | |
| curr = root_key | |
| for step in ref: | |
| curr_node = nodes.get(curr, {}) | |
| curr_inputs = curr_node.get("inputs", {}) | |
| if step not in curr_inputs: | |
| return None | |
| next_ref = curr_inputs[step] | |
| if isinstance(next_ref, str): | |
| curr = next_ref | |
| elif isinstance(next_ref, list): | |
| curr = resolve_ref(next_ref, nodes, root_key) | |
| return curr | |
| return None | |
| def is_flake_false(node): | |
| orig = node.get("original", {}) | |
| return orig.get("flake") is False | |
| def build_url(node): | |
| """还原 Nix 识别的 URL""" | |
| orig = node.get("original", {}) | |
| t = orig.get("type") | |
| if not t: | |
| orig = node.get("locked", {}) | |
| t = orig.get("type") | |
| if not t: | |
| return '""' | |
| if t in ("github", "gitlab", "sourcehut"): | |
| host = orig.get("host") | |
| owner = orig.get("owner", "") | |
| repo = orig.get("repo", "") | |
| base = ( | |
| f"gitlab:{host}/{owner}/{repo}" | |
| if t == "gitlab" and host | |
| else f"{t}:{owner}/{repo}" | |
| ) | |
| ref = orig.get("ref") | |
| rev = orig.get("rev") | |
| if ref: | |
| base += f"/{ref}" | |
| elif rev and "original" not in node: | |
| base += f"/{rev}" | |
| if "dir" in orig: | |
| base += f"?dir={orig['dir']}" | |
| return f'"{base}"' | |
| elif t == "git": | |
| url = orig.get("url", "") | |
| base = ( | |
| url if url.startswith("git://") or url.startswith("git+") else f"git+{url}" | |
| ) | |
| if "ref" in orig: | |
| base += f"?ref={orig['ref']}" | |
| if "dir" in orig: | |
| base += f"&dir={orig['dir']}" if "?" in base else f"?dir={orig['dir']}" | |
| return f'"{base}"' | |
| elif t == "path": | |
| p = orig.get("path", "") | |
| return f'"path:{p}"' if not p.startswith("path:") else f'"{p}"' | |
| elif t == "tarball": | |
| return f'"{orig.get("url", "")}"' | |
| elif "url" in orig: | |
| return f'"{orig["url"]}"' | |
| return '""' | |
| def get_follows(node_key, nodes, root_key, top_level_names, memo): | |
| """递归计算当前节点需要向外抛出的 follows 路径,支持穿透隐式依赖""" | |
| if node_key in memo: | |
| return memo[node_key] | |
| memo[node_key] = [] # 防御性防止死循环 | |
| node = nodes.get(node_key, {}) | |
| follows_list = [] | |
| for local_name, child_ref in node.get("inputs", {}).items(): | |
| child_key = resolve_ref(child_ref, nodes, root_key) | |
| if not child_key: | |
| continue | |
| # 如果这个子依赖也是一个顶层依赖,直接 follow | |
| if local_name in top_level_names: | |
| follows_list.append(([local_name], local_name)) | |
| else: | |
| # 如果这个子依赖不提取,则往下穿透,寻找其内部是否有需要 follow 的顶层依赖 | |
| child_follows = get_follows( | |
| child_key, nodes, root_key, top_level_names, memo | |
| ) | |
| for sub_path, target in child_follows: | |
| follows_list.append(([local_name] + sub_path, target)) | |
| memo[node_key] = follows_list | |
| return follows_list | |
| def main(): | |
| input_data = sys.stdin.read() | |
| if not input_data.strip(): | |
| return | |
| try: | |
| lock_data = json.loads(input_data) | |
| except json.JSONDecodeError: | |
| sys.stderr.write("Error: Invalid JSON format from stdin.\n") | |
| sys.exit(1) | |
| nodes = lock_data.get("nodes", {}) | |
| root_key = lock_data.get("root", "root") | |
| # 1. 广度优先遍历(BFS),找到所有可达的 Node,并统计每个 `local_name` 的全局使用频率 | |
| reachable_nodes = set() | |
| queue = [root_key] | |
| while queue: | |
| curr = queue.pop(0) | |
| if curr in reachable_nodes: | |
| continue | |
| reachable_nodes.add(curr) | |
| node = nodes.get(curr, {}) | |
| for child_ref in node.get("inputs", {}).values(): | |
| child_key = resolve_ref(child_ref, nodes, root_key) | |
| if child_key: | |
| queue.append(child_key) | |
| name_counts = {} | |
| for r_key in reachable_nodes: | |
| node = nodes.get(r_key, {}) | |
| for local_name, child_ref in node.get("inputs", {}).items(): | |
| if resolve_ref(child_ref, nodes, root_key): | |
| name_counts[local_name] = name_counts.get(local_name, 0) + 1 | |
| # 2. 决定哪些名字应当提取到顶层 (所有的 root inputs 加上 全局被复用 >= 2 次的名字) | |
| top_level_names = set() | |
| root_inputs = nodes.get(root_key, {}).get("inputs", {}) | |
| for name in root_inputs: | |
| if resolve_ref(root_inputs[name], nodes, root_key): | |
| top_level_names.add(name) | |
| for name, count in name_counts.items(): | |
| if count > 1: | |
| top_level_names.add(name) | |
| # 3. 再进行一次 BFS,为所有 top_level_names 分配对应的 lock node (采用最近原则) | |
| global_inputs = {} | |
| queue = [(root_key, 0)] | |
| visited_nodes = set() | |
| while queue: | |
| curr_key, depth = queue.pop(0) | |
| if curr_key in visited_nodes: | |
| continue | |
| visited_nodes.add(curr_key) | |
| node = nodes.get(curr_key, {}) | |
| for local_name, child_ref in node.get("inputs", {}).items(): | |
| child_key = resolve_ref(child_ref, nodes, root_key) | |
| if not child_key: | |
| continue | |
| # 只有初次遇到且被标记为顶层的名字,才注册进 global_inputs | |
| if local_name in top_level_names and local_name not in global_inputs: | |
| global_inputs[local_name] = { | |
| "node_key": child_key, | |
| "depth": depth + 1, | |
| "follows": [], | |
| } | |
| queue.append((child_key, depth + 1)) | |
| # 4. 递归收集所有顶层模块需要的穿透 follows | |
| memo = {} | |
| for name, info in global_inputs.items(): | |
| info["follows"] = get_follows( | |
| info["node_key"], nodes, root_key, top_level_names, memo | |
| ) | |
| # ---------------- 5. 输出 Nix 格式 ---------------- | |
| out = ["{"] | |
| current_depth = -1 | |
| # 按照深度和名称排序 | |
| sorted_items = sorted(global_inputs.items(), key=lambda x: (x[1]["depth"], x[0])) | |
| for name, info in sorted_items: | |
| depth = info["depth"] | |
| if depth != current_depth: | |
| if depth == 1: | |
| out.append(" # direct deps") | |
| elif depth == 2: | |
| out.append(" # shared indirect deps (depth 2)") | |
| else: | |
| out.append(f" # shared indirect deps (depth {depth})") | |
| current_depth = depth | |
| node_key = info["node_key"] | |
| node = nodes.get(node_key, {}) | |
| url_str = build_url(node) | |
| follows = info["follows"] | |
| is_flake = not is_flake_false(node) | |
| safe_name = safe_nix_id(name) | |
| if not follows and is_flake: | |
| out.append(f" {safe_name}.url = {url_str};") | |
| else: | |
| out.append(f" {safe_name} = {{") | |
| out.append(f" url = {url_str};") | |
| if not is_flake: | |
| out.append(" flake = false;") | |
| # 按字母顺序打印 follows 避免混乱 | |
| for path, tgt in sorted(follows, key=lambda x: (".".join(x[0]), x[1])): | |
| safe_path = ".".join(f"inputs.{safe_nix_id(p)}" for p in path) | |
| out.append(f' {safe_path}.follows = "{tgt}";') | |
| out.append(" };") | |
| out.append("}") | |
| print("\n".join(out)) | |
| if __name__ == "__main__": | |
| main() |
for the following flake input, there's 10 _2 in the flake.lock. (20260526)
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
nixpkgs-2511.url = "github:nixos/nixpkgs/25.11";
impermanence = {
url = "github:nix-community/impermanence";
inputs.nixpkgs.follows = "nixpkgs";
inputs.home-manager.follows = "home-manager";
};
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
plasma-manager = {
url = "github:nix-community/plasma-manager";
inputs.nixpkgs.follows = "nixpkgs";
inputs.home-manager.follows = "home-manager";
};
nix-gaming = {
url = "github:fufexan/nix-gaming";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-wsl = {
url = "github:nix-community/NixOS-WSL/main";
inputs.nixpkgs.follows = "nixpkgs";
};
nur = {
url = "github:nix-community/NUR";
inputs.nixpkgs.follows = "nixpkgs";
};
stylix = {
url = "github:nix-community/stylix";
inputs.nixpkgs.follows = "nixpkgs";
inputs.nur.follows = "nur";
};
niri = {
url = "github:sodiboo/niri-flake";
inputs.nixpkgs.follows = "nixpkgs";
inputs.nixpkgs-stable.follows = "nixpkgs";
};
dankMaterialShell = {
url = "github:AvengeMedia/DankMaterialShell";
inputs.nixpkgs.follows = "nixpkgs";
};
noctalia = {
url = "github:noctalia-dev/noctalia-shell";
inputs.nixpkgs.follows = "nixpkgs";
};
catppuccin = {
url = "github:catppuccin/nix";
inputs.nixpkgs.follows = "nixpkgs";
};
mybar = {
url = "github:lxl66566/mybar";
inputs.nixpkgs.follows = "nixpkgs";
};
};the script output will be:
{
# direct deps
catppuccin = {
url = "github:catppuccin/nix";
inputs.nixpkgs.follows = "nixpkgs";
};
dankMaterialShell = {
url = "github:AvengeMedia/DankMaterialShell";
inputs.flake-compat.follows = "flake-compat";
inputs.nixpkgs.follows = "nixpkgs";
};
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
impermanence = {
url = "github:nix-community/impermanence";
inputs.home-manager.follows = "home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
mybar = {
url = "github:lxl66566/mybar";
inputs.flake-parts.follows = "flake-parts";
inputs.nixpkgs.follows = "nixpkgs";
inputs.treefmt-nix.follows = "treefmt-nix";
};
niri = {
url = "github:sodiboo/niri-flake";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-gaming = {
url = "github:fufexan/nix-gaming";
inputs.flake-parts.follows = "flake-parts";
inputs.git-hooks.inputs.flake-compat.follows = "flake-compat";
inputs.git-hooks.inputs.gitignore.inputs.nixpkgs.follows = "nixpkgs";
inputs.git-hooks.inputs.nixpkgs.follows = "nixpkgs";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-wsl = {
url = "github:nix-community/NixOS-WSL/main";
inputs.flake-compat.follows = "flake-compat";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
nixpkgs-2511.url = "github:nixos/nixpkgs/25.11";
noctalia = {
url = "github:noctalia-dev/noctalia-shell";
inputs.nixpkgs.follows = "nixpkgs";
inputs.noctalia-qs.inputs.nixpkgs.follows = "nixpkgs";
inputs.noctalia-qs.inputs.systems.follows = "systems";
inputs.noctalia-qs.inputs.treefmt-nix.follows = "treefmt-nix";
};
nur = {
url = "github:nix-community/NUR";
inputs.flake-parts.follows = "flake-parts";
inputs.nixpkgs.follows = "nixpkgs";
};
plasma-manager = {
url = "github:nix-community/plasma-manager";
inputs.home-manager.follows = "home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
stylix = {
url = "github:nix-community/stylix";
inputs.flake-parts.follows = "flake-parts";
inputs.nixpkgs.follows = "nixpkgs";
inputs.nur.follows = "nur";
inputs.systems.follows = "systems";
};
# shared indirect deps (depth 2)
flake-compat.url = "github:NixOS/flake-compat";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs-lib";
};
systems.url = "github:nix-systems/default";
treefmt-nix = {
url = "github:numtide/treefmt-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# shared indirect deps (depth 3)
nixpkgs-lib.url = "github:nix-community/nixpkgs.lib";
}
put it into your flake.nix, and execute nix flake lock. Now there's no _2 in your flake.
current problem: input like niri/nixpkgs-stable will not follows nixpkgs. although there's no _2, but there's still inputs has been double imported.