Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions internal/timber/review/mise.go
Original file line number Diff line number Diff line change
@@ -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
}
179 changes: 179 additions & 0 deletions internal/timber/review/mise_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
9 changes: 8 additions & 1 deletion internal/timber/review/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading