Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 158 additions & 1 deletion cmd/entire/cli/checkpoint/remote/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 := ""
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -285,6 +314,134 @@ func deriveCheckpointURLFromInfo(info *Info, config *settings.CheckpointRemoteCo
}
}

// originalURLConfigKey is the git config option, under remote.<name>., 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.<name>.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.<name>.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 {
Expand Down
140 changes: 139 additions & 1 deletion cmd/entire/cli/checkpoint/remote/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
},
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading