diff --git a/internal/gitutil/repo_context.go b/internal/gitutil/repo_context.go index 34018e9..6032b68 100644 --- a/internal/gitutil/repo_context.go +++ b/internal/gitutil/repo_context.go @@ -2,17 +2,23 @@ package gitutil import ( "context" + "errors" "fmt" + "net/url" "os/exec" + "slices" "strings" ) -// tangledSSHPrefix is the SSH remote prefix for Tangled repositories. -const tangledSSHPrefix = "git@tangled.org:" +// tangledHost is the hostname for Tangled repositories. +const tangledHost = "tangled.org" + +// defaultRemote is the conventional name of the primary git remote. +const defaultRemote = "origin" // tangledRemoteURL builds the SSH clone/push URL for a Tangled repo. func tangledRemoteURL(handle, repo string) string { - return tangledSSHPrefix + handle + "/" + repo + return "git@" + tangledHost + ":" + handle + "/" + repo } // RepoContext holds the handle and repo name parsed from a git remote URL. @@ -21,33 +27,122 @@ type RepoContext struct { Repo string } -// DetectRepoFromCWD reads the "origin" remote URL in the current directory -// It supports the ssh format git@tangled.org:handle/repo. +// DetectRepoFromCWD scans the git remotes in the current directory for one +// pointing at Tangled, checking the default remote first. Returns the first +// match. func DetectRepoFromCWD(ctx context.Context) (*RepoContext, error) { - cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin") - output, err := cmd.Output() + remotes, err := gitLines(ctx, "remote") if err != nil { - return nil, fmt.Errorf("get remote origin URL: %w", err) + return nil, fmt.Errorf("list git remotes: %w", err) + } + + for _, name := range originFirst(remotes) { + urls, err := gitLines(ctx, "remote", "get-url", "--all", name) + if err != nil { + return nil, fmt.Errorf("get URLs for remote %q: %w", name, err) + } + for _, raw := range urls { + if rc, ok := parseTangledURL(raw); ok { + return rc, nil + } + } } - url := strings.TrimSpace(string(output)) + return nil, fmt.Errorf("no Tangled remote found among %d remote(s) %q; pass the repository as handle/repo", len(remotes), remotes) +} + +// originFirst returns remotes with the default remote first (if present), +// followed by the rest in their original order. +func originFirst(remotes []string) []string { + idx := slices.Index(remotes, defaultRemote) + if idx <= 0 { + return remotes + } + ordered := make([]string, 0, len(remotes)) + ordered = append(ordered, defaultRemote) + ordered = append(ordered, remotes[:idx]...) + ordered = append(ordered, remotes[idx+1:]...) + return ordered +} + +// parseTangledURL parses a Tangled git remote URL into handle and repo. +// Returns ok=false for URLs that don't point at Tangled. +// +// Supported formats: SCP-like (git@tangled.org:handle/repo), ssh://, git://, +// https://, and http:// URLs. +func parseTangledURL(raw string) (*RepoContext, bool) { + u, err := parseGitURL(strings.TrimSpace(raw)) + if err != nil { + return nil, false + } + if !strings.EqualFold(u.Hostname(), tangledHost) { + return nil, false + } + return splitHandleRepo(strings.TrimPrefix(u.Path, "/")) +} - if !strings.HasPrefix(url, tangledSSHPrefix) { - return nil, fmt.Errorf( - "remote URL %q does not look like a Tangled repo (expected %s/)", - url, tangledSSHPrefix, - ) +// parseGitURL parses a git remote URL, including SCP-like syntax +// (e.g. git@host:path), which net/url.Parse does not handle. +func parseGitURL(raw string) (*url.URL, error) { + if raw == "" { + return nil, errors.New("empty URL") + } + // Schemed URLs (ssh://, git://, https://, etc.) are handled by stdlib. + if strings.Contains(raw, "://") { + return url.Parse(raw) + } + // SCP-like: [user@]host:path, where ':' comes before any '/'. + colon := strings.Index(raw, ":") + slash := strings.Index(raw, "/") + if colon > 0 && (slash < 0 || colon < slash) { + user, host, hasUser := strings.Cut(raw[:colon], "@") + if !hasUser { + host, user = user, "" + } + var builder strings.Builder + builder.WriteString("ssh://") + if user != "" { + builder.WriteString(user) + builder.WriteString("@") + } + builder.WriteString(host) + builder.WriteString("/") + builder.WriteString(raw[colon+1:]) + return url.Parse(builder.String()) } + // Local path or anything else — let stdlib produce the error. + return url.Parse(raw) +} - path := strings.TrimPrefix(url, tangledSSHPrefix) +// splitHandleRepo splits "handle/repo" (optionally with a trailing .git). +// Leading/trailing slashes are tolerated; returns ok=false for empty +// segments or paths with extra segments. +func splitHandleRepo(path string) (*RepoContext, bool) { + path = strings.Trim(path, "/") parts := strings.SplitN(path, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return nil, fmt.Errorf("could not parse handle/repo from %q", url) + return nil, false + } + if strings.Contains(parts[1], "/") { + return nil, false } - - repoName := strings.TrimSuffix(parts[1], ".git") return &RepoContext{ Handle: parts[0], - Repo: repoName, - }, nil + Repo: strings.TrimSuffix(parts[1], ".git"), + }, true +} + +// gitLines runs git with the given args and returns non-empty output lines. +func gitLines(ctx context.Context, args ...string) ([]string, error) { + out, err := exec.CommandContext(ctx, "git", args...).Output() + if err != nil { + return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + var lines []string + for _, line := range strings.Split(string(out), "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + return lines, nil } diff --git a/internal/gitutil/repo_context_test.go b/internal/gitutil/repo_context_test.go new file mode 100644 index 0000000..4b68e64 --- /dev/null +++ b/internal/gitutil/repo_context_test.go @@ -0,0 +1,77 @@ +package gitutil + +import ( + "slices" + "testing" +) + +func TestParseTangledURL(t *testing.T) { + tests := []struct { + name string + url string + wantOK bool + wantHandle string + wantRepo string + }{ + {"ssh scp-like", "git@tangled.org:aly.codes/tg", true, "aly.codes", "tg"}, + {"ssh scp-like with .git", "git@tangled.org:aly.codes/tg.git", true, "aly.codes", "tg"}, + {"ssh scp-like no user", "tangled.org:aly.codes/tg", true, "aly.codes", "tg"}, + {"ssh scp-like trailing slash", "git@tangled.org:aly.codes/tg/", true, "aly.codes", "tg"}, + {"ssh:// with user", "ssh://git@tangled.org/aly.codes/tg", true, "aly.codes", "tg"}, + {"ssh:// without user", "ssh://tangled.org/aly.codes/tg", true, "aly.codes", "tg"}, + {"ssh:// with port", "ssh://git@tangled.org:2222/aly.codes/tg", true, "aly.codes", "tg"}, + {"git://", "git://tangled.org/aly.codes/tg", true, "aly.codes", "tg"}, + {"git:// with .git", "git://tangled.org/aly.codes/tg.git", true, "aly.codes", "tg"}, + {"https", "https://tangled.org/aly.codes/tg", true, "aly.codes", "tg"}, + {"https with .git", "https://tangled.org/aly.codes/tg.git", true, "aly.codes", "tg"}, + {"https trailing slash", "https://tangled.org/aly.codes/tg/", true, "aly.codes", "tg"}, + {"https .git trailing slash", "https://tangled.org/aly.codes/tg.git/", true, "aly.codes", "tg"}, + {"https extra segment", "https://tangled.org/aly.codes/tg/extra", false, "", ""}, + {"http", "http://tangled.org/aly.codes/tg", true, "aly.codes", "tg"}, + {"hostname case insensitive", "git@Tangled.ORG:aly.codes/tg", true, "aly.codes", "tg"}, + {"github ssh", "git@github.com:alyraffauf/tg.git", false, "", ""}, + {"github https", "https://github.com/alyraffauf/tg.git", false, "", ""}, + {"unrelated", "https://example.com/foo/bar", false, "", ""}, + {"empty", "", false, "", ""}, + {"ssh wrong host", "git@example.org:aly.codes/tg", false, "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rc, ok := parseTangledURL(tt.url) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if rc.Handle != tt.wantHandle { + t.Errorf("Handle = %q, want %q", rc.Handle, tt.wantHandle) + } + if rc.Repo != tt.wantRepo { + t.Errorf("Repo = %q, want %q", rc.Repo, tt.wantRepo) + } + }) + } +} + +func TestOriginFirst(t *testing.T) { + tests := []struct { + name string + input []string + want []string + }{ + {"origin first", []string{"upstream", "origin", "fork"}, []string{"origin", "upstream", "fork"}}, + {"no origin", []string{"upstream", "fork"}, []string{"upstream", "fork"}}, + {"only origin", []string{"origin"}, []string{"origin"}}, + {"empty", []string{}, []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := originFirst(tt.input); !slices.Equal(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +}