mirror of
https://gitlab.com/khumba/nvd.git
synced 2024-11-10 06:59:29 +01:00
parent
ae4749c20d
commit
b1813cb042
2 changed files with 227 additions and 6 deletions
152
src/nvd
152
src/nvd
|
@ -3,6 +3,7 @@
|
||||||
# nvd - Nix/NixOS package version diff tool
|
# nvd - Nix/NixOS package version diff tool
|
||||||
#
|
#
|
||||||
# Copyright 2021-2024 Bryan Gardiner <bog@khumba.net>
|
# Copyright 2021-2024 Bryan Gardiner <bog@khumba.net>
|
||||||
|
# Copyright 2024 Felix Uhl <nvd@mail.felix-uhl.de>
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
|
@ -38,17 +39,18 @@
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
from itertools import pairwise
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import abstractmethod
|
||||||
from functools import cmp_to_key, total_ordering
|
from functools import cmp_to_key, total_ordering
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from signal import SIGPIPE, SIG_DFL, signal
|
from signal import SIGPIPE, SIG_DFL, signal
|
||||||
from subprocess import PIPE
|
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.
|
signal(SIGPIPE, SIG_DFL) # Python handles SIGPIPE improperly by default.
|
||||||
|
|
||||||
|
@ -99,12 +101,39 @@ SEL_RIGHT_ONLY_UNSELECTED = "r"
|
||||||
SEL_NO_SELECTED_SETS = ""
|
SEL_NO_SELECTED_SETS = ""
|
||||||
|
|
||||||
NIX_STORE_PATH_REGEX = re.compile(r"^/nix/store/[a-z0-9]+-(.+?)(-([0-9].*?))?(\.drv)?$")
|
NIX_STORE_PATH_REGEX = re.compile(r"^/nix/store/[a-z0-9]+-(.+?)(-([0-9].*?))?(\.drv)?$")
|
||||||
|
PROFILE_LINK_REGEX = re.compile(r"^(?P<profile>.+)-(?P<version>[0-9]+)-link$")
|
||||||
|
|
||||||
ALPHANUM_RE = re.compile(r"\w") # Alternatively [0-9a-zA-Z] would work.
|
ALPHANUM_RE = re.compile(r"\w") # Alternatively [0-9a-zA-Z] would work.
|
||||||
|
|
||||||
def raise_arg(e):
|
def raise_arg(e):
|
||||||
raise 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:
|
class StorePath:
|
||||||
def __init__(self, path: Union[str, Path]):
|
def __init__(self, path: Union[str, Path]):
|
||||||
assert str(path).startswith("/nix/store/"), \
|
assert str(path).startswith("/nix/store/"), \
|
||||||
|
@ -753,6 +782,50 @@ def query_nix_version() -> Version:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Could not determine Nix version from 'nix-store --version' output: {output!r}")
|
"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(
|
def run_list(
|
||||||
*,
|
*,
|
||||||
root,
|
root,
|
||||||
|
@ -928,6 +1001,56 @@ def run_diff(
|
||||||
f"{diff_closure_disk_usage_str})."
|
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 parse_args():
|
||||||
def add_sort_arg(p: argparse.ArgumentParser) -> None:
|
def add_sort_arg(p: argparse.ArgumentParser) -> None:
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
|
@ -993,6 +1116,30 @@ def parse_args():
|
||||||
dest="name_patterns",
|
dest="name_patterns",
|
||||||
help="Patterns (globs) that must all match package names.")
|
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()
|
return parser.parse_args()
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
@ -1038,6 +1185,7 @@ def main():
|
||||||
{
|
{
|
||||||
"list": run_list,
|
"list": run_list,
|
||||||
"diff": run_diff,
|
"diff": run_diff,
|
||||||
|
"history": run_history,
|
||||||
}[action](**args)
|
}[action](**args)
|
||||||
|
|
||||||
# Importing this module is kinda broken because of the constant initializations
|
# Importing this module is kinda broken because of the constant initializations
|
||||||
|
|
81
src/nvd.1
81
src/nvd.1
|
@ -33,6 +33,19 @@ nvd \- Nix/NixOS package version diff tool
|
||||||
.B ]*
|
.B ]*
|
||||||
.RE
|
.RE
|
||||||
.P
|
.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:
|
.B GLOBAL OPTIONS:
|
||||||
.P
|
.P
|
||||||
.RS
|
.RS
|
||||||
|
@ -66,6 +79,10 @@ The
|
||||||
.B list
|
.B list
|
||||||
command displays the list of packages included in a closure, optionally filtered
|
command displays the list of packages included in a closure, optionally filtered
|
||||||
to some criteria.
|
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
|
.P
|
||||||
.B nvd
|
.B nvd
|
||||||
distinguishes between packages that are explicitly included in
|
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
|
matches exactly one of any character, and the character
|
||||||
.B *
|
.B *
|
||||||
matches zero or more of any character.
|
matches zero or more of any character.
|
||||||
|
.TP
|
||||||
|
-p|--profile <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 <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
|
.SH OUTPUT
|
||||||
.P
|
.P
|
||||||
If the
|
If the
|
||||||
.B diff
|
.B diff
|
||||||
command is requested, then the output of
|
or
|
||||||
|
.B history
|
||||||
|
commands are requested, then the output of
|
||||||
.B nvd
|
.B nvd
|
||||||
displays the given names of the two store paths being compared on the first two
|
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
|
.B diff
|
||||||
and
|
,
|
||||||
.B list
|
.B list
|
||||||
|
, and
|
||||||
|
.B history
|
||||||
commands, package names, versions, and other information is printed out, with
|
commands, package names, versions, and other information is printed out, with
|
||||||
one line per package name, in lexicographic order. When producing a diff,
|
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,
|
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
|
>>> /nix/var/nix/profiles/system-2-link
|
||||||
.EE
|
.EE
|
||||||
.RE
|
.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
|
.P
|
||||||
After this, packages are listed. Each package is displayed on a line like the
|
After this, packages are listed. Each package is displayed on a line like the
|
||||||
following.
|
following.
|
||||||
|
@ -238,7 +303,15 @@ state, and the
|
||||||
.B diff
|
.B diff
|
||||||
command can display all states but the
|
command can display all states but the
|
||||||
.B I
|
.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
|
.B C
|
||||||
install state for a few different reasons, including the number of copies of an
|
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
|
existing version in the closure changing, or due to the selection state changing
|
||||||
|
|
Loading…
Reference in a new issue