summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.golangci.toml17
-rw-r--r--LICENSE19
-rw-r--r--README.md15
-rwxr-xr-xcheck9
-rw-r--r--default.nix18
-rw-r--r--go.mod10
-rw-r--r--go.sum6
-rw-r--r--integration_test.go103
-rw-r--r--main.go412
-rw-r--r--main_test.go110
-rw-r--r--manual.md28
-rw-r--r--testdata/configuration.nix35
-rw-r--r--testdata/package.nix35
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 = [
+]
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 <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
+ '';
+}
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 <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; }