diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | .golangci.toml | 17 | ||||
-rw-r--r-- | LICENSE | 19 | ||||
-rw-r--r-- | README.md | 15 | ||||
-rwxr-xr-x | check | 9 | ||||
-rw-r--r-- | default.nix | 18 | ||||
-rw-r--r-- | go.mod | 10 | ||||
-rw-r--r-- | go.sum | 6 | ||||
-rw-r--r-- | integration_test.go | 103 | ||||
-rw-r--r-- | main.go | 412 | ||||
-rw-r--r-- | main_test.go | 110 | ||||
-rw-r--r-- | manual.md | 28 | ||||
-rw-r--r-- | testdata/configuration.nix | 35 | ||||
-rw-r--r-- | testdata/package.nix | 35 |
14 files changed, 818 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4a847d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/result diff --git a/.golangci.toml b/.golangci.toml new file mode 100644 index 0000000..816e446 --- /dev/null +++ b/.golangci.toml @@ -0,0 +1,17 @@ +version = "2" + +[linters] +enable = [ + "exhaustive", + "exhaustruct", +] + +[linters.settings.errcheck] +exclude-functions = [ + "fmt.Fprintln(io.Writer)", + "fmt.Fprintf(io.Writer)", +] + +[linters.settings.exhaustruct] +exclude = [ +] @@ -0,0 +1,19 @@ +Copyright (c) 2025 Martin Fischer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f0ee38 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# vdf - version diff for Nix + +vdf was inspired by [nvd] but groups direct dependencies first rather +than highlighting them as bold. Some more features are planned. + +See the [man page](manual.md). + +## Contributing + +The unit tests in `main_test.go` can be run with `go test`. + +The integration tests in `integration_test.go` can be run with `go test -tags=integration` +(running them requires `nix-build` and `nixos-rebuild` on the PATH). + +[nvd]: https://khumba.net/projects/nvd/ @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +s=0 + +! git grep FIX''ME; s=$((s | $?)) +golangci-lint run; s=$((s | $?)) +golangci-lint fmt --diff; s=$((s | $?)) +go test ./...; s=$((s | $?)) + +exit $s diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..4b0f9dd --- /dev/null +++ b/default.nix @@ -0,0 +1,18 @@ +{ pkgs ? import <nixpkgs> {} }: + +pkgs.buildGoModule { + pname = "vdf"; + version = "0+git"; + src = ./.; + vendorHash = "sha256-ZnjEnksVFER5GiOfn+RabYxu3sNgno7UKYE6SCHAFt8="; + + nativeBuildInputs = [ pkgs.lowdown ]; + + postBuild = '' + lowdown manual.md -st man -M section=1 > vdf.1 + ''; + + postInstall = '' + install -Dm644 vdf.1 $out/share/man/man1/vdf.1 + ''; +} @@ -0,0 +1,10 @@ +module push-f.com/vdf + +go 1.24.3 + +require github.com/alecthomas/assert/v2 v2.11.0 + +require ( + github.com/alecthomas/repr v0.4.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect +) @@ -0,0 +1,6 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..42b13a5 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,103 @@ +//go:build integration +// +build integration + +package main + +import ( + "bytes" + _ "embed" + "fmt" + "os" + "os/exec" + "strings" + "testing" + + "github.com/alecthomas/assert/v2" +) + +//go:embed testdata/package.nix +var packageNix []byte + +//go:embed testdata/configuration.nix +var configurationNix []byte + +func TestDiffNixPackage(t *testing.T) { + tempDir := t.TempDir() + os.WriteFile(tempDir+"/default.nix", packageNix, 0644) + runIn(tempDir, "nix-build", "--argstr", "version", "1.2.3") + copySymlink(tempDir+"/result", tempDir+"/old-result") + runIn(tempDir, "nix-build", "--argstr", "version", "1.2.4") + + var sb strings.Builder + innerMain(&sb, tempDir+"/old-result", tempDir+"/result") + assert.Equal(t, sb.String(), strings.TrimLeft(` +Version changed: + test-dependency: 1.2.3 -> 1.2.4 + + test-indirect-dependency: 1.2.3 -> 1.2.4 +`, "\n")) +} + +func TestDiffNixPackageDrv(t *testing.T) { + tempDir := t.TempDir() + os.WriteFile(tempDir+"/default.nix", packageNix, 0644) + runIn(tempDir, "nix-build", "--argstr", "version", "1.2.3") + copySymlink(tempDir+"/result", tempDir+"/old-result") + runIn(tempDir, "nix-build", "--argstr", "version", "1.2.4") + + out, _ := runIn(tempDir, "nix-store", "--query", "--deriver", "old-result", "result") + lines := strings.Split(out, "\n") + + var sb strings.Builder + innerMain(&sb, lines[0], lines[1]) + assert.Equal(t, sb.String(), strings.TrimLeft(` +Version changed: + test-dependency: 1.2.3 -> 1.2.4 + + test-indirect-dependency: 1.2.3 -> 1.2.4 +`, "\n")) +} + +func TestDiffNixSystem(t *testing.T) { + tempDir := t.TempDir() + os.WriteFile(tempDir+"/package.nix", packageNix, 0644) + os.WriteFile(tempDir+"/configuration.nix", configurationNix, 0644) + os.WriteFile(tempDir+"/version.nix", []byte("\"1.2.3\""), 0644) + runIn(tempDir, "nixos-rebuild", "build", "-I", "nixos-config=configuration.nix", "-I", "pkg-version=./version.nix") + err := copySymlink(tempDir+"/result", tempDir+"/old-result") + assert.NoError(t, err) + os.WriteFile(tempDir+"/version.nix", []byte("\"1.2.4\""), 0644) + runIn(tempDir, "nixos-rebuild", "build", "-I", "nixos-config=configuration.nix", "-I", "pkg-version=./version.nix") + + var sb strings.Builder + innerMain(&sb, tempDir+"/old-result", tempDir+"/result") + assert.Equal(t, sb.String(), strings.TrimLeft(` +Version changed: + test-package: 1.2.3 -> 1.2.4 + user-package: 1.2.3 -> 1.2.4 + + test-dependency: 1.2.3 -> 1.2.4 + test-indirect-dependency: 1.2.3 -> 1.2.4 +`, "\n")) +} + +func runIn(dir string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("%s failed: %v: %s", name, err, stderr.String()) + } + return string(out), nil +} + +func copySymlink(src, dst string) error { + target, err := os.Readlink(src) + if err != nil { + return err + } + + return os.Symlink(target, dst) +} @@ -0,0 +1,412 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" +) + +func main() { + if len(os.Args) != 3 { + fmt.Fprintf(os.Stderr, "Usage: %s <path> <path>\n\nSee the vdf(1) manual page for more information.\n", os.Args[0]) + os.Exit(1) + } + err := innerMain(os.Stdout, os.Args[1], os.Args[2]) + if err != nil { + fmt.Fprintf(os.Stderr, "%s", err) + os.Exit(1) + } +} + +func innerMain(w io.Writer, oldRoot string, newRoot string) error { + // We resolve symlinks so that we can filter out the roots from the dependencies. + oldRoot, err := filepath.EvalSymlinks(oldRoot) + if err != nil { + return fmt.Errorf("failed to eval symlinks in %q: %v", oldRoot, err) + } + newRoot, err = filepath.EvalSymlinks(newRoot) + if err != nil { + return fmt.Errorf("failed to eval symlinks in %q: %v", newRoot, err) + } + + oldPathInfos, err := queryPaths(oldRoot) + if err != nil { + return err + } + newPathInfos, err := queryPaths(newRoot) + if err != nil { + return err + } + + var nameRe *regexp.Regexp + if strings.HasSuffix(oldRoot, ".drv") && strings.HasSuffix(newRoot, ".drv") { + nameRe = derivationNameRe + } else if strings.HasSuffix(oldRoot, ".drv") || strings.HasSuffix(newRoot, ".drv") { + return fmt.Errorf("error: one derivation path given (either both paths should be output paths or both should be derivation paths)") + } else { + nameRe = outputNameRe + } + + var oldDependencies []Dependency + for path := range oldPathInfos { + if path == oldRoot { + continue + } + output, err := parseStorePath(nameRe, path) + if err != nil { + return err + } + oldDependencies = append(oldDependencies, *output) + } + + var newDependencies []Dependency + for path := range newPathInfos { + if path == newRoot { + continue + } + output, err := parseStorePath(nameRe, path) + if err != nil { + return err + } + newDependencies = append(newDependencies, *output) + } + + // TODO: exclude package names defined in TOML config + + added, removed, changed := comparePackages(oldDependencies, newDependencies) + + systemPathPath := findReference(newPathInfos[newRoot], "system-path") + etcPath := findReference(newPathInfos[newRoot], "etc") + + directDependencies := make(map[string]bool) + if systemPathPath != nil && etcPath != nil { + // We assume this is a NixOS system. + + // Detect environment.systemPackages as direct dependencies. + for _, path := range newPathInfos[*systemPathPath].References { + directDependencies[path] = true + } + + // Detect users.users.<name>.packages as direct dependencies. + for _, path := range newPathInfos[*etcPath].References { + name, _ := stripDigest(filepath.Base(path)) + if name == "user-environment" { + for _, path := range newPathInfos[path].References { + directDependencies[path] = true + } + } + } + } else { + for _, path := range newPathInfos[newRoot].References { + directDependencies[path] = true + } + + } + + printChanges(w, added, removed, changed, directDependencies) + + return nil +} + +type PathInfo struct { + References []string `json:"references"` +} + +func queryPaths(path string) (map[string]PathInfo, error) { + // We're using the experimental CLI because the stable CLI does not support JSON output. + cmd := exec.Command("nix", "path-info", "--json", "--recursive", "--extra-experimental-features", "nix-command", "--", path) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf( + "error: nix path-info --json failed: %v\n %s", + err, strings.TrimSpace(stderr.String())) + } + + var paths map[string]PathInfo + if err := json.Unmarshal(output, &paths); err != nil { + return nil, fmt.Errorf("error parsing JSON: %v", err) + } + return paths, nil +} + +func findReference(info PathInfo, name string) *string { + pathIdx := slices.IndexFunc(info.References, func(path string) bool { + pathName, _ := stripDigest(filepath.Base(path)) + return pathName == name + }) + if pathIdx == -1 { + return nil + } + return &info.References[pathIdx] +} + +var digestRe = regexp.MustCompile(`^[a-z0-9]{32}-`) + +func stripDigest(name string) (string, error) { + indices := digestRe.FindStringIndex(name) + if indices == nil { + return name, fmt.Errorf("path %q did not start with digest", name) + } + return name[indices[1]:], nil +} + +type Dependency struct { + Name string + Version string + Output string + Path string +} + +// We assume that versions start with a digit (nixpkgs also requires this.) +// We only recognize outputs after a version and assume it starts with a non-digit and doesn't contain a dash. +var outputNameRe = regexp.MustCompile(`(.+?)(?:-([0-9].*?)(?:-([^0-9].+))?)?$`) + +// For derivations there is no output name and we strip .drv if it exists. +var derivationNameRe = regexp.MustCompile(`(.+?)(?:-([0-9].*?)())?(?:\.drv)?$`) + +func parseStorePath(nameRe *regexp.Regexp, path string) (*Dependency, error) { + name, err := stripDigest(filepath.Base(path)) + if err != nil { + return nil, fmt.Errorf("path %s did not start with digest", path) + } + // fixed output derivations have a name starting with two digests + name, _ = stripDigest(name) + matches := nameRe.FindStringSubmatch(name) + if matches == nil { + return nil, fmt.Errorf("path %s did not match regex", path) + } + return &Dependency{ + Name: matches[1], + Version: matches[2], + Output: matches[3], + Path: path, + }, nil +} + +type VersionChange struct { + Name string + Output string + OldVersions []Version + NewVersions []Version +} + +type Version struct { + Version string + Paths []string +} + +func (vc *Dependency) nameOutput() NameOutput { + return NameOutput{vc.Name, vc.Output} +} + +type NameOutput struct { + Name string + Output string +} + +func comparePackages(oldDependencies, newDependencies []Dependency) ([]Dependency, []Dependency, []VersionChange) { + // For one package name there can be different versions in the same dependency tree. + oldMap := make(map[NameOutput][]Dependency) + newMap := make(map[NameOutput][]Dependency) + + for _, dep := range oldDependencies { + versions, exists := oldMap[dep.nameOutput()] + if exists { + oldMap[dep.nameOutput()] = append(versions, dep) + } else { + oldMap[dep.nameOutput()] = []Dependency{dep} + } + } + + for _, dep := range newDependencies { + versions, exists := newMap[dep.nameOutput()] + if exists { + newMap[dep.nameOutput()] = append(versions, dep) + } else { + newMap[dep.nameOutput()] = []Dependency{dep} + } + } + + var added []Dependency + var removed []Dependency + var changed []VersionChange + + for nameOut, newDeps := range newMap { + if oldDeps, exists := oldMap[nameOut]; exists { + // There can be different paths in the same dependency tree with the same package name and version. + oldVersions := groupByVersion(oldDeps) + newVersions := groupByVersion(newDeps) + + if !slices.EqualFunc(newVersions, oldVersions, sameVersion) { + changed = append(changed, VersionChange{ + Name: nameOut.Name, + Output: nameOut.Output, + OldVersions: oldVersions, + NewVersions: newVersions, + }) + } + } else { + added = append(added, newDeps...) + } + } + + for nameOut, oldDeps := range oldMap { + if _, exists := newMap[nameOut]; !exists { + removed = append(removed, oldDeps...) + } + } + + return added, removed, changed +} + +func groupByVersion(deps []Dependency) []Version { + versionMap := make(map[string][]string) + for _, dep := range deps { + if dep.Version == "" { + continue + } + paths, exists := versionMap[dep.Version] + if exists { + versionMap[dep.Version] = append(paths, dep.Path) + } else { + versionMap[dep.Version] = []string{dep.Path} + } + } + versions := make([]Version, 0, len(versionMap)) + for version, paths := range versionMap { + versions = append(versions, Version{version, paths}) + } + slices.SortFunc(versions, func(a Version, b Version) int { + return strings.Compare(a.Version, b.Version) + }) + return versions +} + +func sameVersion(v1, v2 Version) bool { + return v1.Version == v2.Version +} + +func printChanges(w io.Writer, added, removed []Dependency, changed []VersionChange, newDirectDeps map[string]bool) { + if len(changed) > 0 { + fmt.Fprintln(w, "Version changed:") + // TODO: grouping changes into categories defined in TOML config + changedSelected := make([]VersionChange, 0) + changedUnselected := make([]VersionChange, 0) + for _, change := range changed { + // If any path of any new version is a direct dependency in the new dependency tree + // we show the whole version change in the direct dependency section because figuring + // out which old version exactly changed to which new version is non-trivial + // since there can be several versions. + if slices.IndexFunc(change.NewVersions, func(v Version) bool { + for _, path := range v.Paths { + _, exists := newDirectDeps[path] + if exists { + return true + } + } + return false + }) != -1 { + changedSelected = append(changedSelected, change) + } else { + changedUnselected = append(changedUnselected, change) + } + } + printVersionChanges(w, filterVersionChanges(changedSelected)) + if len(changedSelected) > 0 && len(changedUnselected) > 0 { + fmt.Fprintln(w) + } + printVersionChanges(w, filterVersionChanges(changedUnselected)) + } + if len(added) > 0 { + if len(changed) > 0 { + fmt.Fprintln(w) + } + fmt.Fprintln(w, "Added packages:") + printDependencyList(w, added) + } + if len(removed) > 0 { + if len(added) > 0 || len(changed) > 0 { + fmt.Fprintln(w) + } + fmt.Fprintln(w, "Removed packages:") + printDependencyList(w, removed) + } +} + +func filterVersionChanges(versionChanges []VersionChange) []VersionChange { + slices.SortFunc(versionChanges, func(a, b VersionChange) int { + if a.Name == b.Name { + return strings.Compare(a.Output, b.Output) + } + return strings.Compare(a.Name, b.Name) + }) + var outChange VersionChange + filteredVersionChanges := []VersionChange{} + for _, change := range versionChanges { + if change.Name == outChange.Name && + slices.EqualFunc(change.OldVersions, outChange.OldVersions, sameVersion) && + slices.EqualFunc(change.NewVersions, outChange.NewVersions, sameVersion) { + continue + } + + filteredVersionChanges = append(filteredVersionChanges, change) + + if change.Output == "" { + outChange = change + } + } + return filteredVersionChanges +} + +func printVersionChanges(w io.Writer, changed []VersionChange) { + for _, change := range changed { + // TODO: link changelogs defined in TOML config + fmt.Fprintf(w, " %s%s: %s -> %s\n", change.Name, formatOutput(change.Output), + strings.Join(mapSlice(change.OldVersions, version), ", "), + strings.Join(mapSlice(change.NewVersions, version), ", "), + ) + } +} + +func version(v Version) string { + return v.Version +} + +func printDependencyList(w io.Writer, deps []Dependency) { + slices.SortFunc(deps, func(a, b Dependency) int { + if a.Name == b.Name { + return strings.Compare(a.Output, b.Output) + } + return strings.Compare(a.Name, b.Name) + }) + for _, dep := range deps { + fmt.Fprintf(w, " %s%s %s\n", dep.Name, formatOutput(dep.Output), dep.Version) + } +} + +func formatOutput(output string) string { + if output == "" { + return "" + } else { + return fmt.Sprintf("(%s)", output) + } +} + +func mapSlice[T, U any](slice []T, fn func(T) U) []U { + result := make([]U, len(slice)) + for i, v := range slice { + result[i] = fn(v) + } + return result +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..438ce7f --- /dev/null +++ b/main_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "strings" + "testing" + + "github.com/alecthomas/assert/v2" +) + +var prefix = "/nix/store/x2kkw05xdx9g0aqvgl5lnnypq9fvv2sn-" + +func TestParseStorePathWithoutOutput(t *testing.T) { + path := prefix + "some-name-01.23.45" + out, err := parseStorePath(outputNameRe, path) + assert.NoError(t, err) + assert.Equal(t, out, &Dependency{ + Name: "some-name", + Version: "01.23.45", + Output: "", + Path: path, + }) +} + +func TestParseStorePathWithOutput(t *testing.T) { + path := prefix + "some-name-01.23.45-some123output" + out, err := parseStorePath(outputNameRe, path) + assert.NoError(t, err) + assert.Equal(t, out, &Dependency{ + Name: "some-name", + Version: "01.23.45", + Output: "some123output", + Path: path, + }) +} + +func TestParseStorePathNoName(t *testing.T) { + path := "/nix/store/l7rjijvn6vx8njaf95vviw5krn3i9nnx" + _, err := parseStorePath(nil, path) + assert.Equal(t, err, fmt.Errorf("path %s did not start with digest", path)) +} + +func versionChange(name string, output string, oldVersions, newVersions []string) VersionChange { + return VersionChange{ + Name: name, + Output: output, + OldVersions: mapSlice(oldVersions, func(v string) Version { + return Version{v, []string{fmt.Sprintf("%s-%s-%s", name, v, output)}} + }), + NewVersions: mapSlice(newVersions, func(v string) Version { + return Version{v, []string{fmt.Sprintf("%s-%s-%s", name, v, output)}} + }), + } +} + +func TestPrintChanges(t *testing.T) { + added := []Dependency{ + { + Name: "new-foo", + Version: "1.2.3", + Output: "", + Path: "/nix/store/digest-new-foo-1.2.3", + }, + { + Name: "new-bar", + Version: "1.2.3", + Output: "some-output", + Path: "/nix/store/digest-new-bar-1.2.3", + }, + } + removed := []Dependency{ + { + Name: "removed-foo", + Version: "1.2.3", + Output: "", + Path: "/nix/store/digest-removed-foo-1.2.3", + }, + { + Name: "removed-bar", + Version: "1.2.3", + Output: "some-output", + Path: "/nix/store/digest-removed-bar-1.2.3", + }, + } + changed := []VersionChange{ + versionChange("changed-foo", "", []string{"1.2.3"}, []string{"1.2.4"}), + versionChange("changed-foo", "man", []string{"1.2.3"}, []string{"1.2.4"}), // will be omitted + versionChange("changed-foo", "bar", []string{"1.2.3"}, []string{"1.2.5"}), + versionChange("changed-bar", "some-output", []string{"1.2.3"}, []string{"1.2.4"}), + } + + var sb strings.Builder + printChanges(&sb, added, removed, changed, map[string]bool{"changed-bar-1.2.4-some-output": true}) + + assert.Equal(t, sb.String(), strings.TrimLeft(` +Version changed: + changed-bar(some-output): 1.2.3 -> 1.2.4 + + changed-foo: 1.2.3 -> 1.2.4 + changed-foo(bar): 1.2.3 -> 1.2.5 + +Added packages: + new-bar(some-output) 1.2.3 + new-foo 1.2.3 + +Removed packages: + removed-bar(some-output) 1.2.3 + removed-foo 1.2.3 +`, "\n")) +} diff --git a/manual.md b/manual.md new file mode 100644 index 0000000..b12ddfb --- /dev/null +++ b/manual.md @@ -0,0 +1,28 @@ +# NAME + +vdf - version diff for Nix + +# SYNOPSIS + +vdf \<path> \<path> + +# DESCRIPTION + +vdf compares the dependencies of two Nix store paths. If you pass output paths +(such as a `result` symlink or `/run/current-system`) runtime dependencies will +be compared, whereas if you pass derivation paths build-time dependencies will +be compared. + +vdf parses Nix store paths by assuming that the version starts with a digit +(nixpkgs also requires that). For output paths with a version the part after the +last dash is recognized as the output name if it does not start with a digit. + +In the *Version changed* section direct dependencies are grouped first. +If the root path has dependencies named `system-path` and `etc` then +direct dependencies are considered dependencies of the `system-path` path +and direct dependencies of `user-environment` paths that `etc` depends on +so that packages listed in `environment.systemPackages` and `users.users.<name>.packages` +are grouped first. + +Output-specific version changes are collapsed to one line if they have the same +version change and would be printed next to each other. diff --git a/testdata/configuration.nix b/testdata/configuration.nix new file mode 100644 index 0000000..fa31b47 --- /dev/null +++ b/testdata/configuration.nix @@ -0,0 +1,35 @@ +{ pkgs, ... }: + +{ + boot.loader.systemd-boot.enable = true; + + fileSystems."/" = { + device = "/dev/disk/by-label/nixos"; + fsType = "ext4"; + }; + + environment.systemPackages = [ + (pkgs.callPackage ./package.nix { version = import <pkg-version>; }).pkg + ]; + + users.users.test-user = { + isSystemUser = true; + group = "test-group"; + packages = [ + (pkgs.stdenv.mkDerivation { + pname = "user-package"; + version = import <pkg-version>; + dontUnpack = true; + installPhase = " + mkdir -p $out/bin + touch $out/bin/foo + "; + }) + ]; + }; + users.groups.test-group = {}; + + networking.hostName = "test-system"; + + system.stateVersion = "24.05"; +} diff --git a/testdata/package.nix b/testdata/package.nix new file mode 100644 index 0000000..8fe8904 --- /dev/null +++ b/testdata/package.nix @@ -0,0 +1,35 @@ +{ pkgs ? import <nixpkgs> {}, version }: + +let + dep-dep = pkgs.stdenv.mkDerivation { + pname = "test-indirect-dependency"; + dontUnpack = true; + inherit version; + installPhase = '' + mkdir -p $out/bin + echo ${version} > $out/bin/output.txt + ''; + }; + + dep = pkgs.stdenv.mkDerivation { + pname = "test-dependency"; + dontUnpack = true; + inherit version; + installPhase = '' + mkdir -p $out/bin + echo ${dep-dep} > $out/bin/output.txt + ''; + }; + + pkg = pkgs.stdenv.mkDerivation { + pname = "test-package"; + dontUnpack = true; + outputs = [ "out" ]; + inherit version; + installPhase = '' + mkdir -p $out/bin + echo ${dep} > $out/bin/output.txt + ''; + }; + +in { inherit pkg; } |