-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgitremote.go
More file actions
262 lines (237 loc) · 9.29 KB
/
Copy pathgitremote.go
File metadata and controls
262 lines (237 loc) · 9.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
// Package gitremote provides general-purpose git remote URL utilities:
// parsing, resolving, and redacting remote URLs. It has no dependency on
// checkpoint, strategy, or settings packages.
package gitremote
import (
"context"
"errors"
"fmt"
"net/url"
"os/exec"
"strings"
"unicode"
)
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.
// Host is the hostname only (never includes a port). Port is empty unless the
// source URL specified an explicit non-default port. Callers that need the
// combined "host[:port]" form should use HostPort.
//
// Forge is the short identifier of the upstream forge ("gh", "et", ...) used
// by the Entire trails API. It is populated from the path prefix on entire://
// URLs (entire://host/<forge>/owner/repo) and from a hostname lookup on
// direct git URLs (github.com → "gh"). It is empty for direct git URLs to
// unrecognized hosts, and for entire:// URLs without a forge segment.
type Info struct {
Protocol string
Host string
Port string
Forge string
Owner string
Repo string
}
// hostToForge maps direct git hostnames to their forge identifier on the
// trails API. entire:// URLs carry the forge in the path instead and bypass
// this map.
var hostToForge = map[string]string{
"github.com": "gh",
}
// forgeToHost is the reverse of hostToForge: it maps a forge identifier back to
// its canonical public host. Used to recover the real forge host from an
// entire:// remote, whose Host is the Entire cluster rather than the forge.
var forgeToHost = func() map[string]string {
m := make(map[string]string, len(hostToForge))
for host, forge := range hostToForge {
m[forge] = host
}
return m
}()
// IsSupportedForge reports whether forge is a known short forge id (e.g. "gh")
// understood by the trails API. It rejects forge hostnames ("github.com") and
// any other unrecognized value, so callers parsing a bare forge/owner/repo
// triple can fail clearly instead of forwarding a malformed forge to the API.
func IsSupportedForge(forge string) bool {
_, ok := forgeToHost[forge]
return ok
}
// CanonicalHost returns the canonical public host of the upstream forge.
//
// For direct git URLs this is just Host. For entire:// remotes — whose Host is
// the Entire cluster (e.g. aws-us-east-2.entire.io) rather than the forge — it
// maps the forge prefix back to the forge's host (gh → github.com). Falls back
// to Host when the forge is unknown (e.g. a self-hosted GitHub Enterprise),
// preserving the only host we know for it.
func (i *Info) CanonicalHost() string {
if host, ok := forgeToHost[i.Forge]; ok {
return host
}
return i.Host
}
// HostPort returns Host, or "Host:Port" when Port is non-empty.
func (i *Info) HostPort() string {
if i.Port == "" {
return i.Host
}
return i.Host + ":" + i.Port
}
// GetRemoteURL returns the URL configured for the named git remote.
func GetRemoteURL(ctx context.Context, remoteName string) (string, error) {
return GetRemoteURLInDir(ctx, "", remoteName)
}
// 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)
if dir != "" {
cmd.Dir = dir
}
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("remote %q not found", remoteName)
}
return strings.TrimSpace(string(output)), nil
}
// GetPushURLs returns every URL a push to remoteName delivers to, in the order
// git will use them.
//
// A remote's push destinations are remote.<name>.pushurl when any is set and its
// remote.<name>.url otherwise (git's push_url_of_remote), and BOTH may repeat —
// git pushes to all of them, in config order. So this, not GetRemoteURL,
// describes where a push actually goes; GetRemoteURL reports the FETCH URL,
// which can name a different repository entirely.
//
// Returns at least one entry on success.
func GetPushURLs(ctx context.Context, remoteName string) ([]string, error) {
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "--push", "--all", remoteName)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("remote %q not found", remoteName)
}
var urls []string
for _, line := range strings.Split(string(output), "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
urls = append(urls, trimmed)
}
}
if len(urls) == 0 {
return nil, fmt.Errorf("remote %q has no push URL", remoteName)
}
return urls, nil
}
// ParseURL parses a git remote URL (SSH SCP-style or HTTPS) into its components.
func ParseURL(rawURL string) (*Info, error) {
rawURL = strings.TrimSpace(rawURL)
if strings.Contains(rawURL, ":") && !strings.Contains(rawURL, "://") {
parts := strings.SplitN(rawURL, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid SSH URL: %s", RedactURL(rawURL))
}
hostPart := parts[0]
_, host, found := strings.Cut(hostPart, "@")
if !found {
host = hostPart
}
pathPart := strings.TrimSuffix(parts[1], ".git")
owner, repo, err := splitOwnerRepo(pathPart)
if err != nil {
return nil, err
}
return &Info{Protocol: ProtocolSSH, Host: host, Forge: hostToForge[host], Owner: owner, Repo: repo}, nil
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %s", RedactURL(rawURL))
}
if u.Scheme == "" {
return nil, fmt.Errorf("no protocol in URL: %s", RedactURL(rawURL))
}
pathPart := strings.TrimPrefix(u.Path, "/")
forge := hostToForge[u.Hostname()]
if u.Scheme == ProtocolEntire {
// entire:// URLs encode the forge as the first path segment.
forge, pathPart = splitForgePrefix(pathPart)
}
owner, repo, err := splitOwnerRepo(pathPart)
if err != nil {
return nil, err
}
return &Info{Protocol: u.Scheme, Host: u.Hostname(), Port: u.Port(), Forge: forge, Owner: owner, Repo: repo}, nil
}
// splitForgePrefix returns the leading forge/namespace segment of an entire://
// URL path and the remainder (e.g. "gh/owner/repo" -> "gh", "owner/repo").
// Paths without a separator are returned with an empty forge.
func splitForgePrefix(path string) (forge, rest string) {
if forge, rest, found := strings.Cut(path, "/"); found {
return forge, 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.
func RedactURL(rawURL string) string {
// SCP-style SSH: user@host:path — no credentials to redact.
if strings.Contains(rawURL, ":") && !strings.Contains(rawURL, "://") {
return rawURL
}
u, err := url.Parse(rawURL)
if err != nil {
return "<unparseable>"
}
u.User = nil
u.RawQuery = ""
return u.Scheme + "://" + u.Host + u.Path
}
// RedactURLOrPath renders a remote for display with any credentials removed,
// accepting values that are not URLs at all.
//
// RedactURL cannot be applied blanket-fashion: it round-trips through url.Parse
// and rebuilds "scheme://host/path", so a bare filesystem path like
// /srv/repo.git comes back as ":///srv/repo.git" and a bare word like "origin"
// as "://origin". Those inputs carry no credentials, so they pass through
// unchanged. Use this wherever the value may be a remote name, a local path, or
// a URL — i.e. anywhere a push/fetch target is shown to a user.
func RedactURLOrPath(remote string) string {
if strings.Contains(remote, "://") || strings.Contains(remote, "@") {
return RedactURL(remote)
}
return remote
}
// ResolveRemoteRepo returns the forge identifier, owner, and repo name for the
// given git remote. The forge is the short id used by the trails API ("gh",
// "et", ...); it is derived from the hostname for direct git URLs or from the
// path prefix on entire:// URLs. It is empty for unrecognized hosts.
// For example, git@github.com:org/my-repo.git returns ("gh", "org", "my-repo").
func ResolveRemoteRepo(ctx context.Context, remoteName string) (forge, owner, repo string, err error) {
rawURL, err := GetRemoteURL(ctx, remoteName)
if err != nil {
return "", "", "", fmt.Errorf("get remote URL for %q: %w", remoteName, err)
}
info, err := ParseURL(rawURL)
if err != nil {
return "", "", "", fmt.Errorf("parse remote URL: %w", err)
}
return info.Forge, info.Owner, info.Repo, nil
}
func splitOwnerRepo(path string) (string, string, error) {
path = strings.TrimSuffix(path, ".git")
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("cannot parse owner/repo from path: %s", path)
}
// Reject control characters (newlines, ANSI escapes, ...). The SCP-style
// branch in ParseURL bypasses net/url.Parse's built-in control-char
// rejection, so a crafted origin URL could otherwise smuggle a newline or
// escape into owner/repo and, via plain-text consumers like `entire
// agent-help`, into an agent's context or a user's terminal. This shared
// chokepoint protects every caller; the tainted bytes are not echoed back.
if strings.IndexFunc(parts[0]+"/"+parts[1], unicode.IsControl) >= 0 {
return "", "", errors.New("invalid control character in remote owner/repo")
}
return parts[0], parts[1], nil
}