diff --git a/internal/timber/review/mise.go b/internal/timber/review/mise.go new file mode 100644 index 0000000..cbeeb54 --- /dev/null +++ b/internal/timber/review/mise.go @@ -0,0 +1,126 @@ +package review + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +// miseConfigFiles names the project configuration files that make mise prompt +// for trust in a checkout root. Trust covers the directory, so one match +// identifies a checkout that needs it. +var miseConfigFiles = []string{"mise.toml", ".mise.toml", "mise.local.toml"} + +// mirrorMiseTrust copies the source checkout's mise trust state to the review +// checkout. Git wrappers installed by mise refuse to read an untrusted project +// config, which breaks every Git command in the review checkout. +// +// Call it before resetting the checkout and again after, because paranoid mise +// ties trust to file contents and a reset can change them. +func mirrorMiseTrust(ctx context.Context, source Repository, path string) error { + if !hasMiseConfig(path) { + return nil + } + trusted, err := miseTrustsDirectory(ctx, source, source.Dir) + if errors.Is(err, exec.ErrNotFound) { + return nil + } + if err != nil { + return err + } + return setReviewMiseTrust(ctx, source, path, trusted) +} + +// setReviewMiseTrust makes the review checkout match the source trust state. +func setReviewMiseTrust(ctx context.Context, source Repository, path string, trusted bool) error { + current, err := miseTrustsDirectory(ctx, source, path) + if err != nil { + return err + } + if current == trusted { + return nil + } + args := []string{"trust", path} + if !trusted { + args = []string{"trust", "--untrust", path} + } + command := exec.CommandContext(ctx, "mise", args...) + // Run from the source checkout so mise does not read the review config + // while it changes the trust of that directory. + command.Dir = source.Dir + command.Env = slices.Clone(source.environment()) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("mirror mise trust in review checkout: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func hasMiseConfig(dir string) bool { + for _, name := range miseConfigFiles { + info, err := os.Lstat(filepath.Join(dir, name)) + if err == nil && info.Mode().IsRegular() { + return true + } + } + return false +} + +// miseTrustsDirectory reports whether mise lists dir as trusted. +func miseTrustsDirectory(ctx context.Context, source Repository, dir string) (bool, error) { + command := exec.CommandContext(ctx, "mise", "trust", "--show") + command.Dir = dir + command.Env = slices.Clone(source.environment()) + output, err := command.Output() + if exitError, ok := errors.AsType[*exec.ExitError](err); ok { + return false, fmt.Errorf("inspect mise trust in %s: %w: %s", dir, err, strings.TrimSpace(string(exitError.Stderr))) + } + if err != nil { + return false, fmt.Errorf("inspect mise trust in %s: %w", dir, err) + } + return miseListedAsTrusted(string(output), dir), nil +} + +// miseListedAsTrusted matches a directory against mise trust --show output, +// which names each config directory as trusted or untrusted. +func miseListedAsTrusted(output, dir string) bool { + for line := range strings.SplitSeq(output, "\n") { + name, ok := strings.CutSuffix(strings.TrimSpace(line), ": trusted") + if !ok { + continue + } + if miseSameDirectory(name, dir) { + return true + } + } + return false +} + +// miseSameDirectory compares a reported path, which mise abbreviates under the +// home directory, to an absolute directory. +func miseSameDirectory(reported, dir string) bool { + if rest, ok := strings.CutPrefix(reported, "~/"); ok { + home, err := os.UserHomeDir() + if err != nil { + return false + } + reported = filepath.Join(home, rest) + } + if filepath.Clean(reported) == filepath.Clean(dir) { + return true + } + reportedPath, err := filepath.EvalSymlinks(reported) + if err != nil { + return false + } + dirPath, err := filepath.EvalSymlinks(dir) + if err != nil { + return false + } + return reportedPath == dirPath +} diff --git a/internal/timber/review/mise_test.go b/internal/timber/review/mise_test.go new file mode 100644 index 0000000..02468d2 --- /dev/null +++ b/internal/timber/review/mise_test.go @@ -0,0 +1,179 @@ +package review + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type mirrorMiseTrustCase struct { + name string + sourceTrust bool + reviewTrust bool + trustExit string + withoutMise bool + wantArgs string + wantError string +} + +// fakeMise installs a mise stub that records trust state in a file, because +// mise stores trust per directory and the real state is user-global. +func fakeMise(t *testing.T) (logPath, trustedPath string) { + t.Helper() + dir := t.TempDir() + logPath = filepath.Join(dir, "mise.log") + trustedPath = filepath.Join(dir, "trusted") + require.NoError(t, os.WriteFile(trustedPath, nil, 0o600)) + script := `#!/bin/sh +set -eu +printf '%s|%s\n' "$PWD" "$*" >> "$MISE_TEST_LOG" +if [ "$1" = "trust" ] && [ "$2" = "--show" ]; then + if grep -qxF "$PWD" "$MISE_TEST_TRUSTED"; then + printf '%s: trusted\n' "$PWD" + else + printf '%s: untrusted\n' "$PWD" + fi + exit 0 +fi +if [ "$1" = "trust" ] && [ "$2" = "--untrust" ]; then + if [ "${MISE_TEST_TRUST_EXIT:-0}" -ne 0 ]; then + echo "mise untrust refused" >&2 + exit "$MISE_TEST_TRUST_EXIT" + fi + grep -vxF "$3" "$MISE_TEST_TRUSTED" > "$MISE_TEST_TRUSTED.tmp" || true + mv "$MISE_TEST_TRUSTED.tmp" "$MISE_TEST_TRUSTED" + exit 0 +fi +if [ "$1" = "trust" ]; then + if [ "${MISE_TEST_TRUST_EXIT:-0}" -ne 0 ]; then + echo "mise trust refused" >&2 + exit "$MISE_TEST_TRUST_EXIT" + fi + printf '%s\n' "$2" >> "$MISE_TEST_TRUSTED" + exit 0 +fi +exit 64 +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "mise"), []byte(script), 0o700)) + t.Setenv("MISE_TEST_LOG", logPath) + t.Setenv("MISE_TEST_TRUSTED", trustedPath) + t.Setenv("MISE_TEST_TRUST_EXIT", "0") + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath, trustedPath +} + +// resolvedTempDir returns a temporary directory with symlinks resolved. The +// fake mise reports the physical working directory that the shell resolves, and +// macOS makes the temporary directory reachable through a symlink. +func resolvedTempDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + return dir +} + +func writeMiseConfig(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "mise.toml"), []byte("[env]\nTIMBER_TEST = \"1\"\n"), 0o600)) +} + +func readMiseLog(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return strings.Split(strings.TrimSpace(string(data)), "\n") +} + +func TestMirrorMiseTrust(t *testing.T) { + for _, test := range []mirrorMiseTrustCase{ + {name: "copies trusted source", sourceTrust: true, wantArgs: "trust"}, + {name: "leaves untrusted source alone"}, + {name: "skips already trusted review", sourceTrust: true, reviewTrust: true}, + {name: "untrusts for an untrusted source", reviewTrust: true, wantArgs: "trust --untrust"}, + {name: "skips already untrusted review"}, + {name: "reports trust failure", sourceTrust: true, trustExit: "1", wantArgs: "trust", wantError: "mirror mise trust in review checkout"}, + {name: "reports untrust failure", reviewTrust: true, trustExit: "1", wantArgs: "trust --untrust", wantError: "mirror mise trust in review checkout"}, + {name: "skips without mise", sourceTrust: true, withoutMise: true}, + } { + t.Run(test.name, func(t *testing.T) { testMirrorMiseTrust(t, test) }) + } +} + +func testMirrorMiseTrust(t *testing.T, test mirrorMiseTrustCase) { + t.Helper() + logPath, trustedPath := fakeMise(t) + source, path := resolvedTempDir(t), resolvedTempDir(t) + writeMiseConfig(t, source) + writeMiseConfig(t, path) + var trusted []string + if test.sourceTrust { + trusted = append(trusted, source) + } + if test.reviewTrust { + trusted = append(trusted, path) + } + require.NoError(t, os.WriteFile(trustedPath, []byte(strings.Join(trusted, "\n")+"\n"), 0o600)) + t.Setenv("MISE_TEST_TRUST_EXIT", test.trustExit) + if test.withoutMise { + t.Setenv("PATH", t.TempDir()) + } + repository := Repository{Dir: source, Environment: os.Environ()} + assertMirrorMiseTrust(t, test, mirrorMiseTrust(t.Context(), repository, path), source, path, logPath) +} + +func assertMirrorMiseTrust(t *testing.T, test mirrorMiseTrustCase, err error, source, path, logPath string) { + t.Helper() + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + } else { + require.NoError(t, err) + } + if test.withoutMise { + require.NoFileExists(t, logPath) + return + } + want := []string{source + "|trust --show", path + "|trust --show"} + if test.wantArgs != "" { + want = append(want, source+"|"+test.wantArgs+" "+path) + } + require.Equal(t, want, readMiseLog(t, logPath)) +} + +func TestMirrorMiseTrustSkipsReviewWithoutConfig(t *testing.T) { + logPath, _ := fakeMise(t) + source, path := resolvedTempDir(t), resolvedTempDir(t) + writeMiseConfig(t, source) + require.NoError(t, mirrorMiseTrust(t.Context(), Repository{Dir: source, Environment: os.Environ()}, path)) + require.NoFileExists(t, logPath) +} + +func TestMiseListedAsTrustedReadsHomeRelativePath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, "src", "project") + require.True(t, miseListedAsTrusted("~/src/project: trusted\n", dir)) + require.False(t, miseListedAsTrusted("~/src/project: untrusted\n", dir)) + require.False(t, miseListedAsTrusted("/other/project: trusted\n", dir)) + require.False(t, miseListedAsTrusted("mise No trusted config files found.\n", dir)) +} + +func TestPrepareWorktreeMirrorsMiseTrust(t *testing.T) { + _, trustedPath := fakeMise(t) + r := testRepo(t) + writeMiseConfig(t, r.Dir) + gitTest(t, r.Dir, "add", "mise.toml") + gitTest(t, r.Dir, "commit", "-m", "Add mise config") + require.NoError(t, os.WriteFile(trustedPath, []byte(r.Dir+"\n"), 0o600)) + view, err := r.Snapshot(t.Context()) + require.NoError(t, err) + path, err := WorktreePath(r.Dir) + require.NoError(t, err) + require.NoError(t, prepareWorktree(t.Context(), r, path, view.Stacks[0])) + data, err := os.ReadFile(trustedPath) + require.NoError(t, err) + require.Contains(t, strings.Fields(string(data)), path) + require.Equal(t, view.Stacks[0].Tip, gitTest(t, path, "rev-parse", "HEAD")) +} diff --git a/internal/timber/review/worktree.go b/internal/timber/review/worktree.go index 5b67152..353ed46 100644 --- a/internal/timber/review/worktree.go +++ b/internal/timber/review/worktree.go @@ -81,7 +81,14 @@ func prepareWorktree(ctx context.Context, source Repository, path string, stack return err } } - return resetReviewCheckout(ctx, source, path, stack) + if err := mirrorMiseTrust(ctx, source, path); err != nil { + return err + } + if err := resetReviewCheckout(ctx, source, path, stack); err != nil { + return err + } + // A reset can change file contents, and paranoid mise ties trust to them. + return mirrorMiseTrust(ctx, source, path) } func ensureReviewCheckout(ctx context.Context, source Repository, path string, stack Stack) error {