From c1e56e475bc4d560eacec083fd36c723dd36973d Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Wed, 21 Oct 2026 12:59:52 -0500 Subject: [PATCH 1/2] Use SSH consistently for GitHub shorthand in timber repo add The anonymous GitHub visibility check selected HTTPS for public repositories and inconclusive responses. This overrode the expected SSH default and required a network request before cloning. Resolve `owner/repo` shorthand without that check so visibility does not determine the protocol. Preserve explicit HTTPS and SSH URLs. --- internal/timber/remote_url.go | 4 +- internal/timber/remote_url_test.go | 2 +- internal/timber/repo_add.go | 23 +---- internal/timber/repo_add_test.go | 38 +++++++++ internal/timber/repo_add_visibility_test.go | 94 +-------------------- internal/timber/runtime.go | 38 --------- 6 files changed, 43 insertions(+), 156 deletions(-) diff --git a/internal/timber/remote_url.go b/internal/timber/remote_url.go index 1c6d3ea..18b5f91 100644 --- a/internal/timber/remote_url.go +++ b/internal/timber/remote_url.go @@ -38,7 +38,7 @@ func parseGitHubShortForm(input string) (string, bool) { // resolveRemoteURL maps user input to a git remote URL. // // Schema-less GitHub short forms (e.g. "nnutter/timber") become -// https://github.com// without a .git suffix. Absolute URLs, +// git@github.com:/ without a .git suffix. Absolute URLs, // SSH forms, and local paths pass through unchanged. func resolveRemoteURL(input string) (string, error) { trimmed := strings.TrimSpace(input) @@ -61,7 +61,7 @@ func resolveRemoteURL(input string) (string, error) { return trimmed, nil default: if short, ok := parseGitHubShortForm(trimmed); ok { - return "https://github.com/" + short, nil + return "git@github.com:" + short, nil } return "https://github.com/" + strings.TrimPrefix(trimmed, "/"), nil } diff --git a/internal/timber/remote_url_test.go b/internal/timber/remote_url_test.go index 6c405cf..ee2c798 100644 --- a/internal/timber/remote_url_test.go +++ b/internal/timber/remote_url_test.go @@ -9,7 +9,7 @@ import ( func TestRepoAddMapsGitHubRelativePath(t *testing.T) { t.Parallel() - assert.Equal(t, "https://github.com/nnutter/timber", mustResolveRemoteURL(t, "nnutter/timber")) + assert.Equal(t, "git@github.com:nnutter/timber", mustResolveRemoteURL(t, "nnutter/timber")) assert.Equal(t, "https://example.com/r.git", mustResolveRemoteURL(t, "https://example.com/r.git")) assert.Equal(t, "git@github.com:nnutter/timber.git", mustResolveRemoteURL(t, "git@github.com:nnutter/timber.git")) } diff --git a/internal/timber/repo_add.go b/internal/timber/repo_add.go index 067078e..5c11dfb 100644 --- a/internal/timber/repo_add.go +++ b/internal/timber/repo_add.go @@ -1,7 +1,6 @@ package timber import ( - "context" "errors" "fmt" "os" @@ -32,7 +31,7 @@ func NewRepoAddCommand(runtime Runtime) *cobra.Command { } func (x *repoAddCommandOptions) Execute(command *cobra.Command, args []string) error { - remoteURL, err := x.resolveAddRemoteURL(command.Context(), args[0]) + remoteURL, err := resolveRemoteURL(args[0]) if err != nil { return err } @@ -76,23 +75,3 @@ func (x *repoAddCommandOptions) Execute(command *cobra.Command, args []string) e _, err = fmt.Fprintf(command.ErrOrStderr(), "%s\n", statusStyle.Render("added repository "+repoName+" at "+targetPath)) return err } - -// resolveAddRemoteURL maps user input to the clone URL for registration. -// GitHub short forms resolve to HTTPS when the repository is public and to -// SSH (without a .git suffix) when the GitHub API reports it as private. -// Anything inconclusive falls back to HTTPS. -func (x *repoAddCommandOptions) resolveAddRemoteURL(ctx context.Context, input string) (string, error) { - remoteURL, err := resolveRemoteURL(input) - if err != nil { - return "", err - } - short, ok := parseGitHubShortForm(input) - if !ok { - return remoteURL, nil - } - public, known := x.runtime.githubRepoIsPublic(ctx, short) - if !known || public { - return "https://github.com/" + short, nil - } - return "git@github.com:" + short, nil -} diff --git a/internal/timber/repo_add_test.go b/internal/timber/repo_add_test.go index 2833332..4c4cfd7 100644 --- a/internal/timber/repo_add_test.go +++ b/internal/timber/repo_add_test.go @@ -8,6 +8,44 @@ import ( "github.com/stretchr/testify/require" ) +func TestRepoAddRemoteURL(t *testing.T) { + t.Parallel() + + for _, fixture := range []struct { + input string + want string + }{ + {"nnutter/timber", "git@github.com:nnutter/timber"}, + {"nnutter/timber.git", "git@github.com:nnutter/timber"}, + {"https://github.com/nnutter/timber", "https://github.com/nnutter/timber"}, + {"https://github.com/nnutter/timber.git", "https://github.com/nnutter/timber.git"}, + {"git@github.com:nnutter/timber.git", "git@github.com:nnutter/timber.git"}, + } { + t.Run(fixture.input, func(t *testing.T) { + t.Parallel() + + home := resolvedTempDir(t) + runtime := testRuntimeForHome(home, home) + remote := filepath.Join(resolvedTempDir(t), "remote.git") + runGitCommand(t, home, "init", "--bare", remote) + seedBareRemote(t, remote) + // Redirect only the expected URL to a local remote. A different URL + // must fail instead of attempting a network connection. + runtime.Environment = append(runtime.Environment, + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=url."+remote+".insteadOf", + "GIT_CONFIG_VALUE_0="+fixture.want, + "GIT_ALLOW_PROTOCOL=file", + ) + + result := runTimberCommandWithRuntime(t, runtime, "repo", "add", fixture.input) + require.NoError(t, result.err, result.stderr) + assert.Equal(t, fixture.want+"\n", runGitCommand(t, runtime.bareRepoPath("nnutter/timber"), + "config", "--local", "--get", "remote.origin.url")) + }) + } +} + func TestRepoAddAlias(t *testing.T) { t.Parallel() for _, alias := range []string{"", "custom-alias"} { diff --git a/internal/timber/repo_add_visibility_test.go b/internal/timber/repo_add_visibility_test.go index 63295c1..c62fc65 100644 --- a/internal/timber/repo_add_visibility_test.go +++ b/internal/timber/repo_add_visibility_test.go @@ -1,9 +1,6 @@ package timber import ( - "context" - "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -54,96 +51,7 @@ func mustParseGitHubShortForm(t *testing.T, input string) string { func TestResolveRemoteURLStripsGitSuffixFromShortForm(t *testing.T) { t.Parallel() - assert.Equal(t, "https://github.com/nnutter/timber", mustResolveRemoteURL(t, "nnutter/timber.git")) + assert.Equal(t, "git@github.com:nnutter/timber", mustResolveRemoteURL(t, "nnutter/timber.git")) assert.Equal(t, "https://example.com/r.git", mustResolveRemoteURL(t, "https://example.com/r.git")) assert.Equal(t, "git@github.com:nnutter/timber.git", mustResolveRemoteURL(t, "git@github.com:nnutter/timber.git")) } - -func TestResolveAddRemoteURLUsesSSHForPrivateRepos(t *testing.T) { - t.Parallel() - - fixtures := map[string]struct { - status int - input string - want string - requests int - }{ - "public uses https": {http.StatusOK, "nnutter/timber", "https://github.com/nnutter/timber", 1}, - "private uses ssh without suffix": {http.StatusNotFound, "nnutter/go-template", "git@github.com:nnutter/go-template", 1}, - "public strips git suffix": {http.StatusOK, "nnutter/timber.git", "https://github.com/nnutter/timber", 1}, - "private strips git suffix": {http.StatusNotFound, "nnutter/timber.git", "git@github.com:nnutter/timber", 1}, - "server error falls back to https": {http.StatusInternalServerError, "nnutter/timber", "https://github.com/nnutter/timber", 1}, - } - - for name, fixture := range fixtures { - t.Run(name, func(t *testing.T) { - t.Parallel() - - var requests int - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requests++ - writer.WriteHeader(fixture.status) - })) - t.Cleanup(server.Close) - - runtime := testRuntime(t) - runtime.GitHubAPIBaseURL = server.URL - options := &repoAddCommandOptions{runtime: runtime} - - remoteURL, err := options.resolveAddRemoteURL(context.Background(), fixture.input) - require.NoError(t, err) - assert.Equal(t, fixture.want, remoteURL) - assert.Equal(t, fixture.requests, requests) - }) - } -} - -func TestResolveAddRemoteURLSkipsCheckForExplicitURLs(t *testing.T) { - t.Parallel() - - for _, input := range []string{ - "https://github.com/nnutter/timber", - "https://github.com/nnutter/timber.git", - "git@github.com:nnutter/timber", - "git@github.com:nnutter/timber.git", - "/local/repo.git", - "owner/repo/extra", - } { - t.Run(input, func(t *testing.T) { - t.Parallel() - - var requests int - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests++ - writer.WriteHeader(http.StatusNotFound) - })) - t.Cleanup(server.Close) - - runtime := testRuntime(t) - runtime.GitHubAPIBaseURL = server.URL - options := &repoAddCommandOptions{runtime: runtime} - - remoteURL, err := options.resolveAddRemoteURL(context.Background(), input) - require.NoError(t, err) - assert.Equal(t, mustResolveRemoteURL(t, input), remoteURL) - assert.Zero(t, requests) - }) - } -} - -func TestResolveAddRemoteURLFallsBackWhenUnreachable(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.WriteHeader(http.StatusOK) - })) - server.Close() - - runtime := testRuntime(t) - runtime.GitHubAPIBaseURL = server.URL - options := &repoAddCommandOptions{runtime: runtime} - - remoteURL, err := options.resolveAddRemoteURL(context.Background(), "nnutter/timber") - require.NoError(t, err) - assert.Equal(t, "https://github.com/nnutter/timber", remoteURL) -} diff --git a/internal/timber/runtime.go b/internal/timber/runtime.go index 35b2f7b..9cb4094 100644 --- a/internal/timber/runtime.go +++ b/internal/timber/runtime.go @@ -6,15 +6,12 @@ import ( "context" "errors" "fmt" - "io" - "net/http" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" - "time" "github.com/spf13/cobra" ) @@ -60,11 +57,6 @@ type Runtime struct { // PATH lookup of "trash". TrashExecutable string - // GitHubAPIBaseURL overrides the base URL used for GitHub API - // visibility checks. It is primarily useful for tests; an empty value - // uses https://api.github.com. - GitHubAPIBaseURL string - // TodoSkillContent holds the embedded skills/timber-todo/SKILL.md // installed by `timber todo --install-skill`. It is set by main from // the embedded file; an empty value reports an error instead of @@ -1150,36 +1142,6 @@ func (x Runtime) ghAuthOK(ctx context.Context) bool { return command.Run() == nil } -// githubRepoIsPublic reports whether owner/repo is public on GitHub. A 200 -// from the repository endpoint means public; a 404 means private (or -// missing), since private repositories are hidden from anonymous callers. -// Any other outcome leaves ok false so callers fall back to HTTPS. -func (x Runtime) githubRepoIsPublic(ctx context.Context, ownerRepo string) (public bool, ok bool) { - base := strings.TrimSuffix(strings.TrimSpace(cmp.Or(x.GitHubAPIBaseURL, "https://api.github.com")), "/") - request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/repos/"+ownerRepo, nil) - if err != nil { - return false, false - } - request.Header.Set("Accept", "application/vnd.github+json") - - client := http.Client{Timeout: 10 * time.Second} - response, err := client.Do(request) - if err != nil { - return false, false - } - _, _ = io.Copy(io.Discard, response.Body) - _ = response.Body.Close() - - switch response.StatusCode { - case http.StatusOK: - return true, true - case http.StatusNotFound: - return false, true - default: - return false, false - } -} - func (x Runtime) selectManagedWorktree(worktrees []managedWorktree, name string) (managedWorktree, error) { if name != "" { return managedWorktreeByName(worktrees, name) From a8b12f1d84f6bac49e5d9ef0d07708022eddf8bc Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Wed, 21 Oct 2026 13:01:38 -0500 Subject: [PATCH 2/2] Require Go 1.27.3 to fix crypto/x509 vulnerability GO-2026-6809 Pin Go 1.27.3 because the crypto/x509 vulnerability in Go 1.27.2 caused `mise run on-start` to fail. --- go.mod | 2 +- mise.lock | 30 +++++++++++++++--------------- mise.toml | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index cc752a3..1712534 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/nnutter/timber -go 1.27.2 +go 1.27.3 require ( charm.land/fang/v2 v2.0.1 diff --git a/mise.lock b/mise.lock index 1fd7b65..200f39f 100644 --- a/mise.lock +++ b/mise.lock @@ -40,36 +40,36 @@ url = "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8 url_api = "https://api.github.com/repos/gitleaks/gitleaks/releases/assets/378333178" [[tools.go]] -version = "1.27.2" +version = "1.27.3" backend = "core:go" [tools.go."platforms.linux-arm64"] -checksum = "sha256:94f3e30b8e374bc285e7dadc11e0865726b9bc6e85b841ccceaabc0214c6b7c8" -url = "https://dl.google.com/go/go1.27.2.linux-arm64.tar.gz" +checksum = "sha256:a06e99309dbcbdca1a8ccd2282c3ee146e55930886e26b4f1193269587165f49" +url = "https://dl.google.com/go/go1.27.3.linux-arm64.tar.gz" [tools.go."platforms.linux-arm64-musl"] -checksum = "sha256:94f3e30b8e374bc285e7dadc11e0865726b9bc6e85b841ccceaabc0214c6b7c8" -url = "https://dl.google.com/go/go1.27.2.linux-arm64.tar.gz" +checksum = "sha256:a06e99309dbcbdca1a8ccd2282c3ee146e55930886e26b4f1193269587165f49" +url = "https://dl.google.com/go/go1.27.3.linux-arm64.tar.gz" [tools.go."platforms.linux-x64"] -checksum = "sha256:ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5" -url = "https://dl.google.com/go/go1.27.2.linux-amd64.tar.gz" +checksum = "sha256:65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40" +url = "https://dl.google.com/go/go1.27.3.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl"] -checksum = "sha256:ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5" -url = "https://dl.google.com/go/go1.27.2.linux-amd64.tar.gz" +checksum = "sha256:65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40" +url = "https://dl.google.com/go/go1.27.3.linux-amd64.tar.gz" [tools.go."platforms.macos-arm64"] -checksum = "sha256:76812b213b1b2302c978d28fa52fa92d541704b9e7d9d5db8002c50e4018c4c5" -url = "https://dl.google.com/go/go1.27.2.darwin-arm64.tar.gz" +checksum = "sha256:dd3908cf20959d798c1e06ebe5b72213f0505ce01373b40ce3110975a6cd99c2" +url = "https://dl.google.com/go/go1.27.3.darwin-arm64.tar.gz" [tools.go."platforms.macos-x64"] -checksum = "sha256:587b59182488b23aa6e5fc25110405a3e0e5b38ed2f5b2f46ed13c32aee356fe" -url = "https://dl.google.com/go/go1.27.2.darwin-amd64.tar.gz" +checksum = "sha256:b6f8e931e4ab497fcde6da8ae4499a58f9d258e55c8a80824a516eae5950a750" +url = "https://dl.google.com/go/go1.27.3.darwin-amd64.tar.gz" [tools.go."platforms.windows-x64"] -checksum = "sha256:1314008898bd40df77af4b014f777f08873dbdfbcd3d92308728ee03304fe04f" -url = "https://dl.google.com/go/go1.27.2.windows-amd64.zip" +checksum = "sha256:f26081f438319757f8a1b46d7d0b2c5ec7583d4038e485e0aa725cfde7885fff" +url = "https://dl.google.com/go/go1.27.3.windows-amd64.zip" [[tools."go:github.com/Antonboom/testifylint"]] version = "1.6.4" diff --git a/mise.toml b/mise.toml index 24f261c..7c33a27 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -go = "1.27.2" +go = "1.27.3" ##################### # Development Tasks #