summaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'main.go')
-rw-r--r--main.go412
1 files changed, 412 insertions, 0 deletions
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
+}