From ba6b72b4c7e257b9514ce21b7fbbc469e4c8a849 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Wed, 9 Jul 2025 20:32:34 +0200 Subject: initial commit --- .gitignore | 1 + .golangci.toml | 17 ++ LICENSE | 19 +++ README.md | 15 ++ check | 9 + default.nix | 18 ++ go.mod | 10 ++ go.sum | 6 + integration_test.go | 103 ++++++++++++ main.go | 412 +++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 110 ++++++++++++ manual.md | 28 +++ testdata/configuration.nix | 35 ++++ testdata/package.nix | 35 ++++ 14 files changed, 818 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100755 check create mode 100644 default.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 integration_test.go create mode 100644 main.go create mode 100644 main_test.go create mode 100644 manual.md create mode 100644 testdata/configuration.nix create mode 100644 testdata/package.nix 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 = [ +] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b37cec7 --- /dev/null +++ b/LICENSE @@ -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/ diff --git a/check b/check new file mode 100755 index 0000000..8b31ebb --- /dev/null +++ b/check @@ -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 {} }: + +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 + ''; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e503e8 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f571a34 --- /dev/null +++ b/go.sum @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9c30e33 --- /dev/null +++ b/main.go @@ -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 \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..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 \ \ + +# 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..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 + ]; + + users.users.test-user = { + isSystemUser = true; + group = "test-group"; + packages = [ + (pkgs.stdenv.mkDerivation { + pname = "user-package"; + version = import ; + 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 {}, 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; } -- cgit v1.2.3