From 5d4793e0493ab512f83a382acae5a2bd7ad5ca00 Mon Sep 17 00:00:00 2001 From: Radin Date: Sat, 14 Nov 2026 22:33:50 +0000 Subject: [PATCH 1/3] Add Upstream as a built-in GitHub-compatible host Upstream (upstream.codes) serves GitHub's REST API at /api/v3, so the GitHub backend already works against it once the host is detected. Its clone URLs are on git.coline.app and ssh.git.coline.app, which don't serve the API, so forge couldn't resolve a repository from a clone. Register upstream.codes with the defaults, map its git hosts to it, and read UPSTREAM_TOKEN. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++++---- factory/factory.go | 2 ++ factory/factory_test.go | 11 ++++++----- internal/cli/auth.go | 2 +- internal/resolve/resolve.go | 21 ++++++++++++++++++--- internal/resolve/resolve_test.go | 15 ++++++++++++++- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 45603db..3ca6479 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # forge -Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, Gerrit, Tangled and Radicle (read-only) through a single interface. +Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, Gerrit, Tangled and Radicle (read-only) through a single interface. GitHub Enterprise and other hosts that serve GitHub's REST API, such as [Upstream](https://upstream.codes), work through the GitHub backend. ## CLI @@ -14,7 +14,7 @@ Or with Go: go install github.com/git-pkgs/forge/cmd/forge@latest ``` -The CLI detects which forge to use from your git remote, or you can set it with `--forge-type`. +The CLI detects which forge to use from your git remote, or you can set it with `--forge-type`. Upstream is built in: remotes on `git.coline.app` and `ssh.git.coline.app`, where Upstream serves git, resolve to its API on `upstream.codes`. ``` forge repo view @@ -61,7 +61,7 @@ Command for token (e.g. rbw get github.com): rbw get github-token Check what's configured with `forge auth status`. -Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`, `TANGLED_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` to override it (for example `forge --host gitea.com repo list someone`). `FORGE_HOST` sets the host for `-R OWNER/REPO` and for commands that don't act on the current repo, but doesn't override the current repo's remote. +Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`, `TANGLED_TOKEN`, `UPSTREAM_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` to override it (for example `forge --host gitea.com repo list someone`). `FORGE_HOST` sets the host for `-R OWNER/REPO` and for commands that don't act on the current repo, but doesn't override the current repo's remote. Radicle repositories have no owner, so they're selected by repository ID: `-R rad:` or `-R HOST/rad:`. The node to query comes from the host in `-R`, `--host`, `FORGE_HOST` or a section with `type = radicle` in your user config (a project `.forge` file can't pick the node). There's no default node, so a repository ID is never sent to a seed you didn't choose. For example `forge --host seed.radicle.dev -R rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 file ls`. The Radicle backend is read-only over `radicle-httpd` and currently covers repositories, branches, tags and files. @@ -189,7 +189,7 @@ case errors.Is(err, forges.ErrServerError): // 5xx } ``` -The `factory` package imports every provider and returns a `Client` that already knows github.com, gitlab.com, codeberg.org, bitbucket.org and tangled.org. It detects and registers any other domain over https the first time a repository URL on it is used, which sends requests to that domain: +The `factory` package imports every provider and returns a `Client` that already knows github.com, gitlab.com, codeberg.org, bitbucket.org, tangled.org and upstream.codes. It detects and registers any other domain over https the first time a repository URL on it is used, which sends requests to that domain: ```go import "github.com/git-pkgs/forge/factory" diff --git a/factory/factory.go b/factory/factory.go index f71dfa0..e8d206f 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -38,6 +38,8 @@ func DefaultForges(token func(domain string) string, hc *http.Client) map[string "codeberg.org": gitea.New("https://codeberg.org", token("codeberg.org"), hc), "bitbucket.org": bitbucket.New(token("bitbucket.org"), hc), "tangled.org": tangled.New("https://tangled.org", token("tangled.org"), hc), + // Upstream serves GitHub's REST API at /api/v3. + "upstream.codes": github.NewWithBase("https://upstream.codes", token("upstream.codes"), hc), } } diff --git a/factory/factory_test.go b/factory/factory_test.go index 9d97427..3eaac7b 100644 --- a/factory/factory_test.go +++ b/factory/factory_test.go @@ -71,11 +71,12 @@ func (rt *giteaTransport) RoundTrip(req *http.Request) (*http.Response, error) { func TestNewClientRegistersDefaultForges(t *testing.T) { c := NewClient() for domain, wantAPI := range map[string]string{ - "github.com": "https://api.github.com", - "gitlab.com": "https://gitlab.com/api/v4", - "codeberg.org": "https://codeberg.org/api/v1", - "bitbucket.org": "https://api.bitbucket.org/2.0", - "tangled.org": "https://tangled.org/xrpc", + "github.com": "https://api.github.com", + "gitlab.com": "https://gitlab.com/api/v4", + "codeberg.org": "https://codeberg.org/api/v1", + "bitbucket.org": "https://api.bitbucket.org/2.0", + "tangled.org": "https://tangled.org/xrpc", + "upstream.codes": "https://upstream.codes/api/v3", } { f, err := c.ForgeFor(domain) if err != nil { diff --git a/internal/cli/auth.go b/internal/cli/auth.go index b12f170..2b97ab5 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -214,7 +214,7 @@ func authStatusCmd() *cobra.Command { } // Known domains to check for env var tokens - knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org", "tangled.org"} + knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org", "tangled.org", "upstream.codes"} // Collect all unique domains domains := make(map[string]bool) diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 8961a71..c24593f 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -325,15 +325,26 @@ func resolveRemote() (domain, owner, repo string, err error) { // Returns the input unchanged when no mapping is configured. func mapSSHHost(domain string) string { cfg, err := config.Load() - if err != nil { - return domain + if err == nil { + if api := cfg.DomainForSSHHost(domain); api != "" { + return api + } } - if api := cfg.DomainForSSHHost(domain); api != "" { + if api, ok := knownGitHosts[domain]; ok { return api } return domain } +// knownGitHosts maps the git hosts of hosted forges whose clone URLs live on +// a different host from their API. A config ssh_host takes precedence. +var knownGitHosts = map[string]string{ + // Upstream clones over https://git.coline.app and git@ssh.git.coline.app, + // while its API and web pages are on upstream.codes. + "git.coline.app": "upstream.codes", + "ssh.git.coline.app": "upstream.codes", +} + func gitRemoteURL(name string) (string, error) { out, err := exec.Command("git", "remote", "get-url", name).Output() if err != nil { @@ -547,6 +558,10 @@ func TokenForDomainEnv(domain string) string { if t := os.Getenv("TANGLED_TOKEN"); t != "" { return t } + case "upstream.codes": + if t := os.Getenv("UPSTREAM_TOKEN"); t != "" { + return t + } } // FORGE_TOKEN is a fallback for any domain without a specific token. diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 2302e6e..a7be817 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -67,6 +67,9 @@ ssh_host = ssh.gitlab.test // no mapping: pass through unchanged {"github.com", "github.com"}, {"gitlab.test", "gitlab.test"}, + // built-in git hosts of a hosted forge + {"git.coline.app", "upstream.codes"}, + {"ssh.git.coline.app", "upstream.codes"}, } for _, tt := range tests { @@ -95,7 +98,7 @@ func clearTokenEnv(t *testing.T) { for _, v := range []string{ "GITHUB_TOKEN", "GH_TOKEN", "GITLAB_TOKEN", "GLAB_TOKEN", - "FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", "TANGLED_TOKEN", + "FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", "TANGLED_TOKEN", "UPSTREAM_TOKEN", "FORGE_TOKEN", } { t.Setenv(v, "") @@ -172,6 +175,14 @@ func TestTokenForDomain(t *testing.T) { t.Errorf("expected tangled-tok, got %q", got) } t.Setenv("TANGLED_TOKEN", "") + + // Upstream + t.Setenv("UPSTREAM_TOKEN", "upstream-tok") + got = TokenForDomain("upstream.codes") + if got != "upstream-tok" { + t.Errorf("expected upstream-tok, got %q", got) + } + t.Setenv("UPSTREAM_TOKEN", "") } func TestTokenForDomainLogsFailingCommand(t *testing.T) { @@ -661,6 +672,8 @@ func TestRepoResolvesGitRemoteURLs(t *testing.T) { {name: "HTTPS userinfo", remoteURL: "https://user:token@github.com/owner/repo.git", domain: "github.com", owner: "owner"}, {name: "bracketed IPv6", remoteURL: "ssh://git@[2001:db8::1]:2222/owner/repo.git", domain: "2001:db8::1", owner: "owner"}, {name: "bracketed IPv6 SCP", remoteURL: "git@[2001:db8::1]:owner/repo.git", domain: "2001:db8::1", owner: "owner"}, + {name: "Upstream HTTPS", remoteURL: "https://git.coline.app/owner/repo.git", domain: "upstream.codes", owner: "owner"}, + {name: "Upstream SSH", remoteURL: "git@ssh.git.coline.app:owner/repo.git", domain: "upstream.codes", owner: "owner"}, {name: "empty host", remoteURL: ":owner/repo", wantErr: true}, } From 0486e726c83aee4e76b1e524f6b6cf0fb7165cd9 Mon Sep 17 00:00:00 2001 From: Radin Date: Sun, 15 Nov 2026 13:46:38 +0000 Subject: [PATCH 2/3] Map Upstream's git hosts to upstream.codes in the library Clone URLs on git.coline.app and ssh.git.coline.app now resolve to upstream.codes in factory.NewClient, not only for the current directory's remote in the CLI. WithHostAlias adds such a mapping to any Client, and factory.DefaultHostAliases holds the defaults. The CLI uses the same defaults for its remote and for -R HOST/OWNER/REPO, with a configured ssh_host still taking precedence. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +-- factory/factory.go | 23 +++++++++--- factory/factory_test.go | 44 +++++++++++++++++++++++ forge.go | 18 ++++++++-- forges_test.go | 29 +++++++++++++++ internal/resolve/resolve.go | 16 +++------ internal/resolve/resolve_test.go | 62 ++++++++++++++++++++++++++++++-- 7 files changed, 173 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3ca6479..071c9c4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # forge -Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, Gerrit, Tangled and Radicle (read-only) through a single interface. GitHub Enterprise and other hosts that serve GitHub's REST API, such as [Upstream](https://upstream.codes), work through the GitHub backend. +Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, Gerrit, Tangled and Radicle (read-only) through a single interface. GitHub Enterprise and other hosts that serve GitHub's REST API work through the GitHub backend. ## CLI @@ -189,7 +189,7 @@ case errors.Is(err, forges.ErrServerError): // 5xx } ``` -The `factory` package imports every provider and returns a `Client` that already knows github.com, gitlab.com, codeberg.org, bitbucket.org, tangled.org and upstream.codes. It detects and registers any other domain over https the first time a repository URL on it is used, which sends requests to that domain: +The `factory` package imports every provider and returns a `Client` that already knows github.com, gitlab.com, codeberg.org, bitbucket.org, tangled.org and upstream.codes, and sends repository URLs on Upstream's git hosts, `git.coline.app` and `ssh.git.coline.app`, to upstream.codes. `forges.WithHostAlias` adds such a mapping for another forge. It detects and registers any other domain over https the first time a repository URL on it is used, which sends requests to that domain: ```go import "github.com/git-pkgs/forge/factory" diff --git a/factory/factory.go b/factory/factory.go index e8d206f..f578054 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -43,12 +43,25 @@ func DefaultForges(token func(domain string) string, hc *http.Client) map[string } } -// NewClient returns a Client with the default forges registered and detection -// enabled for every other domain. Options apply first, so WithToken and -// WithHTTPClient reach the default forges, and a domain registered with -// WithForge keeps that registration. +// DefaultHostAliases returns the git hosts of well-known forges that serve git +// from a different host than their API, mapped to the API's domain. +func DefaultHostAliases() map[string]string { + return map[string]string{ + "git.coline.app": "upstream.codes", + "ssh.git.coline.app": "upstream.codes", + } +} + +// NewClient returns a Client with the default forges and host aliases +// registered and detection enabled for every other domain. Options apply +// first, so WithToken and WithHTTPClient reach the default forges, and a +// domain registered with WithForge or WithHostAlias keeps that registration. func NewClient(opts ...forges.Option) *forges.Client { - c := forges.NewClient(append([]forges.Option{forges.WithDetection(Builders())}, opts...)...) + base := []forges.Option{forges.WithDetection(Builders())} + for host, domain := range DefaultHostAliases() { + base = append(base, forges.WithHostAlias(host, domain)) + } + c := forges.NewClient(append(base, opts...)...) tokens := c.Tokens() defaults := DefaultForges(func(domain string) string { return tokens[domain] }, c.HTTPClient()) for domain, f := range defaults { diff --git a/factory/factory_test.go b/factory/factory_test.go index 3eaac7b..a7c37af 100644 --- a/factory/factory_test.go +++ b/factory/factory_test.go @@ -94,6 +94,50 @@ func TestNewClientRegistersDefaultForges(t *testing.T) { } } +func TestNewClientResolvesUpstreamGitHosts(t *testing.T) { + rt := &recordingTransport{} + c := NewClient(forges.WithHTTPClient(&http.Client{Transport: rt})) + want, err := c.ForgeFor("upstream.codes") + if err != nil { + t.Fatalf("ForgeFor: %v", err) + } + + for _, repoURL := range []string{ + "https://git.coline.app/owner/project.git", + "git@ssh.git.coline.app:owner/project.git", + } { + f, owner, repo, err := c.ForgeForURL(context.Background(), repoURL) + if err != nil { + t.Errorf("ForgeForURL(%q): %v", repoURL, err) + continue + } + if f != want { + t.Errorf("ForgeForURL(%q) returned %T, want the upstream.codes forge", repoURL, f) + } + if owner != "owner" || repo != "project" { + t.Errorf("ForgeForURL(%q) owner/repo = %q/%q", repoURL, owner, repo) + } + } + if len(rt.auth) != 0 { + t.Errorf("requests sent to %v, want no detection probe", rt.auth) + } +} + +func TestNewClientKeepsRegisteredHostAlias(t *testing.T) { + c := NewClient(forges.WithHostAlias("git.coline.app", "github.com")) + want, err := c.ForgeFor("github.com") + if err != nil { + t.Fatalf("ForgeFor: %v", err) + } + f, _, _, err := c.ForgeForURL(context.Background(), "https://git.coline.app/owner/project") + if err != nil { + t.Fatalf("ForgeForURL: %v", err) + } + if f != want { + t.Errorf("ForgeForURL returned %T, want the github.com forge", f) + } +} + func TestNewClientAppliesOptionsToDefaultForges(t *testing.T) { rt := &recordingTransport{} c := NewClient( diff --git a/forge.go b/forge.go index 32127ef..cea4e71 100644 --- a/forge.go +++ b/forge.go @@ -152,6 +152,7 @@ type Client struct { tokens map[string]string httpClient *http.Client detection *ForgeBuilders + aliases map[string]string } // Option configures a Client. @@ -180,6 +181,15 @@ func WithForge(domain string, f Forge) Option { } } +// WithHostAlias makes repository URLs on host use the forge for domain, for +// forges that serve git from a different host than their API. Hosts are +// matched case-insensitively. +func WithHostAlias(host, domain string) Option { + return func(c *Client) { + c.aliases[strings.ToLower(host)] = strings.ToLower(domain) + } +} + // WithDetection makes the Client detect and register domains it hasn't seen, // using the given builders, the first time a repository URL on that domain is // used. Detection always uses https, whatever the scheme of the URL, so a @@ -195,8 +205,9 @@ func WithDetection(builders ForgeBuilders) Option { // NewClient creates a Client and applies the given options. func NewClient(opts ...Option) *Client { c := &Client{ - forges: make(map[string]Forge), - tokens: make(map[string]string), + forges: make(map[string]Forge), + tokens: make(map[string]string), + aliases: make(map[string]string), } for _, opt := range opts { opt(c) @@ -332,6 +343,9 @@ func (c *Client) forgeOrDetect(ctx context.Context, domain string) (Forge, error if _, host, err := normalizeBaseURL(domain); err == nil { domain = host } + if alias, ok := c.aliases[domain]; ok { + domain = alias + } } f, err := c.forgeFor(domain) if err == nil || c.detection == nil || strings.Contains(domain, "://") { diff --git a/forges_test.go b/forges_test.go index 898fa78..46b1533 100644 --- a/forges_test.go +++ b/forges_test.go @@ -896,6 +896,35 @@ func TestClientMatchesDomainsCaseInsensitively(t *testing.T) { } } +func TestClientFollowsHostAlias(t *testing.T) { + mock := &mockForge{repoService: &mockRepoService{repos: []Repository{{FullName: "org/repo"}}}} + rt := &detectionTransport{} + c := NewClient( + WithHTTPClient(&http.Client{Transport: rt}), + WithForge("forge.example", mock), + WithHostAlias("Git.Forge.Example", "forge.example"), + WithDetection(ForgeBuilders{Gitea: func(string, string, *http.Client) Forge { return &mockForge{} }}), + ) + + for _, repoURL := range []string{ + "https://git.forge.example/org/repo.git", + "git@GIT.forge.example:org/repo.git", + } { + f, owner, repo, err := c.ForgeForURL(context.Background(), repoURL) + if err != nil || f != Forge(mock) { + t.Errorf("ForgeForURL(%q) = %T, %v, want the aliased forge", repoURL, f, err) + } + assertEqual(t, "owner", "org", owner) + assertEqual(t, "repo", "repo", repo) + } + if repos, err := c.ListRepositories(context.Background(), "git.forge.example", "org", ListRepoOpts{}); err != nil || len(repos) != 1 { + t.Errorf("ListRepositories = %+v, %v", repos, err) + } + if len(rt.urls) != 0 { + t.Errorf("probed %v, want no requests", rt.urls) + } +} + func TestListRepositoriesTrailingSlashKeepsRegistration(t *testing.T) { mock := &mockForge{repoService: &mockRepoService{repos: []Repository{{FullName: "org/repo"}}}} rt := &detectionTransport{} diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index c24593f..492e25d 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -137,7 +137,7 @@ func repoFromFlag(flagRepo, flagForgeType string) (forges.Forge, string, string, } case 3: // host/owner/repo - domain, owner, repo = parts[0], parts[1], parts[2] + domain, owner, repo = mapSSHHost(parts[0]), parts[1], parts[2] default: return nil, "", "", "", fmt.Errorf("invalid repo format %q, expected OWNER/REPO or HOST/OWNER/REPO", flagRepo) } @@ -322,7 +322,8 @@ func resolveRemote() (domain, owner, repo string, err error) { // hostname when the config declares them as different. Self-hosted GitLab // can serve ssh on ssh.gitlab.test while the API lives at gitlab.test; // without this mapping the parsed remote domain would point at the wrong host. -// Returns the input unchanged when no mapping is configured. +// A configured ssh_host takes precedence over factory.DefaultHostAliases. +// Returns the input unchanged when neither maps it. func mapSSHHost(domain string) string { cfg, err := config.Load() if err == nil { @@ -330,21 +331,12 @@ func mapSSHHost(domain string) string { return api } } - if api, ok := knownGitHosts[domain]; ok { + if api, ok := factory.DefaultHostAliases()[strings.ToLower(domain)]; ok { return api } return domain } -// knownGitHosts maps the git hosts of hosted forges whose clone URLs live on -// a different host from their API. A config ssh_host takes precedence. -var knownGitHosts = map[string]string{ - // Upstream clones over https://git.coline.app and git@ssh.git.coline.app, - // while its API and web pages are on upstream.codes. - "git.coline.app": "upstream.codes", - "ssh.git.coline.app": "upstream.codes", -} - func gitRemoteURL(name string) (string, error) { out, err := exec.Command("git", "remote", "get-url", name).Output() if err != nil { diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index a7be817..a630455 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -56,6 +56,10 @@ func TestMapSSHHost(t *testing.T) { _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[gitlab.test] type = gitlab ssh_host = ssh.gitlab.test + +[mirror.example] +type = github +ssh_host = git.coline.app `), 0600) tests := []struct { @@ -67,9 +71,11 @@ ssh_host = ssh.gitlab.test // no mapping: pass through unchanged {"github.com", "github.com"}, {"gitlab.test", "gitlab.test"}, - // built-in git hosts of a hosted forge - {"git.coline.app", "upstream.codes"}, + // a configured ssh_host takes precedence over a default host alias + {"git.coline.app", "mirror.example"}, + // default host aliases from the factory package {"ssh.git.coline.app", "upstream.codes"}, + {"SSH.Git.Coline.App", "upstream.codes"}, } for _, tt := range tests { @@ -91,6 +97,58 @@ func TestMapSSHHostNoConfig(t *testing.T) { if got != "ssh.gitlab.test" { t.Errorf("with no config, expected passthrough, got %q", got) } + // Default host aliases still apply. + if got := mapSSHHost("git.coline.app"); got != "upstream.codes" { + t.Errorf("mapSSHHost(%q) with no config = %q, want %q", "git.coline.app", got, "upstream.codes") + } +} + +func TestRepoFromUpstreamGitHost(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + config.ResetCache() + defer config.ResetCache() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + assertUpstream := func(t *testing.T, f forges.Forge, owner, repo, domain string) { + t.Helper() + if domain != "upstream.codes" || owner != "owner" || repo != "project" { + t.Errorf("got %q/%q on %q, want owner/project on upstream.codes", owner, repo, domain) + } + provider, ok := f.(forges.APIBaseURLProvider) + if !ok { + t.Fatalf("forge %T does not expose its API URL", f) + } + if got := provider.APIBaseURL(); got != "https://upstream.codes/api/v3" { + t.Errorf("API URL = %q, want %q", got, "https://upstream.codes/api/v3") + } + } + + for _, remote := range []string{ + "https://git.coline.app/owner/project.git", + "git@ssh.git.coline.app:owner/project.git", + } { + t.Run(remote, func(t *testing.T) { + t.Chdir(t.TempDir()) + mustGit(t, "init", "-q") + mustGit(t, "remote", "add", "origin", remote) + + f, owner, repo, domain, err := Repo("", "") + if err != nil { + t.Fatalf("Repo: %v", err) + } + assertUpstream(t, f, owner, repo, domain) + }) + } + + t.Run("-R git.coline.app/owner/project", func(t *testing.T) { + f, owner, repo, domain, err := Repo("git.coline.app/owner/project", "") + if err != nil { + t.Fatalf("Repo: %v", err) + } + assertUpstream(t, f, owner, repo, domain) + }) } func clearTokenEnv(t *testing.T) { From 2ee8b954f8bf3d2c73fe719cf521daaec84961b1 Mon Sep 17 00:00:00 2001 From: Radin Date: Sun, 15 Nov 2026 13:56:02 +0000 Subject: [PATCH 3/3] Let a forge registered for an aliased host win over the alias A Client given WithForge("git.coline.app", f) sent that host's URLs to upstream.codes, because factory.NewClient's default alias was applied before the registry was checked. The alias now applies only when no forge is registered for the host itself. Co-Authored-By: Claude Opus 5.5 --- factory/factory_test.go | 12 ++++++++++++ forge.go | 9 ++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/factory/factory_test.go b/factory/factory_test.go index a7c37af..69e359c 100644 --- a/factory/factory_test.go +++ b/factory/factory_test.go @@ -138,6 +138,18 @@ func TestNewClientKeepsRegisteredHostAlias(t *testing.T) { } } +func TestNewClientKeepsForgeRegisteredForAliasHost(t *testing.T) { + want := &stubForge{} + c := NewClient(forges.WithForge("git.coline.app", want)) + f, _, _, err := c.ForgeForURL(context.Background(), "https://git.coline.app/owner/project") + if err != nil { + t.Fatalf("ForgeForURL: %v", err) + } + if f != forges.Forge(want) { + t.Errorf("ForgeForURL returned %T, want the forge registered for git.coline.app", f) + } +} + func TestNewClientAppliesOptionsToDefaultForges(t *testing.T) { rt := &recordingTransport{} c := NewClient( diff --git a/forge.go b/forge.go index cea4e71..ad65c1a 100644 --- a/forge.go +++ b/forge.go @@ -182,8 +182,9 @@ func WithForge(domain string, f Forge) Option { } // WithHostAlias makes repository URLs on host use the forge for domain, for -// forges that serve git from a different host than their API. Hosts are -// matched case-insensitively. +// forges that serve git from a different host than their API. A Forge +// registered for host itself takes precedence. Hosts are matched +// case-insensitively. func WithHostAlias(host, domain string) Option { return func(c *Client) { c.aliases[strings.ToLower(host)] = strings.ToLower(domain) @@ -344,7 +345,9 @@ func (c *Client) forgeOrDetect(ctx context.Context, domain string) (Forge, error domain = host } if alias, ok := c.aliases[domain]; ok { - domain = alias + if _, err := c.forgeFor(domain); err != nil { + domain = alias + } } } f, err := c.forgeFor(domain)