From b1813cb0426b7533f87134e7d398ab892b940781 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 11 Aug 2024 14:47:14 +0200 Subject: [PATCH] Add history command Closes #18 --- src/nvd | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++- src/nvd.1 | 81 +++++++++++++++++++++++++++-- 2 files changed, 227 insertions(+), 6 deletions(-) diff --git a/src/nvd b/src/nvd index c7c58d5..0634da6 100755 --- a/src/nvd +++ b/src/nvd @@ -3,6 +3,7 @@ # nvd - Nix/NixOS package version diff tool # # Copyright 2021-2024 Bryan Gardiner +# Copyright 2024 Felix Uhl # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -38,17 +39,18 @@ import argparse import fnmatch +from itertools import pairwise import os import os.path import re import subprocess import sys -from abc import ABC, abstractmethod +from abc import abstractmethod from functools import cmp_to_key, total_ordering from pathlib import Path from signal import SIGPIPE, SIG_DFL, signal from subprocess import PIPE -from typing import Any, Iterable, Optional, Union +from typing import Iterable, List, Optional, Union signal(SIGPIPE, SIG_DFL) # Python handles SIGPIPE improperly by default. @@ -99,12 +101,39 @@ SEL_RIGHT_ONLY_UNSELECTED = "r" SEL_NO_SELECTED_SETS = "" NIX_STORE_PATH_REGEX = re.compile(r"^/nix/store/[a-z0-9]+-(.+?)(-([0-9].*?))?(\.drv)?$") +PROFILE_LINK_REGEX = re.compile(r"^(?P.+)-(?P[0-9]+)-link$") ALPHANUM_RE = re.compile(r"\w") # Alternatively [0-9a-zA-Z] would work. def raise_arg(e): raise e +@total_ordering +class ProfileVersion: + def __init__(self, path: Path): + match = PROFILE_LINK_REGEX.match(str(path)) + assert match is not None, f"Couldn't parse profile link: {path}" + self._path = path.resolve() + self._version = int(match.group("version")) + + def __lt__(self, other): + if not isinstance(other, ProfileVersion): + return NotImplemented + + return self._version < other._version + + def __eq__(self, other): + if not isinstance(other, ProfileVersion): + return NotImplemented + + return self._version == other._version and self._path == other._path + + def path(self): + return self._path + + def version(self): + return self._version + class StorePath: def __init__(self, path: Union[str, Path]): assert str(path).startswith("/nix/store/"), \ @@ -753,6 +782,50 @@ def query_nix_version() -> Version: raise RuntimeError( "Could not determine Nix version from 'nix-store --version' output: {output!r}") +def read_symlink_chain(start_path: Path) -> List[Path]: + link_chain = [start_path] + + current_path = start_path + while True: + try: + link_target = current_path.readlink() + # If path does not exist or is not a symlink, we've reached the end of the chain + except (FileNotFoundError, OSError): + break + + if link_target in link_chain: + raise RuntimeError(f"Symlink loop detected: {link_chain} -> {link_target}") + + if not link_target.is_absolute(): + link_target = (current_path.parent / link_target) + + link_chain.append(link_target) + current_path = link_target + + return link_chain + +def is_profile_link(path: Path) -> bool: + match = PROFILE_LINK_REGEX.match(str(path)) + return match is not None + +def is_link_to_profile(path: Path, profile_name: str) -> bool: + match = PROFILE_LINK_REGEX.match(str(path)) + return match is not None and match.group("profile") == profile_name + +def make_profile_generation_list(profile_link: Path, minimum_version: int) -> List[ProfileVersion]: + profile_name = PROFILE_LINK_REGEX.match(str(profile_link)).group("profile") + all_profile_links = [l for l in profile_link.parent.iterdir() if is_link_to_profile(l, profile_name)] + all_profile_versions = [ProfileVersion(l) for l in all_profile_links] + all_profile_versions.sort() + profile_versions = [pv for pv in all_profile_versions if pv.version() >= minimum_version] + + if len(profile_versions) < 1: + sys.stderr.write(f"Profile {profile_name} has no versions >= {minimum_version}.\n") + sys.stderr.write(f"Available versions: {[pv.version() for pv in all_profile_versions]}\n") + sys.exit(1) + + return profile_versions + def run_list( *, root, @@ -928,6 +1001,56 @@ def run_diff( f"{diff_closure_disk_usage_str})." ) +def run_history( + *, + profile, + minimum_version: int, + list_oldest: bool, + no_version_suffix_highlight: bool, + sort_comparator: PackageListEntryComparator, +): + profile_path = Path(profile) + + if not profile_path.exists(): + sys.stderr.write(f"Path does not exist: {profile_path}\n") + sys.stderr.write("On non-NixOS systems, try:\nnvd history --profile ~/.nix-profile\n") + sys.exit(1) + + symlink_chain = read_symlink_chain(profile_path) + + active_profile_links = [l for l in symlink_chain if is_profile_link(l)] + + if len(active_profile_links) < 1: + sys.stderr.write(f"No versioned profile link found in chain: {[str(l) for l in symlink_chain]}\n") + sys.exit(1) + + all_profiles = make_profile_generation_list(active_profile_links[0], minimum_version) + + # Show list of packages in the oldest profile if desired. + oldest_profile = all_profiles[0] + + if list_oldest: + print(f"--- Version {oldest_profile.version()}:") + print(f">>> {oldest_profile.path()}") + run_list( + root=oldest_profile.path(), + only_selected=False, + name_patterns=[], + sort_comparator=sort_comparator + ) + else: + print(f"Contents of profile version {oldest_profile.version()} were omitted, use --list-oldest to show them.") + + # Show diff between all profiles. + for [base_profile, displayed_profile] in pairwise(all_profiles): + print(f"\n--- Version {displayed_profile.version()}:") + run_diff( + root1=base_profile.path(), + root2=displayed_profile.path(), + no_version_suffix_highlight=no_version_suffix_highlight, + sort_comparator=sort_comparator, + ) + def parse_args(): def add_sort_arg(p: argparse.ArgumentParser) -> None: p.add_argument( @@ -993,6 +1116,30 @@ def parse_args(): dest="name_patterns", help="Patterns (globs) that must all match package names.") + history_parser = subparsers.add_parser( + "history", + help="Show the history of a Nix profile.") + add_sort_arg(history_parser) + history_parser.add_argument( + "-p", "--profile", + default="/nix/var/nix/profiles/system", + help="The Nix profile to work with.") + history_parser.add_argument( + "-m", "--minimum-version", + # link 0 doesn't exist, but might become the "initial" empty state in the future. + default=1, + type=int, + help="Minimum version of this profile to display.") + history_parser.add_argument( + "--list-oldest", + action="store_true", + help="Additionally list all packages in the first displayed profile." + ) + history_parser.add_argument( + "--no-version-suffix-highlight", + action="store_true", + help="Disable highlighting of the changed portions of versions.") + return parser.parse_args() def main(): @@ -1038,6 +1185,7 @@ def main(): { "list": run_list, "diff": run_diff, + "history": run_history, }[action](**args) # Importing this module is kinda broken because of the constant initializations diff --git a/src/nvd.1 b/src/nvd.1 index 264b796..9b0c398 100644 --- a/src/nvd.1 +++ b/src/nvd.1 @@ -33,6 +33,19 @@ nvd \- Nix/NixOS package version diff tool .B ]* .RE .P +.B nvd [ GLOBAL OPTIONS ] history +.RS +.B [ -p|--profile +.I profile +.B ] +.br +.B [ -m|--minimum-version +.I version +.B ] +.br +.B [ --list-oldest ] +.RE +.P .B GLOBAL OPTIONS: .P .RS @@ -66,6 +79,10 @@ The .B list command displays the list of packages included in a closure, optionally filtered to some criteria. +The +.B history +command displays the difference between each generation of a profile, optionally +also listing the packages included in the oldest generation. .P .B nvd distinguishes between packages that are explicitly included in @@ -169,17 +186,56 @@ command, only print out names matching all of the given patterns. The character matches exactly one of any character, and the character .B * matches zero or more of any character. +.TP +-p|--profile +For the +.B history +command, this is the profile to use. It must be the path of a versioned profile +symlink or a symlink to such a path. Valid examples include: +.br + - +.B +/nix/var/nix/profiles/system +(the default) +.br + - /nix/var/nix/profiles/system-1-link +.br + - /nix/var/nix/profiles/default +.br + - ~/.nix-profile +.br + - ~/.local/state/nix/profiles/profile +.br + - /nix/var/nix/profiles/per-user/myself/profile +.br +The default is the system profile, which will fail on most non-NixOS systems. +.TP +-m|--minimum-version +For the +.B history +command, the first version from which to start comparing generations. Must be +an integer. +.TP +--list-oldest +For the +.B history +command, also list the packages included in the oldest generation. They are +omitted by default. .SH OUTPUT .P If the .B diff -command is requested, then the output of +or +.B history +commands are requested, then the output of .B nvd displays the given names of the two store paths being compared on the first two -lines. After this, for both the +lines. After this, for the .B diff -and +, .B list +, and +.B history commands, package names, versions, and other information is printed out, with one line per package name, in lexicographic order. When producing a diff, packages are grouped by the type of change: newly added or removed packages, @@ -199,6 +255,15 @@ The first two lines of output display the paths being compared: >>> /nix/var/nix/profiles/system-2-link .EE .RE +The +.B history +command also displays the generation number of the profile and shows the +absolute paths of the two generations it is comparing: +.RS +.EX +--- Version 23: +<<< /nix/store/3j30bacl8zsfhg317bv7ig985y8jd5k2-nixos-system-hostname-24.05 +>>> /nix/store/vsjs7p1cc0qwhqaiqk16p0limjs1smpv-nixos-system-hostname-24.05 .P After this, packages are listed. Each package is displayed on a line like the following. @@ -238,7 +303,15 @@ state, and the .B diff command can display all states but the .B I -state. Packages can be displayed with +state. The +.B history +command only displays the same states as +.B diff +by default, but can also display the +.B I +state when +.B --list-oldest +is passed. Packages can be displayed with .B C install state for a few different reasons, including the number of copies of an existing version in the closure changing, or due to the selection state changing