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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/timber/remote_url.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/<repo> without a .git suffix. Absolute URLs,
// git@github.com:<owner>/<repo> without a .git suffix. Absolute URLs,
// SSH forms, and local paths pass through unchanged.
func resolveRemoteURL(input string) (string, error) {
trimmed := strings.TrimSpace(input)
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/timber/remote_url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
23 changes: 1 addition & 22 deletions internal/timber/repo_add.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package timber

import (
"context"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
38 changes: 38 additions & 0 deletions internal/timber/repo_add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand Down
94 changes: 1 addition & 93 deletions internal/timber/repo_add_visibility_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package timber

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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)
}
38 changes: 0 additions & 38 deletions internal/timber/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,12 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"time"

"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 15 additions & 15 deletions mise.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tools]
go = "1.27.2"
go = "1.27.3"

#####################
# Development Tasks #
Expand Down
Loading