Inspect authoritative Git remote state

This commit is contained in:
2026-10-05 18:04:58 +00:00
parent 617c243923
commit 7d2c0c10d4
4 changed files with 562 additions and 1 deletions

View File

@@ -17,6 +17,8 @@ Implemented internal packages:
- `internal/candidate` captures and validates one clean local candidate, checks
its committed release note and local tag state, runs validation once, and
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
profiles, and adapts configured checks to the process runner.

View File

@@ -567,7 +567,7 @@ diagnostic. Temporary Git repositories cover the acceptance cases; offline
## 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,
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.
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
Status: Pending. Depends on Stage 6.

328
internal/git/remote.go Normal file
View 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
View 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)
}
}