Inspect authoritative Git remote state
This commit is contained in:
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