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/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) 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 #