Files
ReleaseKit/internal/git/remote.go

318 lines
11 KiB
Go

// 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("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 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 tag refs. A command failure is unreadable,
// never evidence that a ref is absent.
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return Snapshot{}, err
}
mainRef := "refs/heads/main"
tagRef := "refs/tags/" + tag
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}
remoteTag := 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:
remoteTag = mergeRef(remoteTag, object, false)
case peeledRef:
remoteTag = mergeRef(remoteTag, object, true)
}
}
return Snapshot{Main: main, Tag: remoteTag}, nil
}
// RequireCandidate requires exact main equality and an absent remote
// 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, tag, commit string) (Snapshot, error) {
snapshot, err := endpoint.Query(ctx, repositoryDir, tag)
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 checkTagRef(ctx context.Context, repositoryDir, tag string) error {
if tag == "" {
return fmt.Errorf("invalid Git tag name %q", tag)
}
if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
}
return nil
}
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
}