From 18257618a187d4143023e79c83acfe5bb6791d2c Mon Sep 17 00:00:00 2001 From: Mohith26 <109003762+Mohith26@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:46:48 -0500 Subject: [PATCH] blueprints create: resolve SSH host aliases in the origin remote When the origin remote uses a ~/.ssh/config alias, such as git@github-work:acme/app.git for a second GitHub account, the inferred repository became https://github-work/acme/app.git. Resolve the alias with `ssh -G` (which reads ssh config without connecting) for SSH remotes whose host isn't a known provider host, and map GitHub, GitLab and Bitbucket SSH-over-443 hosts back to their main host. --- pkg/blueprint/git.go | 55 ++++++++++++++++++++++++++++++++- pkg/blueprint/git_test.go | 65 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/pkg/blueprint/git.go b/pkg/blueprint/git.go index 1bcd274..8f81f11 100644 --- a/pkg/blueprint/git.go +++ b/pkg/blueprint/git.go @@ -16,13 +16,66 @@ func getRemoteOrigin(ctx context.Context) (string, error) { if err != nil { return "", fmt.Errorf(`cannot determine the repository from the local checkout; provide --repo, or check that this directory belongs to a Git repository with a remote named "origin": %w`, err) } - originURL, err := normalizeGitRepoURL(origin) + originURL, err := normalizeGitRepoURL(resolveSSHHostAlias(ctx, origin)) if err != nil { return "", fmt.Errorf("origin remote: %w", err) } return originURL, nil } +// gitProviderSSHHosts maps the SSH hosts of supported Git providers, including +// their SSH-over-443 hosts, to the host used in HTTPS repository URLs. +var gitProviderSSHHosts = map[string]string{ + "github.com": "github.com", + "ssh.github.com": "github.com", + "gitlab.com": "gitlab.com", + "altssh.gitlab.com": "gitlab.com", + "bitbucket.org": "bitbucket.org", + "altssh.bitbucket.org": "bitbucket.org", +} + +// resolveSSHHostAlias replaces the host of an SSH remote that is not a known +// provider host with the HostName ssh would connect to, so a ~/.ssh/config +// alias such as "github-work" (common when using more than one GitHub account) +// becomes the provider host. Other remotes, and hosts ssh cannot resolve, are +// returned unchanged. +func resolveSSHHostAlias(ctx context.Context, remote string) string { + endpoint, err := transport.NewEndpoint(remote) + if err != nil || endpoint.Protocol != "ssh" || endpoint.Host == "" { + return remote + } + if _, ok := gitProviderSSHHosts[strings.ToLower(endpoint.Host)]; ok { + return remote + } + hostname, err := sshHostname(ctx, endpoint.Host) + if err != nil || hostname == "" { + return remote + } + if providerHost, ok := gitProviderSSHHosts[strings.ToLower(hostname)]; ok { + hostname = providerHost + } + endpoint.Host = hostname + return endpoint.String() +} + +// sshHostname returns the "hostname" value from `ssh -G host`, which applies +// ~/.ssh/config without connecting. It is a variable so tests can replace it. +var sshHostname = func(ctx context.Context, host string) (string, error) { + if strings.HasPrefix(host, "-") { + return "", fmt.Errorf("invalid SSH host %q", host) + } + out, err := exec.CommandContext(ctx, "ssh", "-G", host).Output() + if err != nil { + return "", err + } + for _, line := range strings.Split(string(out), "\n") { + if value, ok := strings.CutPrefix(line, "hostname "); ok { + return strings.TrimSpace(value), nil + } + } + return "", errors.New("ssh -G printed no hostname") +} + // getLocalBranch returns the current checkout's branch, or an error for a detached HEAD. func getLocalBranch(ctx context.Context) (string, error) { branch, err := executeGitCommand(ctx, "branch", "--show-current") diff --git a/pkg/blueprint/git_test.go b/pkg/blueprint/git_test.go index 8383969..a69f17e 100644 --- a/pkg/blueprint/git_test.go +++ b/pkg/blueprint/git_test.go @@ -1,6 +1,8 @@ package blueprint import ( + "context" + "errors" "os" "path/filepath" "testing" @@ -199,3 +201,66 @@ func TestBuildCreateRequestLinkedWorktree(t *testing.T) { require.NoError(t, err) require.Equal(t, bptypes.BlueprintSource{Repo: "https://github.com/acme/app", Branch: new("review"), Path: new("render.yaml")}, got.Source) } + +func TestBuildCreateRequestResolvesSSHHostAlias(t *testing.T) { + for _, tc := range []struct { + name string + origin string + resolved map[string]string + want string + }{ + { + name: "scp-style alias resolves to its HostName", + origin: "git@github-work:acme/app.git", + resolved: map[string]string{"github-work": "github.com"}, + want: "https://github.com/acme/app.git", + }, + { + name: "ssh scheme alias with a port resolves to its HostName", + origin: "ssh://git@gitlab-alias:2222/acme/app.git", + resolved: map[string]string{"gitlab-alias": "gitlab.com"}, + want: "https://gitlab.com/acme/app.git", + }, + { + name: "alias for GitHub's SSH-over-443 host resolves to github.com", + origin: "git@github-443:acme/app.git", + resolved: map[string]string{"github-443": "ssh.github.com"}, + want: "https://github.com/acme/app.git", + }, + { + name: "known provider host is not passed to ssh", + origin: "git@github.com:acme/app.git", + resolved: map[string]string{"github.com": "ssh.example.com"}, + want: "https://github.com/acme/app.git", + }, + { + name: "unresolvable host is kept", + origin: "git@git.example.com:acme/app.git", + want: "https://git.example.com/acme/app.git", + }, + { + name: "HTTPS origin is not passed to ssh", + origin: "https://github.com/acme/app.git", + resolved: map[string]string{"github.com": "unexpected.example.com"}, + want: "https://github.com/acme/app.git", + }, + } { + t.Run(tc.name, func(t *testing.T) { + original := sshHostname + t.Cleanup(func() { sshHostname = original }) + sshHostname = func(_ context.Context, host string) (string, error) { + if hostname, ok := tc.resolved[host]; ok { + return hostname, nil + } + return "", errors.New("not resolved") + } + + dir, repo := newBlueprintGitRepo(t) + addBlueprintGitRemote(t, repo, "origin", tc.origin) + t.Chdir(dir) + got, err := CreateInput{}.buildCreateRequest(t.Context(), "workspace") + require.NoError(t, err) + require.Equal(t, tc.want, got.Source.Repo) + }) + } +}