diff --git a/README.md b/README.md index e8cbea3..9221cf5 100644 --- a/README.md +++ b/README.md @@ -82,16 +82,18 @@ go install github.com/nnutter/timber@latest Run `timber config` to show settings and create the config file if absent. The file is `$XDG_CONFIG_HOME/timber/config.toml` (fallback: `~/.config/timber/config.toml`). -Automatic mise trust is disabled by default. -On the first eligible worktree creation in an interactive terminal, Timber offers to enable it. -The prompt defaults to No and saves your answer, so Timber does not ask again. -After No, the prompt shows `timber config --auto-mise-trust` so you can enable it later. -An explicit `timber config --no-auto-mise-trust` also suppresses the prompt. +Automatic mise trust and automatic hk hook installation are disabled by default. +On the first eligible worktree creation in an interactive terminal, Timber offers to enable each feature. +The prompts default to No and save your answers, so Timber does not ask again. +After No, a prompt shows the matching `timber config` flag so you can enable the feature later. +An explicit `--no-auto-mise-trust` or `--no-auto-hk-install` also suppresses its prompt. Non-interactive creation does not prompt or record a choice. ```bash timber config --auto-mise-trust timber config --no-auto-mise-trust +timber config --auto-hk-install +timber config --no-auto-hk-install ``` Enabling automatic trust permits mise configuration in eligible worktrees to execute code. @@ -101,11 +103,18 @@ Remove this cache after switching GitHub accounts or changing organization membe If Timber cannot refresh an invalid cache, it warns and skips trust without failing worktree creation. A failed `mise trust` also produces a warning and keeps the worktree. +Enabling automatic hk hook installation runs `hk install` in eligible worktrees. +Installed hooks run repository-defined commands on Git events. +Eligibility requires a root-level, regular `hk.pkl` and a GitHub upstream owned by you or one of your organizations. +Timber adds `--mise` when the worktree also has a root-level, regular `mise.toml`, so hooks can use mise-managed tools. +A failed `hk install` also produces a warning and keeps the worktree. + Entry points: - [internal/timber/config.go](internal/timber/config.go): settings and config command - [internal/timber/github_identity.go](internal/timber/github_identity.go): identity cache - [internal/timber/mise_trust.go](internal/timber/mise_trust.go): eligibility and trust +- [internal/timber/hk_install.go](internal/timber/hk_install.go): hk hook installation ## Herdr Plugin @@ -246,7 +255,7 @@ For full flags and examples, see `timber --help` and `timber --help`. | `repo import` | Convert an existing clone into a managed repository | | `repo remove` | Unregister a bare repository once its worktrees are gone | | `repo rename` | Rename a repository and move its worktree directories | -| `config` | Show settings or change them with `--auto-mise-trust` / `--no-auto-mise-trust` | +| `config` | Show settings or change them with `--auto-mise-trust` / `--no-auto-mise-trust` / `--auto-hk-install` / `--no-auto-hk-install` | | `create` | Create a worktree for a branch (`--no-herdr` skips the Herdr space) | | `switch` | Shell-only: `cd` into a worktree, creating it first with `-c` | | `tui` | Interactively open an existing worktree or create a new one | diff --git a/internal/timber/config.go b/internal/timber/config.go index 3919744..94cccd8 100644 --- a/internal/timber/config.go +++ b/internal/timber/config.go @@ -14,10 +14,12 @@ import ( type config struct { AutoMiseTrust bool `toml:"auto_mise_trust"` AutoMiseTrustDecided bool `toml:"auto_mise_trust_decided,omitempty"` + AutoHKInstall bool `toml:"auto_hk_install"` + AutoHKInstallDecided bool `toml:"auto_hk_install_decided,omitempty"` } func NewConfigCommand(runtime Runtime) *cobra.Command { - var enable, disable bool + var enable, disable, enableHK, disableHK bool command := &cobra.Command{ Use: "config", Short: "Show or change Timber settings", @@ -27,12 +29,24 @@ func NewConfigCommand(runtime Runtime) *cobra.Command { if err != nil { return err } + changed := false if command.Flags().Changed("auto-mise-trust") || command.Flags().Changed("no-auto-mise-trust") { settings.AutoMiseTrust = enable if command.Flags().Changed("no-auto-mise-trust") { settings.AutoMiseTrust = !disable } settings.AutoMiseTrustDecided = true + changed = true + } + if command.Flags().Changed("auto-hk-install") || command.Flags().Changed("no-auto-hk-install") { + settings.AutoHKInstall = enableHK + if command.Flags().Changed("no-auto-hk-install") { + settings.AutoHKInstall = !disableHK + } + settings.AutoHKInstallDecided = true + changed = true + } + if changed { if err := writeConfig(runtime.configPath(), settings); err != nil { return err } @@ -43,6 +57,9 @@ func NewConfigCommand(runtime Runtime) *cobra.Command { command.Flags().BoolVar(&enable, "auto-mise-trust", false, "Trust mise.toml in worktrees owned by you or your GitHub organizations") command.Flags().BoolVar(&disable, "no-auto-mise-trust", false, "Disable automatic mise trust") command.MarkFlagsMutuallyExclusive("auto-mise-trust", "no-auto-mise-trust") + command.Flags().BoolVar(&enableHK, "auto-hk-install", false, "Install hk hooks in worktrees owned by you or your GitHub organizations") + command.Flags().BoolVar(&disableHK, "no-auto-hk-install", false, "Disable automatic hk hook installation") + command.MarkFlagsMutuallyExclusive("auto-hk-install", "no-auto-hk-install") return command } diff --git a/internal/timber/config_test.go b/internal/timber/config_test.go index db71214..d03d9db 100644 --- a/internal/timber/config_test.go +++ b/internal/timber/config_test.go @@ -16,11 +16,15 @@ func TestConfigPersistsSettingsUnderXDGConfigHome(t *testing.T) { args []string want string }{ - {[]string{"config"}, "auto_mise_trust = false\n"}, - {[]string{"config", "--auto-mise-trust"}, "auto_mise_trust = true\nauto_mise_trust_decided = true\n"}, - {[]string{"config"}, "auto_mise_trust = true\nauto_mise_trust_decided = true\n"}, - {[]string{"config", "--no-auto-mise-trust"}, "auto_mise_trust = false\nauto_mise_trust_decided = true\n"}, - {[]string{"config"}, "auto_mise_trust = false\nauto_mise_trust_decided = true\n"}, + {[]string{"config"}, "auto_mise_trust = false\nauto_hk_install = false\n"}, + {[]string{"config", "--auto-mise-trust"}, "auto_mise_trust = true\nauto_mise_trust_decided = true\nauto_hk_install = false\n"}, + {[]string{"config"}, "auto_mise_trust = true\nauto_mise_trust_decided = true\nauto_hk_install = false\n"}, + {[]string{"config", "--no-auto-mise-trust"}, "auto_mise_trust = false\nauto_mise_trust_decided = true\nauto_hk_install = false\n"}, + {[]string{"config"}, "auto_mise_trust = false\nauto_mise_trust_decided = true\nauto_hk_install = false\n"}, + {[]string{"config", "--auto-hk-install"}, "auto_mise_trust = false\nauto_mise_trust_decided = true\nauto_hk_install = true\nauto_hk_install_decided = true\n"}, + {[]string{"config"}, "auto_mise_trust = false\nauto_mise_trust_decided = true\nauto_hk_install = true\nauto_hk_install_decided = true\n"}, + {[]string{"config", "--no-auto-hk-install"}, "auto_mise_trust = false\nauto_mise_trust_decided = true\nauto_hk_install = false\nauto_hk_install_decided = true\n"}, + {[]string{"config"}, "auto_mise_trust = false\nauto_mise_trust_decided = true\nauto_hk_install = false\nauto_hk_install_decided = true\n"}, } { result := runTimberCommandWithRuntime(t, runtime, step.args...) require.NoError(t, result.err) @@ -32,6 +36,9 @@ func TestConfigPersistsSettingsUnderXDGConfigHome(t *testing.T) { result := runTimberCommandWithRuntime(t, runtime, "config", "--auto-mise-trust", "--no-auto-mise-trust") require.Error(t, result.err) assert.Contains(t, result.err.Error(), "none of the others can be") + result = runTimberCommandWithRuntime(t, runtime, "config", "--auto-hk-install", "--no-auto-hk-install") + require.Error(t, result.err) + assert.Contains(t, result.err.Error(), "none of the others can be") } func TestConfigDoesNotOverwriteInvalidOrUnknownSettings(t *testing.T) { diff --git a/internal/timber/create.go b/internal/timber/create.go index 02b9f97..eac1edd 100644 --- a/internal/timber/create.go +++ b/internal/timber/create.go @@ -138,6 +138,7 @@ func (x *createCommandOptions) createWorktree(command *cobra.Command, args []str } x.runtime.autoTrustMise(command, repository, upstreamBranch, worktreePath) + x.runtime.autoInstallHKHooks(command, repository, upstreamBranch, worktreePath) if !x.shouldCreateHerdrWorkspace() { return worktreePath, nil diff --git a/internal/timber/hk_install.go b/internal/timber/hk_install.go new file mode 100644 index 0000000..be8d97f --- /dev/null +++ b/internal/timber/hk_install.go @@ -0,0 +1,105 @@ +package timber + +import ( + "bufio" + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +func (x Runtime) autoInstallHKHooks(command *cobra.Command, repository *Repository, upstream, worktreePath string) { + warn := func(err error) { + fmt.Fprintf(command.ErrOrStderr(), "warning: automatic hk install skipped: %v\n", err) + } + settings, err := x.loadConfig() + if err != nil { + warn(err) + return + } + if !settings.AutoHKInstall && (settings.AutoHKInstallDecided || !isInteractiveTerminal(command.InOrStdin())) { + return + } + regular, err := regularRootFile(filepath.Join(worktreePath, "hk.pkl")) + if err != nil { + warn(err) + return + } + if !regular { + return + } + identity, owned, err := x.githubOwnedWorktree(command, repository, upstream) + if err != nil { + warn(err) + return + } + if !owned { + return + } + if !settings.AutoHKInstall { + enabled, err := x.promptAutoHKInstall(command, settings, identity) + if err != nil { + warn(err) + return + } + if !enabled { + return + } + } + args := []string{"install"} + hasMise, err := regularRootFile(filepath.Join(worktreePath, "mise.toml")) + if err != nil { + warn(err) + return + } + if hasMise { + args = append(args, "--mise") + } + install := x.command(command.Context(), "hk", args...) + install.Dir = worktreePath + output, err := install.CombinedOutput() + if err != nil { + warn(fmt.Errorf("hk install failed: %w: %s", err, strings.TrimSpace(string(output)))) + } +} + +func (x Runtime) promptAutoHKInstall(command *cobra.Command, settings config, identity githubIdentity) (bool, error) { + output := command.ErrOrStderr() + if _, err := fmt.Fprintln(output, "This worktree qualifies for automatic hk hook installation. Installing hk hooks permits code execution."); err != nil { + return false, err + } + if err := writeGitHubIdentity(output, identity); err != nil { + return false, err + } + reader := bufio.NewReader(command.InOrStdin()) + for { + if _, err := fmt.Fprint(output, "Enable automatic hk hook installation for repositories owned by you or your GitHub organizations? [y/N] "); err != nil { + return false, err + } + answer, err := reader.ReadString('\n') + if err != nil { + return false, fmt.Errorf("read hk install choice: %w", err) + } + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + settings.AutoHKInstall = true + case "", "n", "no": + settings.AutoHKInstall = false + default: + if _, err := fmt.Fprintln(output, "Enter yes or no."); err != nil { + return false, err + } + continue + } + settings.AutoHKInstallDecided = true + if err := writeConfig(x.configPath(), settings); err != nil { + return false, fmt.Errorf("save hk install choice: %w", err) + } + if !settings.AutoHKInstall { + _, err := fmt.Fprintln(output, "Automatic hk hook installation remains disabled. Enable it later with: timber config --auto-hk-install") + return false, err + } + return true, nil + } +} diff --git a/internal/timber/hk_install_test.go b/internal/timber/hk_install_test.go new file mode 100644 index 0000000..015a3e9 --- /dev/null +++ b/internal/timber/hk_install_test.go @@ -0,0 +1,234 @@ +package timber + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateAutomaticallyInstallsHKHooksOnlyForEligibleRepositories(t *testing.T) { + parent := t + var timberBin string + for _, test := range []struct { + name string + remote string + enabled bool + hkFile bool + miseFile bool + miseSymlink bool + hkSymlink bool + cache string + hkFails bool + localStart bool + wantInstall bool + wantMise bool + wantWarn string + interactive bool + answer string + wantPrompt bool + explicitDisable bool + noOrganizations bool + }{ + {name: "install with mise", remote: "git@github.com:alice/project.git", enabled: true, hkFile: true, miseFile: true, wantInstall: true, wantMise: true}, + {name: "install without mise", remote: "git@github.com:alice/project.git", enabled: true, hkFile: true, wantInstall: true}, + {name: "mise symlink drops option", remote: "git@github.com:alice/project.git", enabled: true, hkFile: true, miseFile: true, miseSymlink: true, wantInstall: true}, + {name: "prompt yes", remote: "git@github.com:alice/project.git", hkFile: true, miseFile: true, interactive: true, answer: "yes", wantPrompt: true, wantInstall: true, wantMise: true}, + {name: "prompt yes without mise", remote: "git@github.com:alice/project.git", hkFile: true, interactive: true, answer: "yes", wantPrompt: true, wantInstall: true}, + {name: "prompt without organizations", remote: "git@github.com:alice/project.git", hkFile: true, interactive: true, answer: "yes", wantPrompt: true, wantInstall: true, noOrganizations: true}, + {name: "prompt no", remote: "git@github.com:alice/project.git", hkFile: true, miseFile: true, interactive: true, answer: "no", wantPrompt: true}, + {name: "prompt default no", remote: "git@github.com:alice/project.git", hkFile: true, interactive: true, wantPrompt: true}, + {name: "explicit refusal", remote: "git@github.com:alice/project.git", hkFile: true, interactive: true, explicitDisable: true}, + {name: "no prompt for other owner", remote: "git@github.com:stranger/project.git", hkFile: true, interactive: true}, + {name: "no prompt without hk", remote: "git@github.com:alice/project.git", miseFile: true, interactive: true}, + {name: "disabled", remote: "git@github.com:alice/project.git", hkFile: true, miseFile: true}, + {name: "no hk file", remote: "git@github.com:alice/project.git", enabled: true, miseFile: true}, + {name: "symlink hk config", remote: "git@github.com:alice/project.git", enabled: true, hkFile: true, hkSymlink: true}, + {name: "unrelated owner", remote: "git@github.com:stranger/project.git", enabled: true, hkFile: true}, + {name: "other host", remote: "git@gitlab.com:alice/project.git", enabled: true, hkFile: true, cache: "missing"}, + {name: "local branch tracking source", remote: "git@github.com:alice/project.git", enabled: true, hkFile: true, localStart: true, wantInstall: true}, + {name: "organization HTTPS", remote: "https://github.com/SECOND-ORG/project.git", enabled: true, hkFile: true, wantInstall: true}, + {name: "missing cache", remote: "git@github.com:alice/project.git", enabled: true, hkFile: true, cache: "missing", wantWarn: "GitHub identity cache is unavailable"}, + {name: "expired cache", remote: "git@github.com:alice/project.git", enabled: true, hkFile: true, cache: "expired", wantWarn: "GitHub identity cache is unavailable"}, + {name: "hk failure", remote: "git@github.com:alice/project.git", enabled: true, hkFile: true, hkFails: true, wantInstall: true, wantWarn: "hk install failed"}, + } { + t.Run(test.name, func(t *testing.T) { + if test.interactive { + skipIfNoPty(t) + if timberBin == "" { + timberBin = buildTestTimberBinary(parent) + } + } + repository := newTestRepository(t) + if test.hkFile || test.miseFile { + seedRepositoryFiles(t, repository, test.hkFile, test.hkSymlink, test.miseFile, test.miseSymlink) + } + // Fetch locally. The selected upstream belongs to a different remote, + // so installing hooks for origin by mistake would not pass the + // positive cases. + runGitCommand(t, repository.barePath, "fetch", "origin") + runGitCommand(t, repository.barePath, "remote", "add", "source", test.remote) + runGitCommand(t, repository.barePath, "update-ref", "refs/remotes/source/main", "refs/remotes/origin/main") + runtime := repository.runtime + runtime.GhExecutable = filepath.Join(resolvedTempDir(t), "missing-gh") + if test.enabled { + result := runTimberCommandWithRuntime(t, runtime, "config", "--auto-hk-install") + require.NoError(t, result.err) + } + if test.explicitDisable { + result := runTimberCommandWithRuntime(t, runtime, "config", "--no-auto-hk-install") + require.NoError(t, result.err) + } + if test.interactive { + // Keep automatic mise trust from asking its own question first. + result := runTimberCommandWithRuntime(t, runtime, "config", "--no-auto-mise-trust") + require.NoError(t, result.err) + } + if test.cache != "missing" { + fetchedAt := time.Now().UTC() + if test.cache == "expired" { + fetchedAt = fetchedAt.Add(-25 * time.Hour) + } + organizations := `["first-org","second-org"]` + if test.noOrganizations { + organizations = `[]` + } + cache := fmt.Sprintf(`{"username":"alice","organizations":%s,"fetched_at":%q}`, organizations, fetchedAt.Format(time.RFC3339Nano)) + require.NoError(t, writePrivateFile(filepath.Join(runtime.DataHome, "timber", "github-identity.json"), []byte(cache))) + } + bin := resolvedTempDir(t) + logPath := filepath.Join(bin, "hk.log") + script := fmt.Sprintf("#!/bin/sh\n[ -f .git ] && [ -f hk.pkl ] || exit 2\nprintf '%%s\\n' \"$PWD\" \"$@\" > %q\n", logPath) + if test.hkFails { + script += "echo 'install refused' >&2\nexit 1\n" + } + require.NoError(t, os.WriteFile(filepath.Join(bin, "hk"), []byte(script), 0o700)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + runtime = withTestEnvironment(runtime, "PATH="+os.Getenv("PATH")) + upstream := "source/main" + if test.localStart { + runGitCommand(t, repository.barePath, "branch", "--track", "local-start", "source/main") + upstream = "local-start" + } + var result commandResult + if test.interactive { + result = interactiveCreate(t, runtime, timberBin, test.answer, "feature") + } else { + result = runTimberCommandWithRuntime(t, runtime, "create", "--upstream", upstream, at(testRepoName, "feature")) + } + require.NoError(t, result.err) + assert.FileExists(t, filepath.Join(repository.worktreePath("feature"), ".git")) + if test.wantWarn != "" { + assert.Contains(t, result.stderr, "warning: automatic hk install skipped:") + assert.Contains(t, result.stderr, test.wantWarn) + } else { + assert.NotContains(t, result.stderr, "warning:") + } + if test.wantInstall { + contents, err := os.ReadFile(logPath) + require.NoError(t, err) + path := repository.worktreePath("feature") + want := path + "\ninstall\n" + if test.wantMise { + want += "--mise\n" + } + assert.Equal(t, want, string(contents)) + } else { + assert.NoFileExists(t, logPath) + } + if test.wantPrompt { + assert.Contains(t, result.stderr, "permits code execution") + assert.Contains(t, result.stderr, "[y/N]") + assert.Contains(t, result.stderr, "GitHub username: alice") + organizations := "GitHub organizations: first-org, second-org" + if test.noOrganizations { + organizations = "GitHub organizations: none" + } + assert.Contains(t, result.stderr, organizations) + assert.Less(t, strings.Index(result.stderr, organizations), strings.Index(result.stderr, "[y/N]")) + settings, err := runtime.loadConfig() + require.NoError(t, err) + assert.True(t, settings.AutoHKInstallDecided) + assert.Equal(t, test.wantInstall, settings.AutoHKInstall) + if !test.wantInstall { + assert.Contains(t, result.stderr, "Enable it later with: timber config --auto-hk-install") + } + again := interactiveCreate(t, runtime, timberBin, test.answer, "second") + require.NoError(t, again.err) + assert.NotContains(t, again.stderr, "[y/N]") + if !test.wantInstall { + assert.NoFileExists(t, logPath) + // The documented command can reverse a saved refusal. + require.NoError(t, runTimberCommandWithRuntime(t, runtime, "config", "--auto-hk-install").err) + require.NoError(t, runTimberCommandWithRuntime(t, runtime, "create", "--upstream", "source/main", at(testRepoName, "third")).err) + assert.FileExists(t, logPath) + } + } else { + assert.NotContains(t, result.stderr, "[y/N]") + } + }) + } +} + +func TestCreateTrustsMiseBeforeInstallingHKHooks(t *testing.T) { + repository := newTestRepository(t) + seedRepositoryFiles(t, repository, true, false, true, false) + runGitCommand(t, repository.barePath, "fetch", "origin") + runGitCommand(t, repository.barePath, "remote", "add", "source", "git@github.com:alice/project.git") + runGitCommand(t, repository.barePath, "update-ref", "refs/remotes/source/main", "refs/remotes/origin/main") + + runtime := repository.runtime + cache := fmt.Sprintf(`{"username":"alice","organizations":[],"fetched_at":%q}`, time.Now().UTC().Format(time.RFC3339Nano)) + require.NoError(t, writePrivateFile(filepath.Join(runtime.DataHome, "timber", "github-identity.json"), []byte(cache))) + require.NoError(t, runTimberCommandWithRuntime(t, runtime, "config", "--auto-mise-trust").err) + require.NoError(t, runTimberCommandWithRuntime(t, runtime, "config", "--auto-hk-install").err) + + bin := resolvedTempDir(t) + logPath := filepath.Join(bin, "automation.log") + for name, operation := range map[string]string{"mise": "mise", "hk": "hk"} { + script := fmt.Sprintf("#!/bin/sh\nprintf '%s %%s\\n' \"$*\" >> %q\n", operation, logPath) + require.NoError(t, os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700)) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + runtime = withTestEnvironment(runtime, "PATH="+os.Getenv("PATH")) + + result := runTimberCommandWithRuntime(t, runtime, "create", "--upstream", "source/main", at(testRepoName, "feature")) + require.NoError(t, result.err) + assert.NotContains(t, result.stderr, "warning:") + + contents, err := os.ReadFile(logPath) + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(contents)), "\n") + require.Len(t, lines, 2) + assert.True(t, strings.HasPrefix(lines[0], "mise trust ")) + assert.Equal(t, "hk install --mise", lines[1]) +} + +func seedRepositoryFiles(t *testing.T, repository testRepository, hkFile, hkSymlink, miseFile, miseSymlink bool) { + t.Helper() + seed := filepath.Join(resolvedTempDir(t), "seed") + runGitCommand(t, repository.home, "clone", repository.remotePath, seed) + configureGitUser(t, seed) + if hkFile { + if hkSymlink { + require.NoError(t, os.Symlink("README.md", filepath.Join(seed, "hk.pkl"))) + } else { + require.NoError(t, os.WriteFile(filepath.Join(seed, "hk.pkl"), []byte("hooks {}\n"), 0o600)) + } + } + if miseFile { + if miseSymlink { + require.NoError(t, os.Symlink("README.md", filepath.Join(seed, "mise.toml"))) + } else { + require.NoError(t, os.WriteFile(filepath.Join(seed, "mise.toml"), []byte("[tools]\n"), 0o600)) + } + } + runGitCommand(t, seed, "add", ".") + runGitCommand(t, seed, "commit", "-m", "add repository config") + runGitCommand(t, seed, "push") +} diff --git a/internal/timber/mise_trust.go b/internal/timber/mise_trust.go index 30bd470..df6ae10 100644 --- a/internal/timber/mise_trust.go +++ b/internal/timber/mise_trust.go @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "io" "net/url" "os" "path/filepath" @@ -25,33 +26,21 @@ func (x Runtime) autoTrustMise(command *cobra.Command, repository *Repository, u return } configPath := filepath.Join(worktreePath, "mise.toml") - info, err := os.Lstat(configPath) - if errors.Is(err, os.ErrNotExist) { - return - } + regular, err := regularRootFile(configPath) if err != nil { warn(err) return } // Do not trust a symlink that can point outside the new checkout. - if !info.Mode().IsRegular() { + if !regular { return } - remoteURL, err := repository.upstreamRemoteURL(upstream) + identity, owned, err := x.githubOwnedWorktree(command, repository, upstream) if err != nil { warn(err) return } - owner := githubRemoteOwner(remoteURL) - if owner == "" { - return - } - identity, err := x.githubIdentity(command.Context()) - if err != nil { - warn(err) - return - } - if !identity.owns(owner) { + if !owned { return } if !settings.AutoMiseTrust { @@ -72,16 +61,55 @@ func (x Runtime) autoTrustMise(command *cobra.Command, repository *Repository, u } } -func (x Runtime) promptAutoMiseTrust(command *cobra.Command, settings config, identity githubIdentity) (bool, error) { - output := command.ErrOrStderr() - if _, err := fmt.Fprintln(output, "This worktree qualifies for automatic mise trust. Trusting mise configuration permits code execution."); err != nil { +// regularRootFile reports whether path is a regular file instead of a symlink +// or directory. A missing file is not an error. +func regularRootFile(path string) (bool, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { return false, err } + return info.Mode().IsRegular(), nil +} + +func writeGitHubIdentity(output io.Writer, identity githubIdentity) error { organizations := strings.Join(identity.Organizations, ", ") if organizations == "" { organizations = "none" } - if _, err := fmt.Fprintf(output, "GitHub username: %s\nGitHub organizations: %s\n", identity.Username, organizations); err != nil { + _, err := fmt.Fprintf(output, "GitHub username: %s\nGitHub organizations: %s\n", identity.Username, organizations) + return err +} + +// githubOwnedWorktree reports whether the selected upstream resolves to a +// GitHub repository owned by the cached user or one of their organizations. +func (x Runtime) githubOwnedWorktree(command *cobra.Command, repository *Repository, upstream string) (githubIdentity, bool, error) { + remoteURL, err := repository.upstreamRemoteURL(upstream) + if err != nil { + return githubIdentity{}, false, err + } + owner := githubRemoteOwner(remoteURL) + if owner == "" { + return githubIdentity{}, false, nil + } + identity, err := x.githubIdentity(command.Context()) + if err != nil { + return githubIdentity{}, false, err + } + if !identity.owns(owner) { + return githubIdentity{}, false, nil + } + return identity, true, nil +} + +func (x Runtime) promptAutoMiseTrust(command *cobra.Command, settings config, identity githubIdentity) (bool, error) { + output := command.ErrOrStderr() + if _, err := fmt.Fprintln(output, "This worktree qualifies for automatic mise trust. Trusting mise configuration permits code execution."); err != nil { + return false, err + } + if err := writeGitHubIdentity(output, identity); err != nil { return false, err } reader := bufio.NewReader(command.InOrStdin()) diff --git a/internal/timber/mise_trust_test.go b/internal/timber/mise_trust_test.go index 709bb6a..f07fb5e 100644 --- a/internal/timber/mise_trust_test.go +++ b/internal/timber/mise_trust_test.go @@ -129,7 +129,7 @@ func TestCreateAutomaticallyTrustsOnlyEligibleMiseConfigs(t *testing.T) { } var result commandResult if test.interactive { - result = interactiveMiseCreate(t, runtime, timberBin, test.answer, "feature") + result = interactiveCreate(t, runtime, timberBin, test.answer, "feature") } else { result = runTimberCommandWithRuntime(t, runtime, "create", "--upstream", upstream, at(testRepoName, "feature")) } @@ -169,7 +169,7 @@ func TestCreateAutomaticallyTrustsOnlyEligibleMiseConfigs(t *testing.T) { if !test.wantTrust { assert.Contains(t, result.stderr, "Enable it later with: timber config --auto-mise-trust") } - again := interactiveMiseCreate(t, runtime, timberBin, test.answer, "second") + again := interactiveCreate(t, runtime, timberBin, test.answer, "second") require.NoError(t, again.err) assert.NotContains(t, again.stderr, "[y/N]") if !test.wantTrust { @@ -187,7 +187,7 @@ func TestCreateAutomaticallyTrustsOnlyEligibleMiseConfigs(t *testing.T) { } // Exercise terminal detection and the persisted choice through the real CLI. -func interactiveMiseCreate(t *testing.T, runtime Runtime, bin, answer, branch string) commandResult { +func interactiveCreate(t *testing.T, runtime Runtime, bin, answer, branch string) commandResult { t.Helper() script := `import os, pty, select, signal, sys, time pid, fd = pty.fork()