diff --git a/cmd/entire/cli/checkpoint/remote/util.go b/cmd/entire/cli/checkpoint/remote/util.go index 7e2699530c..f7a80bff98 100644 --- a/cmd/entire/cli/checkpoint/remote/util.go +++ b/cmd/entire/cli/checkpoint/remote/util.go @@ -5,11 +5,14 @@ import ( "fmt" "log/slog" "os" + "sort" "strings" "github.com/entireio/cli/cmd/entire/cli/gitremote" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/settings" + + "github.com/go-git/go-git/v6" ) const originRemote = "origin" @@ -109,6 +112,12 @@ func FetchURL(ctx context.Context, opts ...FetchURLOptions) (string, error) { checkpointURL, err := deriveCheckpointURLFromInfo(info, config) if err != nil { + // Origin's protocol can't be mapped to a git transport (e.g. entire://, + // file://). Honor the configured checkpoint_remote by targeting the + // provider's canonical host over HTTPS rather than falling back to origin. + if providerURL, ok := resolveProviderCheckpointURL(config, originRemote, opt.WorktreeRoot); ok { + return providerURL, nil + } logFallback(ctx, "fetch", originURL, "derive checkpoint remote URL", err) return originURL, nil } @@ -177,7 +186,13 @@ func PushURL(ctx context.Context, pushRemoteName string) (string, bool, error) { } return "", true, fmt.Errorf("no push URL found: %w", err) } - if strings.TrimSpace(os.Getenv(CheckpointTokenEnvVar)) != "" { + if strings.TrimSpace(os.Getenv(CheckpointTokenEnvVar)) != "" && isDerivableProtocol(pushInfo.Protocol) { + // Coerce a derivable (ssh/https) remote to HTTPS so the token applies, + // keeping the host so enterprise installations stay on their own host. + // A non-derivable protocol (e.g. entire://) carries a host that isn't a + // usable HTTPS host, so it's left untouched and falls through to the + // providerCheckpointURL fallback below. + // // Keep the port only when the source was already HTTPS. SSH ports // (e.g., :2222) don't map to HTTPS ports on the same host. port := "" @@ -204,6 +219,13 @@ func PushURL(ctx context.Context, pushRemoteName string) (string, bool, error) { pushURL, err := deriveCheckpointURLFromInfo(pushInfo, config) if err != nil { + // The push remote's protocol can't be mapped to a git transport + // (e.g. entire://, file://). Honor the configured checkpoint_remote by + // targeting the provider's canonical host over HTTPS rather than + // misrouting checkpoints to the origin remote. + if providerURL, ok := resolveProviderCheckpointURL(config, pushRemoteName, ""); ok { + return providerURL, true, nil + } fallbackURL, fallbackErr := resolvePushFallbackURL(ctx, pushRemoteName, originURL) if fallbackErr == nil { logFallback(ctx, "push", fallbackURL, "derive push checkpoint URL", err, @@ -269,6 +291,13 @@ func ExtractOwnerFromRemoteURL(rawURL string) string { return gitremote.ExtractOwnerFromRemoteURL(rawURL) } +// isDerivableProtocol reports whether deriveCheckpointURLFromInfo can map the +// protocol to a checkpoint URL (i.e. it's a real git transport, not a remote +// helper scheme like entire:// or a local file://). +func isDerivableProtocol(protocol string) bool { + return protocol == ProtocolSSH || protocol == ProtocolHTTPS +} + func deriveCheckpointURLFromInfo(info *Info, config *settings.CheckpointRemoteConfig) (string, error) { switch info.Protocol { case ProtocolSSH: @@ -285,6 +314,134 @@ func deriveCheckpointURLFromInfo(info *Info, config *settings.CheckpointRemoteCo } } +// originalURLConfigKey is the git config option, under remote.., where +// entiredb's `entire-repo mirror use` records the URL a remote had before it was +// switched to entire://. It is the most faithful record of the endpoint and auth +// method the user had for that remote. +const originalURLConfigKey = "entiredb-original-url" + +// resolveProviderCheckpointURL builds the checkpoint URL for the configured +// provider, choosing the transport from what's already configured for that +// endpoint. It is the fallback used when the push/origin remote's protocol can't +// be mapped to a git transport (e.g. entire://, file://): the configured +// checkpoint_remote names a concrete provider, so checkpoints go there rather +// than being misrouted to the origin remote. +// +// Transport precedence: +// 1. The remote's pre-mirror URL (remote..entiredb-original-url) — the +// endpoint and scheme the remote used before `entire-repo mirror use` +// switched it to entire://. Reused verbatim (host + scheme + port). +// 2. ENTIRE_CHECKPOINT_TOKEN set -> HTTPS on the provider host (the token is +// the credential). +// 3. An existing remote already targets the provider host -> reuse its scheme, +// so checkpoints use the same auth the user already has for that endpoint. +// 4. Otherwise SSH on the provider host. +// +// Returns ok=false when no transport can be determined (unknown provider with no +// usable signal), in which case the caller falls back to the origin remote. +func resolveProviderCheckpointURL(config *settings.CheckpointRemoteConfig, remoteName, dir string) (string, bool) { + repo, err := openRepoAt(dir) + if err != nil { + repo = nil // Fall back to env/provider-only signals. + } + + info, ok := pickProviderTransport(repo, config, remoteName) + if !ok { + return "", false + } + url, err := deriveCheckpointURLFromInfo(info, config) + if err != nil { + return "", false + } + return url, true +} + +// pickProviderTransport returns the protocol/host/port to use when deriving a +// checkpoint URL, following the precedence documented on +// resolveProviderCheckpointURL. +func pickProviderTransport(repo *git.Repository, config *settings.CheckpointRemoteConfig, remoteName string) (*Info, bool) { + // 1. The remote's saved pre-mirror URL: the endpoint and auth the user had. + if repo != nil { + if original := originalRemoteURL(repo, remoteName); original != "" { + if info, err := gitremote.ParseURL(original); err == nil && isDerivableProtocol(info.Protocol) { + return info, true + } + } + } + + host, hostOK := providerHost(config.Provider) + + // 2. Explicit token -> HTTPS on the provider host. + if hostOK && strings.TrimSpace(os.Getenv(CheckpointTokenEnvVar)) != "" { + return &Info{Protocol: ProtocolHTTPS, Host: host}, true + } + + // 3. An existing remote already targeting the provider host -> reuse scheme. + if hostOK && repo != nil { + if info, ok := findRemoteInfoForHost(repo, host); ok { + return &Info{Protocol: info.Protocol, Host: info.Host, Port: info.Port}, true + } + } + + // 4. Default to SSH on the provider host. + if hostOK { + return &Info{Protocol: ProtocolSSH, Host: host}, true + } + + return nil, false +} + +// openRepoAt opens the git repository at dir (current directory when dir is +// empty), walking up to the enclosing .git directory. +func openRepoAt(dir string) (*git.Repository, error) { + if dir == "" { + dir = "." + } + repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{DetectDotGit: true}) + if err != nil { + return nil, fmt.Errorf("open git repository: %w", err) + } + return repo, nil +} + +// originalRemoteURL returns the pre-mirror URL saved by `entire-repo mirror use` +// in remote..entiredb-original-url, or "" when absent. +func originalRemoteURL(repo *git.Repository, remoteName string) string { + cfg, err := repo.Config() + if err != nil { + return "" + } + return cfg.Raw.Section("remote").Subsection(remoteName).Option(originalURLConfigKey) +} + +// findRemoteInfoForHost returns the parsed Info of the first configured git +// remote (in deterministic name order) whose host matches host and whose +// protocol is a usable git transport (ssh/https). entire:// and other +// non-transport remotes are ignored. +func findRemoteInfoForHost(repo *git.Repository, host string) (*Info, bool) { + cfg, err := repo.Config() + if err != nil { + return nil, false + } + names := make([]string, 0, len(cfg.Remotes)) + for name := range cfg.Remotes { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + for _, rawURL := range cfg.Remotes[name].URLs { + info, err := gitremote.ParseURL(rawURL) + if err != nil { + continue + } + if strings.EqualFold(info.Host, host) && isDerivableProtocol(info.Protocol) { + return info, true + } + } + } + return nil, false +} + func deriveTokenOriginURL(originURL string) (string, bool) { info, err := gitremote.ParseURL(originURL) if err != nil { diff --git a/cmd/entire/cli/checkpoint/remote/util_test.go b/cmd/entire/cli/checkpoint/remote/util_test.go index 0160543276..d81a06ada9 100644 --- a/cmd/entire/cli/checkpoint/remote/util_test.go +++ b/cmd/entire/cli/checkpoint/remote/util_test.go @@ -124,9 +124,21 @@ func TestFetchURL_EdgeCases(t *testing.T) { wantErr bool }{ { - name: "unsupported origin protocol without token falls back to origin", + name: "unsupported origin protocol without token routes to provider checkpoint url (ssh default)", addOrigin: true, settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`, + wantURL: "git@github.com:acme/checkpoints.git", + }, + { + name: "entire:// origin without token routes to provider checkpoint url (ssh default)", + originURL: "entire://app.entire.io/gh/acme/app", + settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`, + wantURL: "git@github.com:acme/checkpoints.git", + }, + { + name: "non-derivable origin with unknown provider falls back to origin", + originURL: "entire://app.entire.io/gh/acme/app", + settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"bitbucket","repo":"acme/checkpoints"}}}`, wantURL: "", }, { @@ -298,6 +310,39 @@ func TestPushURL(t *testing.T) { wantURL: "https://github.com/fork/app.git", wantEnabled: false, }, + { + name: "entire:// origin routes to provider checkpoint url (ssh default)", + originURL: "entire://app.entire.io/gh/acme/app", + pushRemote: "origin", + settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`, + wantURL: "git@github.com:acme/checkpoints.git", + wantEnabled: true, + }, + { + name: "file:// origin routes to provider checkpoint url (ssh default)", + originURL: "file:///acme/app", + pushRemote: "origin", + settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`, + wantURL: "git@github.com:acme/checkpoints.git", + wantEnabled: true, + }, + { + name: "non-derivable origin with unknown provider falls back to origin", + originURL: "entire://app.entire.io/gh/acme/app", + pushRemote: "origin", + settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"bitbucket","repo":"acme/checkpoints"}}}`, + wantURL: "entire://app.entire.io/gh/acme/app", + wantEnabled: false, + }, + { + name: "token with entire:// origin routes to provider host not origin host", + originURL: "entire://app.entire.io/gh/acme/app", + pushRemote: "origin", + settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`, + token: "push-token", + wantURL: "https://github.com/acme/checkpoints.git", + wantEnabled: true, + }, { name: "missing push remote falls back to origin when checkpoint remote configured", originURL: "https://github.com/acme/app.git", @@ -352,6 +397,99 @@ func TestPushURL(t *testing.T) { } } +// TestPushURL_EntireOriginReusesProviderRemoteScheme reproduces the real-world +// setup: origin migrated to an entire:// URL (forge-prefixed /gh/owner/repo) +// with a github checkpoint_remote. The checkpoint URL must route to github +// rather than fall back to the entire:// origin, reusing the auth/scheme the +// repo had for that endpoint — first from the pre-mirror URL that +// `entire-repo mirror use` saves (remote.origin.entiredb-original-url), then an +// existing remote on the provider host, then defaulting to SSH. +func TestPushURL_EntireOriginReusesProviderRemoteScheme(t *testing.T) { + const entireOrigin = "entire://aws-eu-central-1.entire.io/gh/entireio/cli" + tests := []struct { + name string + githubURL string + savedURL string + token string + wantURL string + wantEnabled bool + }{ + { + name: "pre-mirror ssh url yields ssh checkpoint url", + savedURL: "git@github.com:entireio/cli.git", + wantURL: "git@github.com:entireio/cli-checkpoints.git", + wantEnabled: true, + }, + { + name: "pre-mirror https url yields https checkpoint url", + savedURL: "https://github.com/entireio/cli.git", + wantURL: "https://github.com/entireio/cli-checkpoints.git", + wantEnabled: true, + }, + { + name: "pre-mirror url wins over token", + savedURL: "git@github.com:entireio/cli.git", + token: "ci-token", + wantURL: "git@github.com:entireio/cli-checkpoints.git", + wantEnabled: true, + }, + { + name: "ssh github remote yields ssh checkpoint url", + githubURL: "git@github.com:entireio/cli.git", + wantURL: "git@github.com:entireio/cli-checkpoints.git", + wantEnabled: true, + }, + { + name: "https github remote yields https checkpoint url", + githubURL: "https://github.com/entireio/cli.git", + wantURL: "https://github.com/entireio/cli-checkpoints.git", + wantEnabled: true, + }, + { + name: "no signal defaults to ssh", + wantURL: "git@github.com:entireio/cli-checkpoints.git", + wantEnabled: true, + }, + { + name: "token forces https when no pre-mirror url", + githubURL: "git@github.com:entireio/cli.git", + token: "ci-token", + wantURL: "https://github.com/entireio/cli-checkpoints.git", + wantEnabled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoDir := t.TempDir() + testutil.InitRepo(t, repoDir) + runGit(t, repoDir, "remote", "add", "origin", entireOrigin) + if tt.githubURL != "" { + runGit(t, repoDir, "remote", "add", "github", tt.githubURL) + } + if tt.savedURL != "" { + runGit(t, repoDir, "config", "remote.origin.entiredb-original-url", tt.savedURL) + } + writeSettings(t, repoDir, `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"entireio/cli-checkpoints"}}}`) + t.Chdir(repoDir) + if tt.token != "" { + t.Setenv(CheckpointTokenEnvVar, tt.token) + } + + gotURL, gotEnabled, err := PushURL(context.Background(), "origin") + if err != nil { + t.Fatalf("PushURL() error = %v", err) + } + if gotEnabled != tt.wantEnabled { + t.Fatalf("PushURL() enabled = %v, want %v", gotEnabled, tt.wantEnabled) + } + if gotURL != tt.wantURL { + t.Fatalf("PushURL() URL = %q, want %q", gotURL, tt.wantURL) + } + }) + } +} + func TestPushURL_ErrorsWhenNoCheckpointRemoteAndOriginMissing(t *testing.T) { repoDir := t.TempDir() testutil.InitRepo(t, repoDir) diff --git a/cmd/entire/cli/gitremote/gitremote.go b/cmd/entire/cli/gitremote/gitremote.go index 80e6ae65df..38197de27a 100644 --- a/cmd/entire/cli/gitremote/gitremote.go +++ b/cmd/entire/cli/gitremote/gitremote.go @@ -14,6 +14,9 @@ import ( const ( ProtocolSSH = "ssh" ProtocolHTTPS = "https" + // ProtocolEntire is the scheme of Entire's git remote helper (entire://). + // These URLs carry a forge/namespace prefix before owner/repo. + ProtocolEntire = "entire" ) // Info holds the parsed components of a git remote URL. @@ -88,6 +91,9 @@ func ParseURL(rawURL string) (*Info, error) { } pathPart := strings.TrimPrefix(u.Path, "/") + if u.Scheme == ProtocolEntire { + pathPart = stripForgePrefix(pathPart) + } owner, repo, err := splitOwnerRepo(pathPart) if err != nil { return nil, err @@ -96,6 +102,16 @@ func ParseURL(rawURL string) (*Info, error) { return &Info{Protocol: u.Scheme, Host: u.Hostname(), Port: u.Port(), Owner: owner, Repo: repo}, nil } +// stripForgePrefix removes the leading forge/namespace segment from an entire:// +// URL path (e.g. "gh/owner/repo" -> "owner/repo"). Paths without a separator are +// returned unchanged. +func stripForgePrefix(path string) string { + if _, rest, found := strings.Cut(path, "/"); found { + return rest + } + return path +} + // RedactURL removes credentials and query parameters from a URL for safe logging. // SCP-style SSH URLs (e.g., git@github.com:org/repo.git) are returned as-is // since they contain no embedded credentials. diff --git a/cmd/entire/cli/gitremote/gitremote_test.go b/cmd/entire/cli/gitremote/gitremote_test.go index 25d6c043e7..6f83782f00 100644 --- a/cmd/entire/cli/gitremote/gitremote_test.go +++ b/cmd/entire/cli/gitremote/gitremote_test.go @@ -60,6 +60,16 @@ func TestParseURL(t *testing.T) { url: "https://github.com/org/repo.git", wantInfo: &Info{Protocol: ProtocolHTTPS, Host: "github.com", Owner: "org", Repo: "repo"}, }, + { + name: "entire:// gh prefix stripped", + url: "entire://entirehost/gh/entireio/cli", + wantInfo: &Info{Protocol: ProtocolEntire, Host: "entirehost", Owner: "entireio", Repo: "cli"}, + }, + { + name: "entire:// non-gh prefix stripped", + url: "entire://abc/jk/myproject/repo", + wantInfo: &Info{Protocol: ProtocolEntire, Host: "abc", Owner: "myproject", Repo: "repo"}, + }, { name: "empty string", url: "",