diff --git a/cmd/git-remote-entire/main.go b/cmd/git-remote-entire/main.go index 778c858557..a025ff740c 100644 --- a/cmd/git-remote-entire/main.go +++ b/cmd/git-remote-entire/main.go @@ -34,6 +34,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/gitremote" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/entireio/cli/internal/entireclient/clusterdiscovery" "github.com/entireio/cli/internal/entireclient/httpclient" @@ -89,8 +90,10 @@ func run(args []string) int { case parsedURL.Scheme != "entire": fmt.Fprintf(os.Stderr, "fatal: unsupported URL scheme %q (expected 'entire')\n", parsedURL.Scheme) return 128 - case parsedURL.Host == "": - fmt.Fprintf(os.Stderr, "fatal: missing host in URL %q\n", rawURL) + case parsedURL.Host == "" || gitremote.IsSupportedForge(parsedURL.Host): + // Cluster host absent (empty, or a forge id in its slot); + // missingClusterHostMessage renders the actionable hint. + fmt.Fprint(os.Stderr, missingClusterHostMessage(parsedURL, rawURL)) return 128 } @@ -210,6 +213,37 @@ func fatalMessage(err error, parsedURL *url.URL) string { return fmt.Sprintf("fatal: %v\n", err) } +// missingClusterHostMessage renders the stderr "fatal: …" line for an entire:// +// URL that omits its cluster host. Two shapes reach here: a forge id typed +// where the host belongs (entire://gh/owner/repo, Host="gh") and an empty host +// (entire:///gh/owner/repo, Host=""). When the reconstructed shorthand is a +// complete forge/owner/repo triple that `entire repo clone` can resolve, it +// points at the interactive picker; a partial path (entire://gh, +// entire://gh/owner) or a non-forge segment falls back to the plain +// missing-host error rather than suggesting a clone command that would reject +// the ref. Kept pure so it's unit-testable. +func missingClusterHostMessage(parsedURL *url.URL, rawURL string) string { + // Reconstruct the forge/owner/repo shorthand the user likely intended: a + // forge id in the host slot sits in front of the path; an empty host + // already has it there. + shorthand := strings.TrimPrefix(parsedURL.Path, "/") + if parsedURL.Host != "" { + shorthand = parsedURL.Host + "/" + shorthand + } + // Only point at `entire repo clone` for a complete forge/owner/repo triple + // (the shape parseMirrorCloneRef accepts); anything shorter would relocate + // the failure into a clone command that rejects the ref. + seg := strings.Split(strings.Trim(shorthand, "/"), "/") + if len(seg) != 3 || seg[0] == "" || seg[1] == "" || seg[2] == "" || !gitremote.IsSupportedForge(seg[0]) { + return fmt.Sprintf("fatal: missing host in URL %q\n", rawURL) + } + return fmt.Sprintf( + "fatal: entire:// URL is missing its cluster host (%q is a forge id, not a host).\n"+ + "The full form is entire:///%s//.\n"+ + "To pick a mirror interactively, run:\n\n entire repo clone /%s\n", + seg[0], seg[0], strings.Join(seg, "/")) +} + // loadedVersion populates the build info and returns the resolved version. func loadedVersion() string { versioninfo.Load() diff --git a/cmd/git-remote-entire/main_test.go b/cmd/git-remote-entire/main_test.go index ac89ddc07e..1e2f61ca09 100644 --- a/cmd/git-remote-entire/main_test.go +++ b/cmd/git-remote-entire/main_test.go @@ -173,6 +173,98 @@ func TestFatalMessage(t *testing.T) { } } +func TestMissingClusterHostMessage(t *testing.T) { + t.Parallel() + tests := []struct { + name string + rawURL string + contains []string + notContains []string + }{ + { + // The motivating case: forge id typed where the cluster host belongs. + name: "forge id in host slot points at repo clone", + rawURL: "entire://gh/entire.io/cli", + contains: []string{"missing its cluster host", `"gh" is a forge id`, "entire repo clone /gh/entire.io/cli"}, + }, + { + // Empty host but the path already reads as a forge shorthand. + name: "empty host with forge path points at repo clone", + rawURL: "entire:///gh/entire.io/cli", + contains: []string{"missing its cluster host", "entire repo clone /gh/entire.io/cli"}, + }, + { + // Empty host, leading segment is not a known forge → generic error. + name: "empty host with non-forge path falls back", + rawURL: "entire:///not-a-forge/owner/repo", + contains: []string{`fatal: missing host in URL "entire:///not-a-forge/owner/repo"`}, + notContains: []string{"entire repo clone"}, + }, + { + name: "bare scheme falls back", + rawURL: "entire://", + contains: []string{`fatal: missing host in URL "entire://"`}, + notContains: []string{"entire repo clone"}, + }, + { + // Not enough path to form owner/repo → not worth pointing at clone. + name: "empty host single-segment path falls back", + rawURL: "entire:///gh", + contains: []string{`fatal: missing host in URL "entire:///gh"`}, + notContains: []string{"entire repo clone"}, + }, + { + // Forge in host slot but no owner/repo — the shorthand `/gh` would be + // rejected by `entire repo clone`, so fall back rather than suggest it. + name: "forge in host slot without path falls back", + rawURL: "entire://gh", + contains: []string{`fatal: missing host in URL "entire://gh"`}, + notContains: []string{"entire repo clone"}, + }, + { + // Forge in host slot with owner but no repo — incomplete triple. + name: "forge in host slot with owner only falls back", + rawURL: "entire://gh/owner", + contains: []string{`fatal: missing host in URL "entire://gh/owner"`}, + notContains: []string{"entire repo clone"}, + }, + { + // Empty host, forge + owner but no repo — incomplete triple. + name: "empty host forge and owner only falls back", + rawURL: "entire:///gh/owner", + contains: []string{`fatal: missing host in URL "entire:///gh/owner"`}, + notContains: []string{"entire repo clone"}, + }, + { + // Too many segments — not the gh// shape either. + name: "forge in host slot with extra path segment falls back", + rawURL: "entire://gh/owner/repo/extra", + contains: []string{`fatal: missing host in URL "entire://gh/owner/repo/extra"`}, + notContains: []string{"entire repo clone"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + parsed, err := url.Parse(tc.rawURL) + if err != nil { + t.Fatalf("parse %q: %v", tc.rawURL, err) + } + got := missingClusterHostMessage(parsed, tc.rawURL) + for _, sub := range tc.contains { + if !strings.Contains(got, sub) { + t.Errorf("missingClusterHostMessage(%q) = %q, missing %q", tc.rawURL, got, sub) + } + } + for _, sub := range tc.notContains { + if strings.Contains(got, sub) { + t.Errorf("missingClusterHostMessage(%q) = %q, should not contain %q", tc.rawURL, got, sub) + } + } + }) + } +} + func TestCoreTrusted(t *testing.T) { t.Parallel() trusted := []string{"https://core.us.entire.io", "https://core.eu.entire.io/"}