329 lines
11 KiB
Go
329 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("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
|
|
}
|