From d2957086a837cd7fe93ce8fe8556e446835d5f0a Mon Sep 17 00:00:00 2001 From: Greg Curtis Date: Thu, 12 Dec 2024 11:57:14 -0500 Subject: [PATCH 1/2] devbox: clean up profile history after sync After syncing the flake packages to the profile, remove all old generations of the Nix profile. This allows the Nix garbage collector to eventually remove any old packages. Opted for a Go implementation instead of calling `nix profile wipe-history` because deleting the history is pretty simple and this is faster. --- internal/devbox/nixprofile.go | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/internal/devbox/nixprofile.go b/internal/devbox/nixprofile.go index 953a35f6fd6..56e37494be4 100644 --- a/internal/devbox/nixprofile.go +++ b/internal/devbox/nixprofile.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "log/slog" + "os" + "path/filepath" "strings" "github.com/samber/lo" @@ -84,5 +86,42 @@ func (d *Devbox) syncNixProfileFromFlake(ctx context.Context) error { return fmt.Errorf("error installing packages in nix profile %s: %w", add, err) } } + if len(add) > 0 || len(remove) > 0 { + err := wipeProfileHistory(profilePath) + if err != nil { + // Log the error, but nothing terrible happens if this + // fails. + slog.DebugContext(ctx, "error cleaning up profile history", "err", err) + } + } + return nil +} + +// wipeProfileHistory removes all old generations of a Nix profile, similar to +// nix profile wipe-history. profile should be a path to the "default" symlink, +// like .devbox/nix/profile/default. +func wipeProfileHistory(profile string) error { + link, err := os.Readlink(profile) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + + dir := filepath.Dir(profile) + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, dent := range entries { + if dent.Name() == "default" || dent.Name() == link { + continue + } + err := os.Remove(filepath.Join(dir, dent.Name())) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } return nil } From a1edfb116dad5f7eb271e09b5dd7792c865ca46a Mon Sep 17 00:00:00 2001 From: Greg Curtis Date: Fri, 13 Dec 2024 16:06:56 -0500 Subject: [PATCH 2/2] assert profile history in TestScripts/multi --- testscripts/rm/multi.test.txt | 4 +++ testscripts/testrunner/testrunner.go | 49 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/testscripts/rm/multi.test.txt b/testscripts/rm/multi.test.txt index a68e52a9236..09252678ece 100644 --- a/testscripts/rm/multi.test.txt +++ b/testscripts/rm/multi.test.txt @@ -10,6 +10,10 @@ exec devbox rm vim hello json.superset devbox.json expected.json +# Check that profile history was cleaned up. There should only be +# default and default-N-link. +glob -count=2 .devbox/nix/profile/* + -- expected.json -- { "packages": [] diff --git a/testscripts/testrunner/testrunner.go b/testscripts/testrunner/testrunner.go index d709c414339..38462270346 100644 --- a/testscripts/testrunner/testrunner.go +++ b/testscripts/testrunner/testrunner.go @@ -80,6 +80,54 @@ func copyFileCmd(script *testscript.TestScript, neg bool, args []string) { script.Check(err) } +func globCmd(script *testscript.TestScript, neg bool, args []string) { + count := -1 + if neg { + count = 0 + } + if len(args) != 0 { + after, ok := strings.CutPrefix(args[0], "-count=") + if ok { + var err error + count, err = strconv.Atoi(after) + if err != nil { + script.Fatalf("invalid -count=: %v", err) + } + if count < 1 { + script.Fatalf("invalid -count=: must be at least 1") + } + args = args[1:] + } + } + if len(args) == 0 { + script.Fatalf("usage: glob [-count=N] pattern") + } + + var matches []string + for _, a := range args { + glob := script.MkAbs(a) + m, err := filepath.Glob(glob) + if err != nil { + script.Fatalf("invalid glob pattern: %v", err) + } + for _, match := range m { + script.Logf("glob %q matched: %s", glob, match) + } + matches = append(matches, m...) + } + + // -1 means that no -count= was given, so we want at least 1 match. + if count == -1 { + if len(matches) == 0 && !neg { + script.Fatalf("no matches for globs %q, want at least 1", strings.Join(args, " ")) + } + return + } + if len(matches) != count { + script.Fatalf("got %d matches for globs %q, want %d", len(matches), strings.Join(args, " "), count) + } +} + func getTestscriptParams(dir string) testscript.Params { return testscript.Params{ Dir: dir, @@ -91,6 +139,7 @@ func getTestscriptParams(dir string) testscript.Params { "devboxjson.packages.contains": assertDevboxJSONPackagesContains, "devboxlock.packages.contains": assertDevboxLockPackagesContains, "env.path.len": assertPathLength, + "glob": globCmd, "json.superset": assertJSONSuperset, "path.order": assertPathOrder, "source.path": sourcePath,