From 231cdb7105ce73e3d26ca0d021f7d74ad408e789 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 22 Oct 2026 07:23:14 -0500 Subject: [PATCH 1/8] Refactor: Extract regular root file check for automatic mise trust Automatic hk hook installation will need the same symlink-safe root file check before it inspects `hk.pkl`. --- internal/timber/mise_trust.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/timber/mise_trust.go b/internal/timber/mise_trust.go index 30bd470..8cf624c 100644 --- a/internal/timber/mise_trust.go +++ b/internal/timber/mise_trust.go @@ -25,16 +25,13 @@ 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) @@ -72,6 +69,19 @@ func (x Runtime) autoTrustMise(command *cobra.Command, repository *Repository, u } } +// 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 (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 { From 7b7e4e2225235a8716f8917d5fa10352e5af1c82 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 22 Oct 2026 07:24:37 -0500 Subject: [PATCH 2/8] Refactor: Extract GitHub-owned worktree eligibility check Automatic mise trust and automatic hk hook installation must agree on which repositories are eligible for code-running automation. --- internal/timber/mise_trust.go | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/internal/timber/mise_trust.go b/internal/timber/mise_trust.go index 8cf624c..fdfc5ca 100644 --- a/internal/timber/mise_trust.go +++ b/internal/timber/mise_trust.go @@ -34,21 +34,12 @@ func (x Runtime) autoTrustMise(command *cobra.Command, repository *Repository, u if !regular { return } - remoteURL, err := repository.upstreamRemoteURL(upstream) - if err != nil { - warn(err) - return - } - owner := githubRemoteOwner(remoteURL) - if owner == "" { - return - } - identity, err := x.githubIdentity(command.Context()) + identity, owned, err := x.githubOwnedWorktree(command, repository, upstream) if err != nil { warn(err) return } - if !identity.owns(owner) { + if !owned { return } if !settings.AutoMiseTrust { @@ -82,6 +73,27 @@ func regularRootFile(path string) (bool, error) { return info.Mode().IsRegular(), nil } +// 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 { From bf3f2340a7f1e2611ff22c5fdd86e971db697a5f Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 22 Oct 2026 07:25:16 -0500 Subject: [PATCH 3/8] Add persistent opt-in setting for automatic hk hook installation Let users enable or disable hk hook installation ahead of worktree creation. Default to disabled because installed hooks can execute repository code. --- README.md | 5 ++++- internal/timber/config.go | 19 ++++++++++++++++++- internal/timber/config_test.go | 17 ++++++++++++----- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e8cbea3..36154ad 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,13 @@ 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. Non-interactive creation does not prompt or record a choice. +Automatic hk hook installation is disabled by default. ```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. @@ -246,7 +249,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) { From 268ada29c55e98a26e915348f8bcbfa9fb181079 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 22 Oct 2026 07:27:48 -0500 Subject: [PATCH 4/8] Install hk hooks in eligible new worktrees Run hk install after mise trust when a worktree has a root-level hk.pkl and its GitHub upstream is owned by the user or an organization. Add --mise only when a regular root mise.toml exists so hooks can use mise-managed tools without forcing the option in repositories that do not use mise. Warn and keep the worktree when hk is missing or fails. --- README.md | 6 ++ internal/timber/create.go | 1 + internal/timber/hk_install.go | 54 ++++++++++++ internal/timber/hk_install_test.go | 130 +++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 internal/timber/hk_install.go create mode 100644 internal/timber/hk_install_test.go diff --git a/README.md b/README.md index 36154ad..a9a1d4a 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,17 @@ 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. +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 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..4e015fb --- /dev/null +++ b/internal/timber/hk_install.go @@ -0,0 +1,54 @@ +package timber + +import ( + "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 { + return + } + regular, err := regularRootFile(filepath.Join(worktreePath, "hk.pkl")) + if err != nil { + warn(err) + return + } + if !regular { + return + } + _, owned, err := x.githubOwnedWorktree(command, repository, upstream) + if err != nil { + warn(err) + return + } + if !owned { + 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)))) + } +} diff --git a/internal/timber/hk_install_test.go b/internal/timber/hk_install_test.go new file mode 100644 index 0000000..e46ad54 --- /dev/null +++ b/internal/timber/hk_install_test.go @@ -0,0 +1,130 @@ +package timber + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateAutomaticallyInstallsHKHooksOnlyForEligibleRepositories(t *testing.T) { + 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 + }{ + {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: "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) { + 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.cache != "missing" { + fetchedAt := time.Now().UTC() + if test.cache == "expired" { + fetchedAt = fetchedAt.Add(-25 * time.Hour) + } + cache := fmt.Sprintf(`{"username":"alice","organizations":["first-org","second-org"],"fetched_at":%q}`, 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" + } + 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) + } + }) + } +} + +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") +} From 09cb2f5252c3e1c50462f001056b15a183bfb309 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 22 Oct 2026 07:29:06 -0500 Subject: [PATCH 5/8] Refactor: Rename interactive prompt test helper The helper only knows the shared `[y/N]` prompt, and automatic hk hook installation tests will use it too. --- internal/timber/mise_trust_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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() From 66b06971fcd6c6b7b6763347e6eb290ecfd0834a Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 22 Oct 2026 07:29:36 -0500 Subject: [PATCH 6/8] Refactor: Extract GitHub identity prompt lines Automatic mise trust and automatic hk hook installation must present the same account and organization context before asking for consent. --- internal/timber/mise_trust.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/timber/mise_trust.go b/internal/timber/mise_trust.go index fdfc5ca..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" @@ -73,6 +74,15 @@ func regularRootFile(path string) (bool, error) { return info.Mode().IsRegular(), nil } +func writeGitHubIdentity(output io.Writer, identity githubIdentity) error { + organizations := strings.Join(identity.Organizations, ", ") + if organizations == "" { + organizations = "none" + } + _, 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) { @@ -99,11 +109,7 @@ func (x Runtime) promptAutoMiseTrust(command *cobra.Command, settings config, id if _, err := fmt.Fprintln(output, "This worktree qualifies for automatic mise trust. Trusting mise configuration permits code execution."); err != nil { return false, err } - 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 { + if err := writeGitHubIdentity(output, identity); err != nil { return false, err } reader := bufio.NewReader(command.InOrStdin()) From 3bf5e577ad27e6da2bdf4ef818d14f605a7b2365 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 22 Oct 2026 07:38:38 -0500 Subject: [PATCH 7/8] Offer automatic hk hook installation once for eligible worktrees Ask on the first eligible interactive worktree when the setting is undecided, defaulting to No because installed hooks run repository code. Save either answer in a decision marker so an explicit refusal is not mistaken for the disabled default and the prompt does not repeat. Show the GitHub account and organizations covered by the choice, and point to `timber config --auto-hk-install` after refusal. --- README.md | 12 ++-- internal/timber/hk_install.go | 55 +++++++++++++++- internal/timber/hk_install_test.go | 100 ++++++++++++++++++++++++----- 3 files changed, 144 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a9a1d4a..9221cf5 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,12 @@ 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. -Automatic hk hook installation is disabled by default. ```bash timber config --auto-mise-trust @@ -105,6 +104,7 @@ If Timber cannot refresh an invalid cache, it warns and skips trust without fail 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. diff --git a/internal/timber/hk_install.go b/internal/timber/hk_install.go index 4e015fb..be8d97f 100644 --- a/internal/timber/hk_install.go +++ b/internal/timber/hk_install.go @@ -1,6 +1,7 @@ package timber import ( + "bufio" "fmt" "path/filepath" "strings" @@ -17,7 +18,7 @@ func (x Runtime) autoInstallHKHooks(command *cobra.Command, repository *Reposito warn(err) return } - if !settings.AutoHKInstall { + if !settings.AutoHKInstall && (settings.AutoHKInstallDecided || !isInteractiveTerminal(command.InOrStdin())) { return } regular, err := regularRootFile(filepath.Join(worktreePath, "hk.pkl")) @@ -28,7 +29,7 @@ func (x Runtime) autoInstallHKHooks(command *cobra.Command, repository *Reposito if !regular { return } - _, owned, err := x.githubOwnedWorktree(command, repository, upstream) + identity, owned, err := x.githubOwnedWorktree(command, repository, upstream) if err != nil { warn(err) return @@ -36,6 +37,16 @@ func (x Runtime) autoInstallHKHooks(command *cobra.Command, repository *Reposito 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 { @@ -52,3 +63,43 @@ func (x Runtime) autoInstallHKHooks(command *cobra.Command, repository *Reposito 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 index e46ad54..5a4f9d9 100644 --- a/internal/timber/hk_install_test.go +++ b/internal/timber/hk_install_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -12,24 +13,39 @@ import ( ) 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 + 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}, @@ -42,6 +58,12 @@ func TestCreateAutomaticallyInstallsHKHooksOnlyForEligibleRepositories(t *testin {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) @@ -58,12 +80,25 @@ func TestCreateAutomaticallyInstallsHKHooksOnlyForEligibleRepositories(t *testin 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) } - cache := fmt.Sprintf(`{"username":"alice","organizations":["first-org","second-org"],"fetched_at":%q}`, fetchedAt.Format(time.RFC3339Nano)) + 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) @@ -80,7 +115,12 @@ func TestCreateAutomaticallyInstallsHKHooksOnlyForEligibleRepositories(t *testin runGitCommand(t, repository.barePath, "branch", "--track", "local-start", "source/main") upstream = "local-start" } - result := runTimberCommandWithRuntime(t, runtime, "create", "--upstream", upstream, at(testRepoName, "feature")) + 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 != "" { @@ -101,6 +141,36 @@ func TestCreateAutomaticallyInstallsHKHooksOnlyForEligibleRepositories(t *testin } 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]") + } }) } } From 7a045bfe35739829bdf08e8862a7b91578a17b18 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Thu, 22 Oct 2026 08:34:48 -0500 Subject: [PATCH 8/8] Test mise trust runs before hk hook installation hk install --mise wraps hooks in mise, so the new worktree must be trusted before hooks are installed. Pin the ordering required by the automatic setup in create. --- internal/timber/hk_install_test.go | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/timber/hk_install_test.go b/internal/timber/hk_install_test.go index 5a4f9d9..015a3e9 100644 --- a/internal/timber/hk_install_test.go +++ b/internal/timber/hk_install_test.go @@ -175,6 +175,40 @@ func TestCreateAutomaticallyInstallsHKHooksOnlyForEligibleRepositories(t *testin } } +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")