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 } 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,