// Package git resolves and inspects a repository's authoritative Git remote. package git import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" ) var ( ErrInvalidRemote = errors.New("invalid Git remote") ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs") ErrEndpointChanged = errors.New("Git publication endpoint changed") ErrRemoteMainMissing = errors.New("authoritative remote has no main branch") ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit") ErrRemoteTagConflict = errors.New("version tag already exists on the authoritative remote") ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable") ) // Endpoint identifies the single effective publication destination captured // from a selected remote. ScopedName is a transient remote name used with // command-scoped configuration; it is never written to the repository. type Endpoint struct { RemoteName string PushURL string ScopedName string transport []string } // RefState records whether an exact remote ref was found by a successful query. type RefState uint8 const ( RefUnreadable RefState = iota RefAbsent RefPresent RefConflict ) // RemoteRef holds an exact ref's object and optional peeled commit. State is // RefConflict only when more than one exact result or malformed data is found. type RemoteRef struct { State RefState Object string Peeled string } // Snapshot is the result of querying refs/heads/main and one exact version tag. type Snapshot struct { Main RemoteRef Tag RemoteRef } // Resolve captures the selected remote's effective push URL and establishes a // command-scoped remote whose read and push URLs resolve to that same endpoint. func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) { if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") { return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, remoteName) } urls, err := pushURLs(ctx, repositoryDir, remoteName) if err != nil { return Endpoint{}, err } if len(urls) != 1 { return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, remoteName, len(urls)) } scopedName, err := newScopedName() if err != nil { return Endpoint{}, fmt.Errorf("create command-scoped remote name: %w", err) } endpoint := Endpoint{RemoteName: remoteName, PushURL: urls[0], ScopedName: scopedName} endpoint.transport, err = transportConfig(ctx, repositoryDir, remoteName, scopedName) if err != nil { return Endpoint{}, err } if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil { return Endpoint{}, err } return endpoint, nil } // Recheck confirms both the selected remote's effective push destination and // the scoped remote's effective read and push URLs still identify this endpoint. func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error { urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName) if err != nil { return fmt.Errorf("re-resolve remote %q: %w", endpoint.RemoteName, err) } if len(urls) != 1 || urls[0] != endpoint.PushURL { return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, endpoint.RemoteName, strings.Join(urls, ", ")) } return endpoint.verifyScopedURLs(ctx, repositoryDir) } // Query reads exact main and version-tag refs. A command failure is unreadable, // never evidence that a ref is absent. func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version string) (Snapshot, error) { if !validVersionRef(version) { return Snapshot{}, fmt.Errorf("invalid version tag name %q", version) } mainRef := "refs/heads/main" tagRef := "refs/tags/" + version peeledRef := tagRef + "^{}" output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef) if err != nil { snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}} return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err) } main := RemoteRef{State: RefAbsent} tag := RemoteRef{State: RefAbsent} for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") { if line == "" { continue } object, name, ok := strings.Cut(line, "\t") if !ok || !validObjectID(object) { return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line) } switch name { case mainRef: main = mergeRef(main, object, false) case tagRef: tag = mergeRef(tag, object, false) case peeledRef: tag = mergeRef(tag, object, true) } } return Snapshot{Main: main, Tag: tag}, nil } // RequireCandidate requires exact main equality and an absent remote version // tag. It deliberately does not accept a main branch that merely contains the // candidate as an ancestor. func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, version, commit string) (Snapshot, error) { snapshot, err := endpoint.Query(ctx, repositoryDir, version) if err != nil { return snapshot, err } if snapshot.Main.State == RefAbsent { return snapshot, ErrRemoteMainMissing } if snapshot.Main.State != RefPresent || snapshot.Main.Object != commit { snapshot.Main.State = RefConflict return snapshot, fmt.Errorf("%w: candidate %s, remote main %s", ErrRemoteMainConflict, commit, snapshot.Main.Object) } if snapshot.Tag.State != RefAbsent { if snapshot.Tag.State == RefUnreadable { return snapshot, ErrRemoteStateUnreadable } snapshot.Tag.State = RefConflict return snapshot, fmt.Errorf("%w: %s", ErrRemoteTagConflict, snapshot.Tag.Object) } return snapshot, nil } func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error { readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName) if err != nil { return fmt.Errorf("resolve scoped read URL: %w", err) } resolved := outputLines(readURL) if len(resolved) != 1 || resolved[0] != endpoint.PushURL { return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, resolved, endpoint.PushURL) } // The scoped remote sets its push URL explicitly to the same captured value. // Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf // applies only when no push URL exists and therefore cannot change this one. return nil } func (endpoint Endpoint) scopedConfig() []string { config := []string{ "remote." + endpoint.ScopedName + ".url=" + endpoint.PushURL, "remote." + endpoint.ScopedName + ".pushurl=" + endpoint.PushURL, "remote." + endpoint.ScopedName + ".mirror=false", } // These options affect Git transport behavior and are carried into the // scoped remote without copying refspecs, mirror mode, or push destinations. config = append(config, endpoint.transport...) return config } // transportConfig evaluates Git's normal configuration, including includes, // then copies transport settings under the transient remote's name. func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName string) ([]string, error) { var config []string for _, key := range []string{"proxy", "uploadpack", "receivepack", "vcs", "serveroption"} { output, err := run(ctx, repositoryDir, nil, "config", "--get-all", "remote."+remoteName+"."+key) if err != nil { // git config exits 1 when the key is absent; that is expected. var exitErr interface{ ExitCode() int } if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { continue } return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", remoteName, key, err) } for _, value := range outputLines(output) { config = append(config, "remote."+scopedName+"."+key+"="+value) } } return config, nil } func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) { output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName) if err != nil { return nil, fmt.Errorf("resolve push URL for remote %q: %w", remoteName, err) } values := outputLines(output) unique := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} unique = append(unique, value) } return unique, nil } func run(ctx context.Context, dir string, config []string, args ...string) (string, error) { commandArgs := make([]string, 0, len(config)*2+len(args)) for _, value := range config { commandArgs = append(commandArgs, "-c", value) } commandArgs = append(commandArgs, args...) var stdout, stderr strings.Builder err := process.Run(ctx, process.Invocation{ Args: append([]string{"git"}, commandArgs...), Dir: dir, Stdout: &stdout, Stderr: &stderr, }) if err != nil { message := strings.TrimSpace(stderr.String()) if message != "" { return stdout.String(), fmt.Errorf("%w: %s", err, message) } return stdout.String(), err } return stdout.String(), nil } func newScopedName() (string, error) { var bytes [12]byte if _, err := rand.Read(bytes[:]); err != nil { return "", err } return "releasekit-" + hex.EncodeToString(bytes[:]), nil } func outputLines(output string) []string { output = strings.TrimSuffix(output, "\n") if output == "" { return nil } lines := strings.Split(output, "\n") for i := range lines { lines[i] = strings.TrimSuffix(lines[i], "\r") } return lines } func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef { if ref.State == RefConflict { return ref } if peeled { if ref.State == RefAbsent { ref.State = RefPresent ref.Peeled = object return ref } if ref.Peeled != "" { return RemoteRef{State: RefConflict, Object: ref.Object} } ref.Peeled = object return ref } if ref.State == RefAbsent { ref.State, ref.Object = RefPresent, object return ref } if ref.Object != "" { return RemoteRef{State: RefConflict, Object: ref.Object} } ref.Object = object return ref } func validVersionRef(version string) bool { if len(version) < 6 || !strings.HasPrefix(version, "v") { return false } parts := strings.Split(version[1:], ".") if len(parts) != 3 { return false } for _, part := range parts { if part == "" || (len(part) > 1 && part[0] == '0') { return false } for _, char := range part { if char < '0' || char > '9' { return false } } } return true } func validObjectID(object string) bool { if len(object) != 40 && len(object) != 64 { return false } for _, char := range object { if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') { return false } } return true }