diff --git a/docs/internal/overview.md b/docs/internal/overview.md index b07c962..24e5bd3 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1223fb1..dbb2f7f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/git/remote.go b/internal/git/remote.go new file mode 100644 index 0000000..1b0eb65 --- /dev/null +++ b/internal/git/remote.go @@ -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 +} diff --git a/internal/git/remote_test.go b/internal/git/remote_test.go new file mode 100644 index 0000000..768b47a --- /dev/null +++ b/internal/git/remote_test.go @@ -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) + } +}