Inspect authoritative Git remote state
This commit is contained in:
@@ -17,6 +17,8 @@ Implemented internal packages:
|
|||||||
- `internal/candidate` captures and validates one clean local candidate, checks
|
- `internal/candidate` captures and validates one clean local candidate, checks
|
||||||
its committed release note and local tag state, runs validation once, and
|
its committed release note and local tag state, runs validation once, and
|
||||||
rechecks mutable repository state.
|
rechecks mutable repository state.
|
||||||
|
- `internal/git` resolves one effective push destination, applies it to a
|
||||||
|
command-scoped remote, and reads exact authoritative branch and tag refs.
|
||||||
- `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON
|
- `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON
|
||||||
profiles, and adapts configured checks to the process runner.
|
profiles, and adapts configured checks to the process runner.
|
||||||
|
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ diagnostic. Temporary Git repositories cover the acceptance cases; offline
|
|||||||
|
|
||||||
## Stage 6: Resolve and inspect the authoritative endpoint
|
## Stage 6: Resolve and inspect the authoritative endpoint
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 5.
|
Status: Complete. Depends on Stage 5.
|
||||||
|
|
||||||
1. Implement effective push-URL capture, command-scoped remote configuration,
|
1. Implement effective push-URL capture, command-scoped remote configuration,
|
||||||
matching read/write resolution, and endpoint rechecks described above.
|
matching read/write resolution, and endpoint rechecks described above.
|
||||||
@@ -581,6 +581,14 @@ ordinary URL rewrites, multiple push destinations, missing main, conflicting
|
|||||||
tags, main ahead/behind the candidate, endpoint changes, and remote failure.
|
tags, main ahead/behind the candidate, endpoint changes, and remote failure.
|
||||||
Queries leave local tags, persistent remote configuration, and remote refs intact.
|
Queries leave local tags, persistent remote configuration, and remote refs intact.
|
||||||
|
|
||||||
|
Evidence: `internal/git` captures effective push destinations through Git,
|
||||||
|
rejects multiple distinct destinations, verifies scoped URL rewriting, and
|
||||||
|
queries exact main and version-tag refs. A candidate check requires main to
|
||||||
|
equal the captured commit and the version tag to be absent. Local bare remote
|
||||||
|
tests cover separate fetch/push URLs, both rewrite modes, missing main, an
|
||||||
|
existing tag, main ahead and behind, endpoint changes, unreadable remote state,
|
||||||
|
and unchanged persistent config and refs.
|
||||||
|
|
||||||
## Stage 7: Create and push the exact annotated tag
|
## Stage 7: Create and push the exact annotated tag
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 6.
|
Status: Pending. Depends on Stage 6.
|
||||||
|
|||||||
328
internal/git/remote.go
Normal file
328
internal/git/remote.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
223
internal/git/remote_test.go
Normal file
223
internal/git/remote_test.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
configBefore := gitText(t, repo.work, "config", "--get-all", "remote.origin.url") + "\x00" + gitText(t, repo.work, "config", "--get-all", "remote.origin.pushurl")
|
||||||
|
refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
|
||||||
|
|
||||||
|
endpoint, err := Resolve(context.Background(), repo.work, "origin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if endpoint.PushURL != repo.bare {
|
||||||
|
t.Fatalf("push URL = %q, want %q", endpoint.PushURL, repo.bare)
|
||||||
|
}
|
||||||
|
if err := endpoint.Recheck(context.Background(), repo.work); err != nil {
|
||||||
|
t.Fatalf("Recheck() error = %v", err)
|
||||||
|
}
|
||||||
|
snapshot, err := endpoint.RequireCandidate(context.Background(), repo.work, "v1.2.3", repo.commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RequireCandidate() error = %v", err)
|
||||||
|
}
|
||||||
|
if snapshot.Main.State != RefPresent || snapshot.Main.Object != repo.commit || snapshot.Tag.State != RefAbsent {
|
||||||
|
t.Fatalf("snapshot = %#v", snapshot)
|
||||||
|
}
|
||||||
|
configAfter := gitText(t, repo.work, "config", "--get-all", "remote.origin.url") + "\x00" + gitText(t, repo.work, "config", "--get-all", "remote.origin.pushurl")
|
||||||
|
refsAfter := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
|
||||||
|
if configAfter != configBefore {
|
||||||
|
t.Fatalf("persistent remote config changed: before %q, after %q", configBefore, configAfter)
|
||||||
|
}
|
||||||
|
if refsAfter != refsBefore {
|
||||||
|
t.Fatalf("remote refs changed: before %q, after %q", refsBefore, refsAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAppliesGitURLRewrites(t *testing.T) {
|
||||||
|
for _, kind := range []string{"insteadOf", "pushInsteadOf"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
alias := "releasekit-test:"
|
||||||
|
if kind == "insteadOf" {
|
||||||
|
gitRun(t, repo.work, "config", "url."+repo.bare+".insteadOf", alias)
|
||||||
|
gitRun(t, repo.work, "remote", "set-url", "origin", alias)
|
||||||
|
gitRun(t, repo.work, "config", "--unset-all", "remote.origin.pushurl")
|
||||||
|
} else {
|
||||||
|
gitRun(t, repo.work, "config", "url."+repo.bare+".pushInsteadOf", alias)
|
||||||
|
gitRun(t, repo.work, "remote", "set-url", "origin", alias)
|
||||||
|
gitRun(t, repo.work, "config", "--unset-all", "remote.origin.pushurl")
|
||||||
|
}
|
||||||
|
endpoint, err := Resolve(context.Background(), repo.work, "origin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if endpoint.PushURL != repo.bare {
|
||||||
|
t.Fatalf("effective push URL = %q, want %q", endpoint.PushURL, repo.bare)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRejectsMultipleDistinctPushDestinations(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
second := filepath.Join(t.TempDir(), "second.git")
|
||||||
|
gitRun(t, t.TempDir(), "init", "--bare", second)
|
||||||
|
gitRun(t, repo.work, "config", "--add", "remote.origin.pushurl", second)
|
||||||
|
_, err := Resolve(context.Background(), repo.work, "origin")
|
||||||
|
if !errors.Is(err, ErrMultiplePushURLs) {
|
||||||
|
t.Fatalf("Resolve() error = %v, want ErrMultiplePushURLs", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireCandidateDistinguishesMissingMainTagConflictAndMainMismatch(t *testing.T) {
|
||||||
|
t.Run("missing main", func(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
gitRun(t, repo.bare, "update-ref", "-d", "refs/heads/main")
|
||||||
|
endpoint := resolveFixture(t, repo)
|
||||||
|
snapshot, err := endpoint.RequireCandidate(context.Background(), repo.work, "v1.2.3", repo.commit)
|
||||||
|
if !errors.Is(err, ErrRemoteMainMissing) || snapshot.Main.State != RefAbsent {
|
||||||
|
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("existing tag", func(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
gitRun(t, repo.work, "tag", "v1.2.3", repo.commit)
|
||||||
|
gitRun(t, repo.work, "push", repo.bare, "refs/tags/v1.2.3:refs/tags/v1.2.3")
|
||||||
|
endpoint := resolveFixture(t, repo)
|
||||||
|
snapshot, err := endpoint.RequireCandidate(context.Background(), repo.work, "v1.2.3", repo.commit)
|
||||||
|
if !errors.Is(err, ErrRemoteTagConflict) || snapshot.Tag.State != RefConflict {
|
||||||
|
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, relation := range []string{"ahead", "behind"} {
|
||||||
|
t.Run("main "+relation, func(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
candidate := repo.commit
|
||||||
|
remoteMain := repo.commit
|
||||||
|
if relation == "ahead" {
|
||||||
|
writeRepoFile(t, repo.work, "ahead.txt", "ahead")
|
||||||
|
gitRun(t, repo.work, "add", "ahead.txt")
|
||||||
|
gitRun(t, repo.work, "commit", "-m", "Advance main")
|
||||||
|
remoteMain = gitText(t, repo.work, "rev-parse", "HEAD")
|
||||||
|
gitRun(t, repo.work, "push", repo.bare, "HEAD:refs/heads/main")
|
||||||
|
} else {
|
||||||
|
writeRepoFile(t, repo.work, "behind.txt", "candidate is ahead")
|
||||||
|
gitRun(t, repo.work, "add", "behind.txt")
|
||||||
|
gitRun(t, repo.work, "commit", "-m", "Candidate ahead of main")
|
||||||
|
candidate = gitText(t, repo.work, "rev-parse", "HEAD")
|
||||||
|
}
|
||||||
|
if relation == "behind" {
|
||||||
|
gitRun(t, repo.bare, "update-ref", "refs/heads/main", remoteMain)
|
||||||
|
}
|
||||||
|
endpoint := resolveFixture(t, repo)
|
||||||
|
snapshot, err := endpoint.RequireCandidate(context.Background(), repo.work, "v1.2.3", candidate)
|
||||||
|
if !errors.Is(err, ErrRemoteMainConflict) || snapshot.Main.State != RefConflict {
|
||||||
|
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryFailureIsUnreadableAndEndpointChangesAreDetected(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
endpoint := resolveFixture(t, repo)
|
||||||
|
missing := filepath.Join(t.TempDir(), "missing.git")
|
||||||
|
gitRun(t, repo.work, "remote", "set-url", "--push", "origin", missing)
|
||||||
|
if err := endpoint.Recheck(context.Background(), repo.work); !errors.Is(err, ErrEndpointChanged) {
|
||||||
|
t.Fatalf("Recheck() error = %v, want ErrEndpointChanged", err)
|
||||||
|
}
|
||||||
|
gitRun(t, repo.work, "remote", "set-url", "--push", "origin", missing)
|
||||||
|
broken, err := Resolve(context.Background(), repo.work, "origin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve(missing bare path) error = %v", err)
|
||||||
|
}
|
||||||
|
snapshot, err := broken.Query(context.Background(), repo.work, "v1.2.3")
|
||||||
|
if !errors.Is(err, ErrRemoteStateUnreadable) || snapshot.Main.State != RefUnreadable || snapshot.Tag.State != RefUnreadable {
|
||||||
|
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeRefMarksDuplicateExactResultsConflicting(t *testing.T) {
|
||||||
|
first := strings.Repeat("a", 40)
|
||||||
|
second := strings.Repeat("b", 40)
|
||||||
|
ref := mergeRef(RemoteRef{State: RefAbsent}, first, false)
|
||||||
|
ref = mergeRef(ref, second, false)
|
||||||
|
if ref.State != RefConflict {
|
||||||
|
t.Fatalf("state = %v, want RefConflict", ref.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type remoteFixture struct {
|
||||||
|
work string
|
||||||
|
bare string
|
||||||
|
commit string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRemoteFixture(t *testing.T) remoteFixture {
|
||||||
|
t.Helper()
|
||||||
|
base := t.TempDir()
|
||||||
|
work := filepath.Join(base, "work")
|
||||||
|
bare := filepath.Join(base, "authoritative.git")
|
||||||
|
decoy := filepath.Join(base, "fetch-only.git")
|
||||||
|
gitRun(t, base, "init", "-b", "main", work)
|
||||||
|
gitRun(t, work, "config", "user.name", "ReleaseKit Test")
|
||||||
|
gitRun(t, work, "config", "user.email", "releasekit@example.invalid")
|
||||||
|
gitRun(t, work, "init", "--bare", bare)
|
||||||
|
gitRun(t, work, "init", "--bare", decoy)
|
||||||
|
writeRepoFile(t, work, "source.txt", "candidate")
|
||||||
|
gitRun(t, work, "add", "source.txt")
|
||||||
|
gitRun(t, work, "commit", "-m", "Candidate")
|
||||||
|
commit := gitText(t, work, "rev-parse", "HEAD")
|
||||||
|
gitRun(t, work, "remote", "add", "origin", decoy)
|
||||||
|
gitRun(t, work, "config", "--add", "remote.origin.pushurl", bare)
|
||||||
|
gitRun(t, work, "push", bare, "HEAD:refs/heads/main")
|
||||||
|
return remoteFixture{work: work, bare: bare, commit: commit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFixture(t *testing.T, repo remoteFixture) Endpoint {
|
||||||
|
t.Helper()
|
||||||
|
endpoint, err := Resolve(context.Background(), repo.work, "origin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRun(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitText(t *testing.T, dir string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
outputBytes, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, outputBytes)
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(string(outputBytes), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRepoFile(t *testing.T, repoDir, name, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(repoDir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user