From 0632aa5901d903100ae92793781648c7b811f1b3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 1 Nov 2026 21:11:06 +0000 Subject: [PATCH] Treat incomplete remote tag observations as uncertain --- cmd/releasekit/main_test.go | 58 ++++++++++++++++++++++++++++++++++ docs/roadmap/implementation.md | 4 ++- internal/git/remote.go | 11 +++++-- internal/git/remote_test.go | 46 +++++++++++++++++++++++++++ internal/git/tag.go | 2 +- publish_test.go | 38 ++++++++++++++++++++++ 6 files changed, 155 insertions(+), 4 deletions(-) diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 0401b72..ba91f54 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -36,6 +36,64 @@ func TestVersionCommand(t *testing.T) { } } +func TestPublishCommandReportsUnknownForPeeledOnlyRecovery(t *testing.T) { + repository := newCLIRepository(t) + writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n") + profile, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"}) + if err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repository, "releasekit.json", string(profile)) + cliGitRun(t, repository, "add", "docs/releases/v1.2.3.md", "releasekit.json") + cliGitRun(t, repository, "commit", "-m", "configure release") + remote := filepath.Join(t.TempDir(), "origin.git") + cliGitRun(t, repository, "init", "--bare", remote) + cliGitRun(t, repository, "remote", "add", "origin", remote) + cliGitRun(t, repository, "push", "--set-upstream", "origin", "main") + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + marker := filepath.Join(t.TempDir(), "pushed") + script := `#!/bin/sh +is_push=0 +is_ls=0 +for arg in "$@"; do + [ "$arg" = push ] && is_push=1 + [ "$arg" = ls-remote ] && is_ls=1 +done +if [ "$is_ls" = 1 ] && [ -f "$PUSH_MARKER" ]; then + case " $* " in *refs/tags/v1.2.3*) + object=$("$REAL_GIT" rev-parse 'refs/tags/v1.2.3^{}') + printf '%s\trefs/tags/v1.2.3^{}\n' "$object" + exit 0;; esac +fi +if [ "$is_push" = 1 ]; then + "$REAL_GIT" "$@" || exit $? + : > "$PUSH_MARKER" + echo 'lost acknowledgment' >&2 + exit 77 +fi +exec "$REAL_GIT" "$@" +` + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("REAL_GIT", realGit) + t.Setenv("PUSH_MARKER", marker) + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"publish", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + if status != 3 || !strings.Contains(stderr.String(), "unknown") || strings.Contains(stdout.String(), "published") { + t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String()) + } + local := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3") + if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != local { + t.Fatalf("remote tag = %s, want %s", got, local) + } +} + func TestUnknownCommand(t *testing.T) { var stdout, stderr bytes.Buffer if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 { diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4d072cc..7c0b937 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -64,7 +64,9 @@ reproducibility guarantees are part of this work. ## Stage 1: Finalize remote ref observations and verify recovery -**Status:** pending. +**Status:** complete. The Git query, public publication, and CLI publication +regressions pass with the offline environment; the root `TestPublish` tests and +the full `internal/git` package also pass. **Files:** `internal/git/remote.go`, `internal/git/tag.go`, their tests, `publish_test.go`, and a focused CLI publication test under `cmd/releasekit/`. diff --git a/internal/git/remote.go b/internal/git/remote.go index 4755b1e..7fa1fdf 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -43,7 +43,7 @@ const ( ) // 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. +// RefConflict when records are inconsistent or incomplete. type RemoteRef struct { State RefState Object string @@ -130,7 +130,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) ( remoteTag = mergeRef(remoteTag, object, true) } } - return Snapshot{Main: main, Tag: remoteTag}, nil + return Snapshot{Main: main, Tag: finalizeRef(remoteTag)}, nil } // RequireCandidate requires exact main equality and an absent remote @@ -294,6 +294,13 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef { return ref } +func finalizeRef(ref RemoteRef) RemoteRef { + if ref.State == RefPresent && ref.Object == "" && ref.Peeled != "" { + ref.State = RefConflict + } + return ref +} + func checkTagRef(ctx context.Context, repositoryDir, tag string) error { if tag == "" { return fmt.Errorf("invalid Git tag name %q", tag) diff --git a/internal/git/remote_test.go b/internal/git/remote_test.go index 64abffa..0662442 100644 --- a/internal/git/remote_test.go +++ b/internal/git/remote_test.go @@ -184,6 +184,52 @@ func TestMergeRefMarksDuplicateExactResultsConflicting(t *testing.T) { } } +func TestTagQueryFinalizesCompleteResponse(t *testing.T) { + repo := newRemoteFixture(t) + endpoint := resolveFixture(t, repo) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + response := filepath.Join(t.TempDir(), "response") + script := "#!/bin/sh\ncase \" $* \" in *' ls-remote '* ) case \" $* \" in *' --get-url '* ) ;; *) cat \"$RESPONSE\"; exit 0;; esac;; esac\nexec \"$REAL_GIT\" \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("REAL_GIT", realGit) + t.Setenv("RESPONSE", response) + object, peeled := strings.Repeat("a", 40), strings.Repeat("b", 40) + exact := object + "\trefs/tags/v1.2.3\n" + peel := peeled + "\trefs/tags/v1.2.3^{}\n" + for _, tc := range []struct { + name, output string + want RemoteRef + }{ + {"empty", "", RemoteRef{State: RefAbsent}}, + {"exact", exact, RemoteRef{State: RefPresent, Object: object}}, + {"exact then peeled", exact + peel, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}}, + {"peeled then exact", peel + exact, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}}, + {"peeled only", peel, RemoteRef{State: RefConflict, Peeled: peeled}}, + {"duplicate", exact + exact, RemoteRef{State: RefConflict, Object: object}}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(response, []byte(tc.output), 0o600); err != nil { + t.Fatal(err) + } + snapshot, err := endpoint.Query(context.Background(), repo.work, "v1.2.3") + if err != nil || snapshot.Tag != tc.want { + t.Fatalf("Query tag = %#v, error = %v, want %#v", snapshot.Tag, err, tc.want) + } + got, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3") + if err != nil || got != tc.want { + t.Fatalf("QueryTag = %#v, error = %v, want %#v", got, err, tc.want) + } + }) + } +} + type remoteFixture struct { work string bare string diff --git a/internal/git/tag.go b/internal/git/tag.go index 111ef2b..2788a97 100644 --- a/internal/git/tag.go +++ b/internal/git/tag.go @@ -124,7 +124,7 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string remoteTag = mergeRef(remoteTag, object, true) } } - return remoteTag, nil + return finalizeRef(remoteTag), nil } // PushTag publishes only the captured tag object to its exact tag ref. diff --git a/publish_test.go b/publish_test.go index 7dc4a07..ca6b57f 100644 --- a/publish_test.go +++ b/publish_test.go @@ -749,6 +749,31 @@ func TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError(t * } } +func TestPublishPeeledOnlyReconciliationRemainsUnknown(t *testing.T) { + repo := newPublishFixture(t) + wrapper := installGitWrapper(t, repo, "peeled-only") + t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, + }) + if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" { + t.Fatalf("result = %#v, error = %v", result, err) + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 77 || !strings.Contains(err.Error(), "lost acknowledgment") { + t.Fatalf("push cause not retained: %v", err) + } + if count := countLines(t, wrapper.queryLog); count != 4 { + t.Fatalf("tag query count = %d, want one recovery query", count) + } + for _, dir := range []string{repo.work, repo.bare} { + if got := gitText(t, dir, "rev-parse", "refs/tags/v1.2.3"); got != result.TagObject { + t.Fatalf("tag in %s = %s, want %s", dir, got, result.TagObject) + } + } +} + func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) { repo := newPublishFixture(t) other := gitText(t, repo.work, "rev-parse", "refs/tags/v0.9.0") @@ -931,11 +956,24 @@ if [ "$IS_LS_REMOTE" = "1" ]; then ;; esac fi + if [ "$MODE" = "peeled-only" ] && [ -f "$PUSH_DONE" ]; then + case "$ARGS" in + *refs/tags/v1.2.3*) + OBJECT=$("$REAL_GIT" rev-parse 'refs/tags/v1.2.3^{}') + printf '%%s\trefs/tags/v1.2.3^{}\n' "$OBJECT" + exit 0 + ;; + esac + fi fi if [ "$IS_PUSH" = "1" ]; then "$REAL_GIT" "$@" STATUS=$? : > "$PUSH_DONE" + if [ "$MODE" = "peeled-only" ] && [ "$STATUS" = "0" ]; then + echo "lost acknowledgment after tag was accepted" >&2 + exit 77 + fi if [ "$MODE" = "lost-ack-cancel" ] && [ "$STATUS" = "0" ]; then kill -STOP $$; fi if [ "$MODE" = "lost-ack-rewrite" ] && [ "$STATUS" = "0" ]; then "$REAL_GIT" config --local "url.\"$MUTATE_OBJECT\".insteadOf" %s