diff --git a/CLAUDE.md b/CLAUDE.md index ff99bc07a2..176d73d392 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,29 @@ Top-level lifecycle and standalone commands: `enable`, `disable`, `status`, `login`, `logout`, `clean`, `version`, `dispatch`, `activity`, `help`, `configure`, `agent-help`, `api`. +`status` also reports whether the current clone points at an Entire mirror. This +section renders only once Entire is set up in the repo (after `entire enable`); +a not-set-up repo shows just the `○ not set up` funnel and no mirror line, and +`status --json` returns its `not set up` form without a `mirror` field. The +"is it a mirror, and which cluster" half is read offline from the clone's git +remote (an `entire://` URL); the mirror's live state (processing / ready / +failed / suspended) is a best-effort, time-bounded control-plane lookup done +only when a mirror remote is present *and* the caller is logged in — logged out, +the state shows `unknown` with an `entire login` hint, and an ordinary +(non-mirror) clone triggers no network at all. When the clone pulls directly +from a mirrorable forge remote instead of a mirror, the human output prints a +hint pointing at `entire repo mirror use` — which repoints the clone at an +existing mirror, or tells the user to `mirror create` when there is none. The +forge host named in the hint is read from the remote URL (so it is the real +provider, e.g. `github.com`, not a hardcoded string), and the trigger gates on +`gitremote.IsSupportedForge`, so it widens automatically if more forges become +mirrorable (only GitHub today). The hint deliberately describes only the local +fact (this clone isn't using a mirror); it does not claim the repo has no +mirror, because that is a server-side fact `status` does not look up. Surfaced in the human output (short +and `--detailed`) and as the `mirror` object in `status --json` (`null` when the +clone isn't pointed at a mirror — the hint is human-output only). See +`status_mirror.go`. + `api` is an authenticated passthrough to Entire's HTTP APIs (gh-style): it attaches the right bearer and dials the right host so callers don't plumb auth themselves. `--to core` (default) hits the control plane; `--to cell` hits an diff --git a/cmd/entire/cli/gitremote/gitremote.go b/cmd/entire/cli/gitremote/gitremote.go index c731786926..420addfcc9 100644 --- a/cmd/entire/cli/gitremote/gitremote.go +++ b/cmd/entire/cli/gitremote/gitremote.go @@ -94,6 +94,27 @@ func GetRemoteURL(ctx context.Context, remoteName string) (string, error) { return GetRemoteURLInDir(ctx, "", remoteName) } +// ListRemotesInDir returns the names of every git remote configured in dir +// (dir "" means the current working directory). An empty slice, not an error, +// is returned when the repo has no remotes. +func ListRemotesInDir(ctx context.Context, dir string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "remote") + if dir != "" { + cmd.Dir = dir + } + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("list git remotes: %w", err) + } + var names []string + for line := range strings.SplitSeq(strings.TrimSpace(string(output)), "\n") { + if name := strings.TrimSpace(line); name != "" { + names = append(names, name) + } + } + return names, nil +} + // GetRemoteURLInDir returns the URL configured for the named git remote in dir. func GetRemoteURLInDir(ctx context.Context, dir, remoteName string) (string, error) { cmd := exec.CommandContext(ctx, "git", "remote", "get-url", remoteName) diff --git a/cmd/entire/cli/gitremote/gitremote_test.go b/cmd/entire/cli/gitremote/gitremote_test.go index 5d804f530d..af34eef30e 100644 --- a/cmd/entire/cli/gitremote/gitremote_test.go +++ b/cmd/entire/cli/gitremote/gitremote_test.go @@ -258,3 +258,33 @@ func TestResolveRemoteRepo_MissingRemote(t *testing.T) { _, _, _, err := ResolveRemoteRepo(context.Background(), "origin") assert.Error(t, err) } + +func TestListRemotesInDir(t *testing.T) { + t.Parallel() + + t.Run("returns configured remotes", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + testutil.InitRepo(t, dir) + for name, url := range map[string]string{ + "origin": "git@github.com:octocat/hello-world.git", + "upstream": "https://github.com/octocat/hello-world", + } { + cmd := exec.CommandContext(t.Context(), "git", "remote", "add", name, url) + cmd.Dir = dir + require.NoError(t, cmd.Run()) + } + got, err := ListRemotesInDir(t.Context(), dir) + require.NoError(t, err) + require.ElementsMatch(t, []string{"origin", "upstream"}, got) + }) + + t.Run("empty for a repo with no remotes", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + testutil.InitRepo(t, dir) + got, err := ListRemotesInDir(t.Context(), dir) + require.NoError(t, err) + require.Empty(t, got) + }) +} diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index 17e3a030ae..ff74bb60f7 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -56,7 +56,8 @@ func runStatus(ctx context.Context, w io.Writer, detailed, jsonOutput bool) erro } // Check if we're in a git repository - if _, repoErr := paths.WorktreeRoot(ctx); repoErr != nil { + repoRoot, repoErr := paths.WorktreeRoot(ctx) + if repoErr != nil { fmt.Fprintln(w, "✕ not a git repository") return nil //nolint:nilerr // Not being in a git repo is a valid status, not an error } @@ -91,7 +92,7 @@ func runStatus(ctx context.Context, w io.Writer, detailed, jsonOutput bool) erro sty := newStatusStyles(w) if detailed { - return runStatusDetailed(ctx, w, sty, settingsPath, localSettingsPath, projectExists, localExists) + return runStatusDetailed(ctx, w, sty, repoRoot, settingsPath, localSettingsPath, projectExists, localExists) } // Short output: just show the effective/merged state @@ -104,6 +105,7 @@ func runStatus(ctx context.Context, w io.Writer, detailed, jsonOutput bool) erro if s.Enabled { writeActiveSessions(ctx, w, sty) } + writeMirrorStatus(ctx, w, repoRoot, sty) writeAgentHelpHint(w, sty) return nil @@ -124,7 +126,7 @@ func writeAgentHelpHint(w io.Writer, sty statusStyles) { } // runStatusDetailed shows the effective status plus detailed status for each settings file. -func runStatusDetailed(ctx context.Context, w io.Writer, sty statusStyles, settingsPath, localSettingsPath string, projectExists, localExists bool) error { +func runStatusDetailed(ctx context.Context, w io.Writer, sty statusStyles, repoRoot, settingsPath, localSettingsPath string, projectExists, localExists bool) error { // First show the effective/merged status effectiveSettings, err := LoadEntireSettings(ctx) if err != nil { @@ -154,6 +156,7 @@ func runStatusDetailed(ctx context.Context, w io.Writer, sty statusStyles, setti if effectiveSettings.Enabled { writeActiveSessions(ctx, w, sty) } + writeMirrorStatus(ctx, w, repoRoot, sty) writeAgentHelpHint(w, sty) return nil @@ -600,7 +603,10 @@ type statusJSON struct { // HooksOutdated lists agents whose installed hook config is out of date and // should be refreshed with `entire enable --force`. HooksOutdated []string `json:"hooks_outdated,omitempty"` - Error string `json:"error,omitempty"` + // Mirror describes the Entire mirror this clone's git remote points at, or is + // omitted when the clone targets the forge directly. + Mirror *mirrorJSON `json:"mirror,omitempty"` + Error string `json:"error,omitempty"` } type sessionBriefJSON struct { @@ -614,7 +620,8 @@ func runStatusJSON(ctx context.Context, w io.Writer) error { return json.NewEncoder(w).Encode(v) } - if _, err := paths.WorktreeRoot(ctx); err != nil { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { return writeJSON(statusJSON{Error: "not a git repository"}) } @@ -650,6 +657,7 @@ func runStatusJSON(ctx context.Context, w io.Writer) error { Agents: []string{}, ActiveSessions: []sessionBriefJSON{}, AgentHelp: agentHelpCommand, + Mirror: mirrorStatusJSON(ctx, repoRoot), } if s.Enabled { diff --git a/cmd/entire/cli/status_mirror.go b/cmd/entire/cli/status_mirror.go new file mode 100644 index 0000000000..cbaaf1098e --- /dev/null +++ b/cmd/entire/cli/status_mirror.go @@ -0,0 +1,260 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "sort" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/gitremote" + "github.com/entireio/cli/internal/coreapi" +) + +// mirrorStatusUnknown is the status shown when the mirror's live state can't be +// read — either the caller isn't logged in, or a best-effort lookup failed. +const mirrorStatusUnknown = "unknown" + +// mirrorStatusTimeout bounds the best-effort control-plane lookup so `entire +// status` can never hang on a slow network. On timeout the status degrades to +// "unknown" like any other lookup failure. +const mirrorStatusTimeout = 4 * time.Second + +// originRemote is git's default remote name, preferred when scanning for the +// mirror remote so the reported remote matches what "use the mirror" points at. +const originRemote = "origin" + +// mirrorClone describes a clone whose git remote points at an Entire mirror, +// detected purely from local git config (no network, no auth). +type mirrorClone struct { + Remote string // git remote name pointing at the mirror + Cluster string // Entire cluster host serving the mirror + Owner string // forge repo owner + Repo string // forge repo name + URL string // the entire:// clone URL as configured +} + +// mirrorJSON is the `mirror` object in `entire status --json`. Omitted entirely +// when the clone does not point at a mirror. +type mirrorJSON struct { + Remote string `json:"remote"` + Cluster string `json:"cluster"` + Owner string `json:"owner"` + Repo string `json:"repo"` + URL string `json:"url"` + Status string `json:"status"` + LoggedIn bool `json:"logged_in"` +} + +// detectMirrorClone inspects the repo's git remotes for an entire:// URL and, if +// found, returns what can be known offline about the mirror this clone points +// at. `origin` is preferred; otherwise remotes are considered in name order so +// the reported remote is stable across runs. Returns nil when no remote points +// at a mirror (the common case for an ordinary clone). +func detectMirrorClone(ctx context.Context, repoRoot string) *mirrorClone { + for _, name := range orderedRemoteNames(ctx, repoRoot) { + rawURL, gerr := gitremote.GetRemoteURLInDir(ctx, repoRoot, name) + if gerr != nil { + continue + } + info, perr := gitremote.ParseURL(rawURL) + if perr != nil || info.Protocol != gitremote.ProtocolEntire { + continue + } + return &mirrorClone{ + Remote: name, + Cluster: info.Host, + Owner: info.Owner, + Repo: info.Repo, + URL: rawURL, + } + } + return nil +} + +// mirrorableForgeHost returns the git host this clone fetches from directly +// (e.g. "github.com") when that host is one Entire can mirror — the case where +// pointing the clone at a mirror is a meaningful next step. ok is false when no +// remote names a mirrorable forge (an already-mirrored entire:// remote, or a +// host mirrors don't support). The host is read straight from the remote URL so +// the hint names the real provider rather than a hardcoded one, and gating on +// gitremote.IsSupportedForge means the trigger widens automatically if more +// forges become mirrorable. This is a purely local check: it cannot tell +// whether a mirror already exists server-side, which is why the hint points at +// `mirror use` (which resolves that, or tells the user to `mirror create` when +// there is none) rather than asserting either way. +func mirrorableForgeHost(ctx context.Context, repoRoot string) (host string, ok bool) { + for _, name := range orderedRemoteNames(ctx, repoRoot) { + rawURL, gerr := gitremote.GetRemoteURLInDir(ctx, repoRoot, name) + if gerr != nil { + continue + } + info, perr := gitremote.ParseURL(rawURL) + if perr != nil { + continue + } + if info.Protocol != gitremote.ProtocolEntire && gitremote.IsSupportedForge(info.Forge) { + return info.Host, true + } + } + return "", false +} + +// orderedRemoteNames lists the repo's git remotes with origin first, then the +// rest by name, so both the mirror scan and the hint report a stable, +// origin-preferring result. Returns nil on any error (treated as "no remotes"). +func orderedRemoteNames(ctx context.Context, repoRoot string) []string { + remotes, err := gitremote.ListRemotesInDir(ctx, repoRoot) + if err != nil || len(remotes) == 0 { + return nil + } + var hasOrigin bool + rest := make([]string, 0, len(remotes)) + for _, name := range remotes { + if name == originRemote { + hasOrigin = true + continue + } + rest = append(rest, name) + } + sort.Strings(rest) + if hasOrigin { + return append([]string{originRemote}, rest...) + } + return rest +} + +// statusLoggedIn reports whether a usable control-plane credential exists, +// without prompting or touching the network: an ENTIRE_TOKEN env token, or an +// active login context. It gates whether `entire status` attempts a live mirror +// lookup at all. +func statusLoggedIn() bool { + if strings.TrimSpace(os.Getenv(auth.EnvTokenVar)) != "" { + return true + } + // An active context in contexts.json is enough to know a login exists. + // auth.Contexts reads only that file — no keychain, no token-manager + // plumbing, no prompt — unlike ResolveControlPlaneTarget, which would build + // a keychain-backed refreshing token source this boolean does not need. + ctxs, current, err := auth.Contexts() + if err != nil || strings.TrimSpace(current) == "" { + return false + } + for _, c := range ctxs { + if c.Name == current && c.CoreURL != "" { + return true + } + } + return false +} + +// resolveMirrorStatus enriches a locally-detected mirror clone with its live +// server-side status. A package var so tests inject deterministic results +// without network or auth. When the caller is logged out it returns +// ("unknown", false) with no network call; when logged in it does a best-effort, +// time-bounded lookup, degrading to ("unknown", true) on any failure. +var resolveMirrorStatus = func(ctx context.Context, m *mirrorClone) (status string, loggedIn bool) { + if !statusLoggedIn() { + return mirrorStatusUnknown, false + } + fctx, cancel := context.WithTimeout(ctx, mirrorStatusTimeout) + defer cancel() + st, err := fetchMirrorStatus(fctx, m.Cluster, m.Owner, m.Repo) + if err != nil { + return mirrorStatusUnknown, true + } + return st, true +} + +// fetchMirrorStatus dials the core fronting clusterHost and reads the mirror's +// clone-lifecycle status (processing / ready / failed / suspended). The cluster +// core is dialed rather than the active context's so the lookup resolves even +// when the mirror lives in a federation other than the active login. Best-effort: +// any failure is the caller's cue to fall back to "unknown". +func fetchMirrorStatus(ctx context.Context, clusterHost, owner, repo string) (string, error) { + c, err := clusterCoreClient(ctx, clusterHost) + if err != nil { + return "", err + } + mirrorID, err := resolveMirrorRef(ctx, c, mirrorCloneURL(clusterHost, owner, repo)) + if err != nil { + return "", err + } + m, err := c.GetMirror(ctx, coreapi.GetMirrorParams{MirrorId: mirrorID}) + if err != nil { + return "", err + } + if st, ok := m.Status.Get(); ok { + return string(st), nil + } + return mirrorStatusUnknown, nil +} + +// formatMirrorStatusLine renders the one-line mirror summary for the human +// status output. +func formatMirrorStatusLine(m *mirrorClone, status string, loggedIn bool, sty statusStyles) string { + var state string + switch { + case status == mirrorStatusUnknown && !loggedIn: + state = "status unknown — run `entire login`" + case status == mirrorStatusUnknown: + state = "status unknown" + default: + state = status + } + return fmt.Sprintf("%s Mirror: %s · %s %s", + sty.render(sty.dim, "⇄"), + sty.render(sty.bold, m.Cluster), + state, + sty.render(sty.dim, fmt.Sprintf("(remote: %s)", m.Remote)), + ) +} + +// formatMirrorHint renders the one-line hint shown when the clone fetches from +// the forge directly. It describes only what is locally true — that this clone +// isn't using a mirror — and points at `mirror use`, which repoints the clone +// at an existing mirror or, when none exists, tells the user to create one. +func formatMirrorHint(host string, sty statusStyles) string { + return fmt.Sprintf("%s This clone pulls directly from %s, not through an Entire mirror. Switch it with:\n %s", + sty.render(sty.dim, "⇄"), + host, + sty.render(sty.bold, "entire repo mirror use"), + ) +} + +// writeMirrorStatus renders the mirror line when this clone points at a mirror, +// or a hint on how to switch to one when it targets a GitHub forge directly. +// Both paths are decided from local git config; the live-status lookup only +// fires for mirror-using repos, and the hint triggers no network at all. +func writeMirrorStatus(ctx context.Context, w io.Writer, repoRoot string, sty statusStyles) { + if m := detectMirrorClone(ctx, repoRoot); m != nil { + status, loggedIn := resolveMirrorStatus(ctx, m) + fmt.Fprintln(w, formatMirrorStatusLine(m, status, loggedIn, sty)) + return + } + if host, ok := mirrorableForgeHost(ctx, repoRoot); ok { + fmt.Fprintln(w, formatMirrorHint(host, sty)) + } +} + +// mirrorStatusJSON builds the `mirror` field for `entire status --json`, or nil +// when this clone does not point at a mirror. +func mirrorStatusJSON(ctx context.Context, repoRoot string) *mirrorJSON { + m := detectMirrorClone(ctx, repoRoot) + if m == nil { + return nil + } + status, loggedIn := resolveMirrorStatus(ctx, m) + return &mirrorJSON{ + Remote: m.Remote, + Cluster: m.Cluster, + Owner: m.Owner, + Repo: m.Repo, + URL: m.URL, + Status: status, + LoggedIn: loggedIn, + } +} diff --git a/cmd/entire/cli/status_mirror_test.go b/cmd/entire/cli/status_mirror_test.go new file mode 100644 index 0000000000..138ff32bf7 --- /dev/null +++ b/cmd/entire/cli/status_mirror_test.go @@ -0,0 +1,264 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +const testMirrorURL = "entire://aws-us-east-2.entire.io/gh/octocat/hello-world" + +func addOriginRemote(t *testing.T, dir, url string) { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", "remote", "add", "origin", url) + cmd.Dir = dir + require.NoError(t, cmd.Run(), "add origin remote") +} + +// initRepoWithRemotes creates a temp git repo with the given remotes configured. +func initRepoWithRemotes(t *testing.T, remotes map[string]string) string { + t.Helper() + dir := t.TempDir() + testutil.InitRepo(t, dir) + for name, url := range remotes { + cmd := exec.CommandContext(t.Context(), "git", "remote", "add", name, url) + cmd.Dir = dir + require.NoError(t, cmd.Run(), "add remote %q", name) + } + return dir +} + +func TestDetectMirrorClone(t *testing.T) { + t.Parallel() + + t.Run("detects an entire:// origin", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, map[string]string{"origin": testMirrorURL}) + m := detectMirrorClone(t.Context(), dir) + require.NotNil(t, m) + require.Equal(t, "origin", m.Remote) + require.Equal(t, "aws-us-east-2.entire.io", m.Cluster) + require.Equal(t, "octocat", m.Owner) + require.Equal(t, "hello-world", m.Repo) + }) + + t.Run("nil for a forge origin", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, map[string]string{"origin": "git@github.com:octocat/hello-world.git"}) + require.Nil(t, detectMirrorClone(t.Context(), dir)) + }) + + t.Run("nil when there are no remotes", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, nil) + require.Nil(t, detectMirrorClone(t.Context(), dir)) + }) + + t.Run("detects an entire:// side remote when origin is a forge", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, map[string]string{ + "origin": "git@github.com:octocat/hello-world.git", + "entire": testMirrorURL, + }) + m := detectMirrorClone(t.Context(), dir) + require.NotNil(t, m) + require.Equal(t, "entire", m.Remote) + }) + + t.Run("prefers origin when it too is a mirror", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, map[string]string{ + "origin": testMirrorURL, + "entire": "entire://aws-eu-central-1.entire.io/gh/octocat/hello-world", + }) + m := detectMirrorClone(t.Context(), dir) + require.NotNil(t, m) + require.Equal(t, "origin", m.Remote, "origin wins the tie") + require.Equal(t, "aws-us-east-2.entire.io", m.Cluster) + }) +} + +func TestFormatMirrorStatusLine(t *testing.T) { + t.Parallel() + sty := newStatusStyles(&bytes.Buffer{}) // color disabled → plain text + m := &mirrorClone{Remote: "origin", Cluster: "aws-us-east-2.entire.io", Owner: "octocat", Repo: "hello-world", URL: testMirrorURL} + + t.Run("ready status", func(t *testing.T) { + t.Parallel() + out := formatMirrorStatusLine(m, "ready", true, sty) + require.Contains(t, out, "Mirror") + require.Contains(t, out, "aws-us-east-2.entire.io") + require.Contains(t, out, "ready") + require.Contains(t, out, "origin") + }) + + t.Run("unknown when logged out points at login", func(t *testing.T) { + t.Parallel() + out := formatMirrorStatusLine(m, mirrorStatusUnknown, false, sty) + require.Contains(t, out, "unknown") + require.Contains(t, out, "entire login") + }) + + t.Run("unknown when logged in gives no login hint", func(t *testing.T) { + t.Parallel() + out := formatMirrorStatusLine(m, mirrorStatusUnknown, true, sty) + require.Contains(t, out, "unknown") + require.NotContains(t, out, "entire login") + }) +} + +func TestStatusLoggedIn(t *testing.T) { + // Not parallel: mutates process env. + t.Run("env token counts as logged in", func(t *testing.T) { + t.Setenv("ENTIRE_TOKEN", "some-token") + require.True(t, statusLoggedIn()) + }) + + t.Run("no token and no context is logged out", func(t *testing.T) { + t.Setenv("ENTIRE_TOKEN", "") + t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir()) // empty config → no active context + require.False(t, statusLoggedIn()) + }) +} + +// stubMirrorStatus replaces the network-backed resolver for a test. +func stubMirrorStatus(t *testing.T, status string, loggedIn bool) { + t.Helper() + prev := resolveMirrorStatus + resolveMirrorStatus = func(context.Context, *mirrorClone) (string, bool) { + return status, loggedIn + } + t.Cleanup(func() { resolveMirrorStatus = prev }) +} + +func TestRunStatus_ShowsMirror(t *testing.T) { + dir := setupTestDir(t) + testutil.InitRepo(t, dir) + addOriginRemote(t, dir, testMirrorURL) + writeSettings(t, testSettingsEnabled) + stubMirrorStatus(t, "ready", true) + + var stdout bytes.Buffer + require.NoError(t, runStatus(context.Background(), &stdout, false, false)) + out := stdout.String() + require.Contains(t, out, "Mirror") + require.Contains(t, out, "aws-us-east-2.entire.io") + require.Contains(t, out, "ready") +} + +func TestRunStatus_HintWhenNotMirrored(t *testing.T) { + dir := setupTestDir(t) + testutil.InitRepo(t, dir) + addOriginRemote(t, dir, "git@github.com:octocat/hello-world.git") + writeSettings(t, testSettingsEnabled) + // A forge clone is not pointed at a mirror, so the live-status resolver must + // never run — the hint is derived purely locally. + prev := resolveMirrorStatus + resolveMirrorStatus = func(context.Context, *mirrorClone) (string, bool) { + t.Fatal("resolveMirrorStatus called for a non-mirror clone") + return "", false + } + t.Cleanup(func() { resolveMirrorStatus = prev }) + + var stdout bytes.Buffer + require.NoError(t, runStatus(context.Background(), &stdout, false, false)) + out := stdout.String() + require.Contains(t, out, "github.com") + require.Contains(t, out, "not through an Entire mirror") + require.Contains(t, out, "entire repo mirror use") +} + +func TestRunStatus_NoHintForNonGitHubRemote(t *testing.T) { + dir := setupTestDir(t) + testutil.InitRepo(t, dir) + addOriginRemote(t, dir, "git@gitlab.com:acme/app.git") + writeSettings(t, testSettingsEnabled) + + var stdout bytes.Buffer + require.NoError(t, runStatus(context.Background(), &stdout, false, false)) + out := stdout.String() + require.NotContains(t, out, "mirror use", "mirrors are GitHub-only; no hint for a GitLab remote") +} + +func TestMirrorableForgeHost(t *testing.T) { + t.Parallel() + + t.Run("returns the host for a github forge origin", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, map[string]string{"origin": "https://github.com/octocat/hello-world"}) + host, ok := mirrorableForgeHost(t.Context(), dir) + require.True(t, ok) + require.Equal(t, "github.com", host) + }) + + t.Run("false for a non-mirrorable host", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, map[string]string{"origin": "git@gitlab.com:acme/app.git"}) + _, ok := mirrorableForgeHost(t.Context(), dir) + require.False(t, ok, "mirrors are GitHub-only today") + }) + + t.Run("false when already a mirror", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, map[string]string{"origin": testMirrorURL}) + _, ok := mirrorableForgeHost(t.Context(), dir) + require.False(t, ok, "an entire:// remote is already a mirror") + }) + + t.Run("false with no remotes", func(t *testing.T) { + t.Parallel() + dir := initRepoWithRemotes(t, nil) + _, ok := mirrorableForgeHost(t.Context(), dir) + require.False(t, ok) + }) +} + +func TestFormatMirrorHint(t *testing.T) { + t.Parallel() + sty := newStatusStyles(&bytes.Buffer{}) + out := formatMirrorHint("github.com", sty) + require.Contains(t, out, "github.com") + require.Contains(t, out, "not through an Entire mirror") + require.Contains(t, out, "entire repo mirror use") +} + +func TestRunStatusJSON_Mirror(t *testing.T) { + dir := setupTestDir(t) + testutil.InitRepo(t, dir) + addOriginRemote(t, dir, testMirrorURL) + writeSettings(t, testSettingsEnabled) + stubMirrorStatus(t, "ready", true) + + var stdout bytes.Buffer + require.NoError(t, runStatusJSON(context.Background(), &stdout)) + + var got statusJSON + require.NoError(t, json.Unmarshal(stdout.Bytes(), &got)) + require.NotNil(t, got.Mirror) + require.Equal(t, "origin", got.Mirror.Remote) + require.Equal(t, "aws-us-east-2.entire.io", got.Mirror.Cluster) + require.Equal(t, "octocat", got.Mirror.Owner) + require.Equal(t, "hello-world", got.Mirror.Repo) + require.Equal(t, "ready", got.Mirror.Status) + require.True(t, got.Mirror.LoggedIn) +} + +func TestRunStatusJSON_NoMirror(t *testing.T) { + dir := setupTestDir(t) + testutil.InitRepo(t, dir) + addOriginRemote(t, dir, "git@github.com:octocat/hello-world.git") + writeSettings(t, testSettingsEnabled) + + var stdout bytes.Buffer + require.NoError(t, runStatusJSON(context.Background(), &stdout)) + require.NotContains(t, stdout.String(), "\"mirror\"") + var got statusJSON + require.NoError(t, json.Unmarshal(stdout.Bytes(), &got)) + require.Nil(t, got.Mirror) +}