-
Notifications
You must be signed in to change notification settings - Fork 696
feat(version): detect mise-managed installs and fix upgrade hint #791
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AndryOre
wants to merge
1
commit into
Gentleman-Programming:main
Choose a base branch
from
AndryOre:feat/mise-managed-detection
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+342
−10
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package version | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "runtime" | ||
| "strings" | ||
| ) | ||
|
|
||
| var currentExecutableFn = os.Executable | ||
| var userHomeDirFn = os.UserHomeDir | ||
|
|
||
| // miseInstallsRoot follows mise's own precedence. Whitespace-only values are | ||
| // unset. goos is a parameter so the Windows branch is testable on Linux CI. | ||
| func miseInstallsRoot(goos string) string { | ||
| if root := strings.TrimSpace(os.Getenv("MISE_INSTALLS_DIR")); root != "" { | ||
| return root | ||
| } | ||
| if dataDir := strings.TrimSpace(os.Getenv("MISE_DATA_DIR")); dataDir != "" { | ||
| return filepath.Join(dataDir, "installs") | ||
| } | ||
| if xdgDataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); xdgDataHome != "" { | ||
| return filepath.Join(xdgDataHome, "mise", "installs") | ||
| } | ||
| if goos == "windows" { | ||
| if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" { | ||
| return filepath.Join(localAppData, "mise", "installs") | ||
| } | ||
| } | ||
| home, err := userHomeDirFn() | ||
| if err != nil || strings.TrimSpace(home) == "" { | ||
| return "" | ||
| } | ||
| if goos == "windows" { | ||
| return filepath.Join(home, "AppData", "Local", "mise", "installs") | ||
| } | ||
| return filepath.Join(home, ".local", "share", "mise", "installs") | ||
| } | ||
|
|
||
| // pathContains reports whether path is root itself or lies beneath it. It climbs | ||
| // path's lexical ancestors and asks the OS for directory identity via os.SameFile | ||
| // (device+inode) rather than comparing strings, so symlinked ancestors, | ||
| // case-insensitive filesystems, and Unicode-equivalent names all answer correctly. | ||
| func pathContains(root, path string) bool { | ||
| rootInfo, err := os.Stat(root) | ||
| if err != nil || !rootInfo.IsDir() { | ||
| return false | ||
| } | ||
| current, err := filepath.Abs(path) | ||
| if err != nil { | ||
| return false | ||
| } | ||
| for { | ||
| if info, statErr := os.Stat(current); statErr == nil && info.IsDir() && os.SameFile(rootInfo, info) { | ||
| return true | ||
| } | ||
| parent := filepath.Dir(current) | ||
| if parent == current { | ||
| return false | ||
| } | ||
| current = parent | ||
| } | ||
| } | ||
|
|
||
| func runningBinaryIsMiseManaged() bool { | ||
| root := miseInstallsRoot(runtime.GOOS) | ||
| if root == "" { | ||
| return false | ||
| } | ||
| exe, err := currentExecutableFn() | ||
| if err != nil { | ||
| return false | ||
| } | ||
| return pathContains(root, exe) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| package version | ||
|
|
||
| import ( | ||
| "errors" | ||
| "os" | ||
| "path/filepath" | ||
| "runtime" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestMiseInstallsRoot(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| goos string | ||
| env map[string]string | ||
| want func(t *testing.T) string | ||
| }{ | ||
| { | ||
| name: "MISE_INSTALLS_DIR wins over all other sources", | ||
| goos: "linux", | ||
| env: map[string]string{ | ||
| "MISE_INSTALLS_DIR": "/opt/mise/installs", | ||
| "MISE_DATA_DIR": "/opt/data", | ||
| "XDG_DATA_HOME": "/opt/xdg", | ||
| }, | ||
| want: func(t *testing.T) string { return "/opt/mise/installs" }, | ||
| }, | ||
| { | ||
| name: "MISE_DATA_DIR wins when MISE_INSTALLS_DIR is unset", | ||
| goos: "linux", | ||
| env: map[string]string{ | ||
| "MISE_DATA_DIR": "/opt/data", | ||
| "XDG_DATA_HOME": "/opt/xdg", | ||
| }, | ||
| want: func(t *testing.T) string { return filepath.Join("/opt/data", "installs") }, | ||
| }, | ||
| { | ||
| name: "XDG_DATA_HOME wins over the platform default", | ||
| goos: "linux", | ||
| env: map[string]string{ | ||
| "XDG_DATA_HOME": "/opt/xdg", | ||
| }, | ||
| want: func(t *testing.T) string { return filepath.Join("/opt/xdg", "mise", "installs") }, | ||
| }, | ||
| { | ||
| name: "whitespace-only MISE_INSTALLS_DIR falls through to the next source", | ||
| goos: "linux", | ||
| env: map[string]string{ | ||
| "MISE_INSTALLS_DIR": " ", | ||
| "XDG_DATA_HOME": "/opt/xdg", | ||
| }, | ||
| want: func(t *testing.T) string { return filepath.Join("/opt/xdg", "mise", "installs") }, | ||
| }, | ||
| { | ||
| name: "windows resolves LOCALAPPDATA when nothing higher-precedence is set", | ||
| goos: "windows", | ||
| env: map[string]string{ | ||
| "LOCALAPPDATA": `C:\Users\dev\AppData\Local`, | ||
| }, | ||
| want: func(t *testing.T) string { | ||
| return filepath.Join(`C:\Users\dev\AppData\Local`, "mise", "installs") | ||
| }, | ||
| }, | ||
| { | ||
| name: "linux falls back to the home directory default", | ||
| goos: "linux", | ||
| env: map[string]string{}, | ||
| want: func(t *testing.T) string { | ||
| home, err := os.UserHomeDir() | ||
| if err != nil { | ||
| t.Fatalf("os.UserHomeDir() error = %v", err) | ||
| } | ||
| return filepath.Join(home, ".local", "share", "mise", "installs") | ||
| }, | ||
| }, | ||
| { | ||
| name: "windows falls back to the home directory default when LOCALAPPDATA is unset", | ||
| goos: "windows", | ||
| env: map[string]string{}, | ||
| want: func(t *testing.T) string { | ||
| home, err := os.UserHomeDir() | ||
| if err != nil { | ||
| t.Fatalf("os.UserHomeDir() error = %v", err) | ||
| } | ||
| return filepath.Join(home, "AppData", "Local", "mise", "installs") | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| clearMiseEnv(t) | ||
| for k, v := range tt.env { | ||
| t.Setenv(k, v) | ||
| } | ||
|
|
||
| want := tt.want(t) | ||
| if got := miseInstallsRoot(tt.goos); got != want { | ||
| t.Errorf("miseInstallsRoot(%q) = %q, want %q", tt.goos, got, want) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| t.Run("empty string when the home directory is unavailable", func(t *testing.T) { | ||
| clearMiseEnv(t) | ||
| withUserHomeDir(t, "", errors.New("no home directory")) | ||
|
|
||
| if got := miseInstallsRoot("linux"); got != "" { | ||
| t.Errorf("miseInstallsRoot(%q) = %q, want empty string", "linux", got) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestPathContains(t *testing.T) { | ||
| t.Run("path nested under root", func(t *testing.T) { | ||
| root := t.TempDir() | ||
| nested := filepath.Join(root, "go", "1.25.10", "bin") | ||
| if err := os.MkdirAll(nested, 0o755); err != nil { | ||
| t.Fatalf("MkdirAll() error = %v", err) | ||
| } | ||
|
|
||
| if !pathContains(root, filepath.Join(nested, "engram")) { | ||
| t.Error("pathContains() = false, want true for a path nested under root") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("path equal to root", func(t *testing.T) { | ||
| root := t.TempDir() | ||
|
|
||
| if !pathContains(root, root) { | ||
| t.Error("pathContains() = false, want true when path is root itself") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("sibling directory is not contained", func(t *testing.T) { | ||
| parent := t.TempDir() | ||
| root := filepath.Join(parent, "mise", "installs") | ||
| sibling := filepath.Join(parent, "mise-evil", "installs") | ||
| if err := os.MkdirAll(root, 0o755); err != nil { | ||
| t.Fatalf("MkdirAll(root) error = %v", err) | ||
| } | ||
| if err := os.MkdirAll(sibling, 0o755); err != nil { | ||
| t.Fatalf("MkdirAll(sibling) error = %v", err) | ||
| } | ||
|
|
||
| if pathContains(root, filepath.Join(sibling, "engram")) { | ||
| t.Error("pathContains() = true, want false for a sibling directory") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("symlinked ancestor resolves to the real root", func(t *testing.T) { | ||
| if runtime.GOOS == "windows" { | ||
| t.Skip("symlinks require elevated privileges on windows") | ||
| } | ||
|
|
||
| parent := t.TempDir() | ||
| realRoot := filepath.Join(parent, "real-installs") | ||
| if err := os.MkdirAll(realRoot, 0o755); err != nil { | ||
| t.Fatalf("MkdirAll() error = %v", err) | ||
| } | ||
| linkedRoot := filepath.Join(parent, "linked-installs") | ||
| if err := os.Symlink(realRoot, linkedRoot); err != nil { | ||
| t.Fatalf("Symlink() error = %v", err) | ||
| } | ||
|
|
||
| nested := filepath.Join(linkedRoot, "go", "bin", "engram") | ||
| if !pathContains(realRoot, nested) { | ||
| t.Error("pathContains() = false, want true through a symlinked ancestor") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("nonexistent root is never contained", func(t *testing.T) { | ||
| root := filepath.Join(t.TempDir(), "does-not-exist") | ||
|
|
||
| if pathContains(root, filepath.Join(root, "engram")) { | ||
| t.Error("pathContains() = true, want false for a nonexistent root") | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestRunningBinaryIsMiseManaged(t *testing.T) { | ||
| t.Run("true when the executable lives under the resolved installs root", func(t *testing.T) { | ||
| clearMiseEnv(t) | ||
| root := t.TempDir() | ||
| t.Setenv("MISE_INSTALLS_DIR", root) | ||
|
|
||
| exe := filepath.Join(root, "go", "1.25.10", "bin", "engram") | ||
| withCurrentExecutable(t, exe, nil) | ||
|
|
||
| if !runningBinaryIsMiseManaged() { | ||
| t.Error("runningBinaryIsMiseManaged() = false, want true") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("false when the current executable cannot be resolved", func(t *testing.T) { | ||
| clearMiseEnv(t) | ||
| t.Setenv("MISE_INSTALLS_DIR", t.TempDir()) | ||
| withCurrentExecutable(t, "", errors.New("executable not found")) | ||
|
|
||
| if runningBinaryIsMiseManaged() { | ||
| t.Error("runningBinaryIsMiseManaged() = true, want false") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("false when no installs root can be resolved", func(t *testing.T) { | ||
| clearMiseEnv(t) | ||
| withUserHomeDir(t, "", errors.New("no home directory")) | ||
| withCurrentExecutable(t, "/some/path/engram", nil) | ||
|
|
||
| if runningBinaryIsMiseManaged() { | ||
| t.Error("runningBinaryIsMiseManaged() = true, want false") | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func clearMiseEnv(t *testing.T) { | ||
| t.Helper() | ||
| for _, k := range []string{"MISE_INSTALLS_DIR", "MISE_DATA_DIR", "XDG_DATA_HOME", "LOCALAPPDATA"} { | ||
| t.Setenv(k, "") | ||
| } | ||
| } | ||
|
|
||
| func withCurrentExecutable(t *testing.T, path string, err error) { | ||
| t.Helper() | ||
| old := currentExecutableFn | ||
| currentExecutableFn = func() (string, error) { return path, err } | ||
| t.Cleanup(func() { currentExecutableFn = old }) | ||
| } | ||
|
|
||
| func withUserHomeDir(t *testing.T, home string, err error) { | ||
| t.Helper() | ||
| old := userHomeDirFn | ||
| userHomeDirFn = func() (string, error) { return home, err } | ||
| t.Cleanup(func() { userHomeDirFn = old }) | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Assert both required Mise commands.
The test checks only
mise upgrade engram. If code removesmise upgrade github:Gentleman-Programming/engram, this test still passes. Assert the complete instruction string.Proposed test update
As per path instructions, verify coverage of happy paths, error paths, and edge cases. Tests must be deterministic.
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Path instructions