From 115fccbc947e650e349d928404ba6f129e262293 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Mon, 6 Jul 2026 15:24:43 +0200 Subject: [PATCH 1/2] git-remote-entire: actionable hint when the cluster host is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git clone entire://gh/entire.io/cli` parses `gh` into the host slot, so the helper previously failed at cluster discovery with an opaque `https://gh/.well-known/...` error. The empty-host form (`entire:///gh/...`) hit only a bare "missing host" message. Detect both shapes in the URL-validation switch: when the host is empty or is a known forge id (`gitremote.IsSupportedForge`), print a message that names the problem and points at the interactive mirror picker, e.g. entire repo clone /gh/entire.io/cli This reuses the existing actionable-error idiom (`fatalMessage`) and keeps the helper a dumb transport — no TTY/stdin/stdout involvement, and safe in CI (stderr line + exit 128, never a hang). The picker lives in `entire repo clone`, the right layer, because it clones a fully-qualified URL so later fetches don't re-prompt. Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: acbb7200e503 --- cmd/git-remote-entire/main.go | 39 +++++++++++++++++- cmd/git-remote-entire/main_test.go | 63 ++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/cmd/git-remote-entire/main.go b/cmd/git-remote-entire/main.go index 778c858557..60f33a9221 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,38 @@ 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 intended repo shorthand is +// recoverable — the host slot, or the path's leading segment, is a known forge +// id — it points at the interactive picker (`entire repo clone `), +// which resolves the mirror and clones a fully-qualified URL. Anything else +// (bare entire://, a non-forge leading segment) falls back to the plain +// missing-host error. Kept pure so it's unit-testable. +func missingClusterHostMessage(parsedURL *url.URL, rawURL string) string { + // Forge id in the host slot: the shorthand is /. + if gitremote.IsSupportedForge(parsedURL.Host) { + return clusterHostHint(parsedURL.Host, "/"+parsedURL.Host+parsedURL.Path) + } + // Empty host with a forge-led path: the path is already the shorthand. + if forge, _, ok := strings.Cut(strings.TrimPrefix(parsedURL.Path, "/"), "/"); ok && gitremote.IsSupportedForge(forge) { + return clusterHostHint(forge, parsedURL.Path) + } + return fmt.Sprintf("fatal: missing host in URL %q\n", rawURL) +} + +// clusterHostHint is the actionable message pointing a host-less entire:// URL +// at the `entire repo clone` picker, which resolves the mirror for the given +// forge shorthand. +func clusterHostHint(forge, shorthand string) string { + 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", + forge, forge, shorthand) +} + // 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..57c93fcb39 100644 --- a/cmd/git-remote-entire/main_test.go +++ b/cmd/git-remote-entire/main_test.go @@ -173,6 +173,69 @@ 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"}, + }, + } + 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/"} From 6050a60b39bbc81a0234460181dbd379da6d6894 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Tue, 7 Jul 2026 18:10:39 +0200 Subject: [PATCH 2/2] git-remote-entire: only suggest clone for a complete forge/owner/repo ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review (Cursor Bugbot + Copilot) flagged that missingClusterHostMessage unconditionally pointed at `entire repo clone` whenever a forge id sat in the host slot, even without owner/repo. So `entire://gh` suggested `entire repo clone /gh` and `entire://gh/owner` suggested `/gh/owner` — refs that parseMirrorCloneRef (^/?gh//$) rejects immediately. The empty-host branch already fell back correctly, so the two equivalent mistakes were handled inconsistently. Reconstruct the intended shorthand once (forge id in host slot vs. empty host) and only emit the hint when it is a complete forge/owner/repo triple; anything shorter or longer falls back to the plain missing-host error. Collapsing the two branches also lets clusterHostHint be inlined. Add regression cases for entire://gh, entire://gh/owner, entire:///gh/owner, and a too-many-segments path. Co-Authored-By: Claude Opus 4.8 Entire-Checkpoint: 01KWYNH3035PPHFTMD9J6GWEYW --- cmd/git-remote-entire/main.go | 41 +++++++++++++++--------------- cmd/git-remote-entire/main_test.go | 29 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/cmd/git-remote-entire/main.go b/cmd/git-remote-entire/main.go index 60f33a9221..a025ff740c 100644 --- a/cmd/git-remote-entire/main.go +++ b/cmd/git-remote-entire/main.go @@ -216,33 +216,32 @@ func fatalMessage(err error, parsedURL *url.URL) string { // 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 intended repo shorthand is -// recoverable — the host slot, or the path's leading segment, is a known forge -// id — it points at the interactive picker (`entire repo clone `), -// which resolves the mirror and clones a fully-qualified URL. Anything else -// (bare entire://, a non-forge leading segment) falls back to the plain -// missing-host error. Kept pure so it's unit-testable. +// (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 { - // Forge id in the host slot: the shorthand is /. - if gitremote.IsSupportedForge(parsedURL.Host) { - return clusterHostHint(parsedURL.Host, "/"+parsedURL.Host+parsedURL.Path) + // 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 } - // Empty host with a forge-led path: the path is already the shorthand. - if forge, _, ok := strings.Cut(strings.TrimPrefix(parsedURL.Path, "/"), "/"); ok && gitremote.IsSupportedForge(forge) { - return clusterHostHint(forge, parsedURL.Path) + // 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: missing host in URL %q\n", rawURL) -} - -// clusterHostHint is the actionable message pointing a host-less entire:// URL -// at the `entire repo clone` picker, which resolves the mirror for the given -// forge shorthand. -func clusterHostHint(forge, shorthand string) string { 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", - forge, forge, shorthand) + "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. diff --git a/cmd/git-remote-entire/main_test.go b/cmd/git-remote-entire/main_test.go index 57c93fcb39..1e2f61ca09 100644 --- a/cmd/git-remote-entire/main_test.go +++ b/cmd/git-remote-entire/main_test.go @@ -213,6 +213,35 @@ func TestMissingClusterHostMessage(t *testing.T) { 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) {