This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new d840452bd feat(table): rename snapshot branches (#2441)
d840452bd is described below

commit d840452bda30329cf76ce27597c8bd721aa0f5dc
Author: Minh Vu <[email protected]>
AuthorDate: Mon Nov 9 18:55:21 2026 +0100

    feat(table): rename snapshot branches (#2441)
    
    * feat(table): rename snapshot branches atomically
    
    * test(table): cover branch rename and concurrent ref changes
    
    * style(table): format branch rename tests
    
    * fix(table): reject branch rename to same name
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): match snapshot ref field name
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * style(table): format branch rename test
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * test(table): assign valid sequence to concurrent branch snapshot
    
    * style(table): gofmt concurrent branch rename test
    
    * docs(table): clarify rename branch concurrency guarantees
    
    * test(table): cover branch renames with optional retention
    
    * fix(table): guard renamed branch type in local commits
    
    * test(table): reject concurrent rename source changes
    
    * test(table): verify branch rename REST commit payload
    
    * refactor(table): stage branch renames in transactions
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/rename_branch.go      | 109 ++++++++++++
 table/rename_branch_test.go | 412 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 521 insertions(+)

diff --git a/table/rename_branch.go b/table/rename_branch.go
new file mode 100644
index 000000000..6dee70ae8
--- /dev/null
+++ b/table/rename_branch.go
@@ -0,0 +1,109 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "fmt"
+
+       "github.com/apache/iceberg-go"
+)
+
+// RenameBranch atomically renames a branch using the table's normal 
transaction
+// commit path. The snapshot and retention policy are preserved.
+func (t Table) RenameBranch(ctx context.Context, from, to string) (*Table, 
error) {
+       if err := ctx.Err(); err != nil {
+               return nil, err
+       }
+       if t.metadata == nil || t.cat == nil {
+               return nil, fmt.Errorf("%w: rename branch requires a loaded 
table and catalog", iceberg.ErrInvalidArgument)
+       }
+
+       tx := t.NewTransaction()
+       if err := tx.RenameBranch(from, to); err != nil {
+               return nil, err
+       }
+
+       updated, err := tx.Commit(ctx)
+       if err != nil {
+               return updated, fmt.Errorf("rename branch %q to %q: %w", from, 
to, err)
+       }
+
+       return updated, nil
+}
+
+// RenameBranch stages a branch rename in this transaction, so it can be
+// committed atomically with other table updates. The source must be a branch,
+// the destination must not exist, and main cannot be renamed or replaced.
+// The source keeps its snapshot and retention settings.
+//
+// Both refs are pinned to their values in the transaction's base metadata.
+// Concurrent source snapshot moves and destination creation fail the commit.
+// Catalogs that validate requirements locally also reject source type changes.
+// REST requirements only compare snapshot IDs, so retention-only changes or
+// type changes with an unchanged ID cannot be detected by the REST catalog.
+func (t *Transaction) RenameBranch(from, to string) error {
+       meta, err := t.txnMeta()
+       if err != nil {
+               return err
+       }
+       if from == "" || to == "" {
+               return fmt.Errorf("%w: branch names must not be empty", 
iceberg.ErrInvalidArgument)
+       }
+       if from == to {
+               return fmt.Errorf("%w: source and destination branch names must 
differ", iceberg.ErrInvalidArgument)
+       }
+       if from == MainBranch || to == MainBranch {
+               return fmt.Errorf("%w: cannot rename or replace the main 
branch", iceberg.ErrInvalidArgument)
+       }
+
+       source, ok := meta.refs[from]
+       if !ok {
+               return fmt.Errorf("%w: branch %q does not exist", 
iceberg.ErrInvalidArgument, from)
+       }
+       if source.Type != BranchRef {
+               return fmt.Errorf("%w: ref %q is a %s, not a branch", 
iceberg.ErrInvalidArgument, from, source.Type)
+       }
+       if _, exists := meta.refs[to]; exists {
+               return fmt.Errorf("%w: ref %q already exists", 
iceberg.ErrInvalidArgument, to)
+       }
+
+       var maxRefAgeMs, maxSnapshotAgeMs int64
+       var minSnapshotsToKeep int
+       if source.MaxRefAgeMs != nil {
+               maxRefAgeMs = *source.MaxRefAgeMs
+       }
+       if source.MaxSnapshotAgeMs != nil {
+               maxSnapshotAgeMs = *source.MaxSnapshotAgeMs
+       }
+       if source.MinSnapshotsToKeep != nil {
+               minSnapshotsToKeep = *source.MinSnapshotsToKeep
+       }
+
+       updates := []Update{
+               NewSetSnapshotRefUpdate(to, source.SnapshotID, BranchRef,
+                       maxRefAgeMs, maxSnapshotAgeMs, minSnapshotsToKeep),
+               NewRemoveSnapshotRefUpdate(from),
+       }
+       requirements := []Requirement{
+               assertBranchRefSnapshotID(from, t.baseRefSnapshotID(from)),
+               AssertRefSnapshotID(to, t.baseRefSnapshotID(to)),
+       }
+
+       return t.applyPinned(updates, requirements, []string{from, to})
+}
diff --git a/table/rename_branch_test.go b/table/rename_branch_test.go
new file mode 100644
index 000000000..4a11593d2
--- /dev/null
+++ b/table/rename_branch_test.go
@@ -0,0 +1,412 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "maps"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/require"
+)
+
+type renameBranchTestCatalog struct {
+       metadata      Metadata
+       reqs          []Requirement
+       updates       []Update
+       commits       int
+       loads         int
+       failFirst     bool
+       firstConflict func()
+}
+
+func (c *renameBranchTestCatalog) CommitTable(_ context.Context, _ Identifier, 
reqs []Requirement, updates []Update) (Metadata, string, error) {
+       c.commits++
+       c.reqs = reqs
+       c.updates = updates
+       if c.failFirst && c.commits == 1 {
+               if c.firstConflict != nil {
+                       c.firstConflict()
+               }
+
+               return nil, "", ErrCommitFailed
+       }
+       for _, req := range reqs {
+               if err := req.Validate(c.metadata); err != nil {
+                       return nil, "", fmt.Errorf("%w: %w", ErrCommitFailed, 
err)
+               }
+       }
+       builder, err := MetadataBuilderFromBase(c.metadata, 
"/metadata/current.json")
+       if err != nil {
+               return nil, "", err
+       }
+       for _, update := range updates {
+               if err := update.Apply(builder); err != nil {
+                       return nil, "", err
+               }
+       }
+       metadata, err := builder.Build()
+       if err != nil {
+               return nil, "", err
+       }
+       c.metadata = metadata
+
+       return metadata, "/metadata/renamed.json", nil
+}
+
+func (c *renameBranchTestCatalog) LoadTable(_ context.Context, ident 
Identifier) (*Table, error) {
+       c.loads++
+
+       return New(ident, c.metadata, "/metadata/current.json",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, c), nil
+}
+
+func newRenameBranchTestTable(t *testing.T) (*Table, *renameBranchTestCatalog) 
{
+       t.Helper()
+       builder := builderWithoutChanges(2)
+       schemaID := builder.currentSchemaID
+       snapshot := Snapshot{
+               SnapshotID:   42,
+               TimestampMs:  builder.base.LastUpdatedMillis() + 1,
+               ManifestList: "/snap-42.avro",
+               SchemaID:     &schemaID,
+               Summary:      &Summary{Operation: OpAppend},
+       }
+       require.NoError(t, builder.AddSnapshot(&snapshot))
+       require.NoError(t, builder.SetSnapshotRef(MainBranch, 42, BranchRef))
+       require.NoError(t, builder.SetSnapshotRef("dev", 42, BranchRef,
+               WithMaxRefAgeMs(1000), WithMaxSnapshotAgeMs(2000), 
WithMinSnapshotsToKeep(3)))
+       meta, err := builder.Build()
+       require.NoError(t, err)
+       cat := &renameBranchTestCatalog{metadata: meta}
+
+       return New(Identifier{"db", "test"}, meta, "/metadata/current.json",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, cat), cat
+}
+
+func TestRenameBranchPreservesMetadata(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       originalRefs := maps.Collect(tbl.Metadata().Refs())
+
+       updated, err := tbl.RenameBranch(context.Background(), "dev", "review")
+       require.NoError(t, err)
+       require.Equal(t, 1, cat.commits)
+       require.Len(t, cat.reqs, 4)
+       require.Len(t, cat.updates, 2)
+       require.Equal(t, "/metadata/renamed.json", updated.MetadataLocation())
+
+       refs := maps.Collect(updated.Metadata().Refs())
+       require.NotContains(t, refs, "dev")
+       require.Equal(t, originalRefs["dev"], refs["review"])
+       require.Equal(t, originalRefs[MainBranch], refs[MainBranch])
+       require.Equal(t, int64(42), updated.CurrentSnapshot().SnapshotID)
+       require.Contains(t, maps.Collect(tbl.Metadata().Refs()), "dev", 
"original table is unchanged")
+}
+
+func TestRenameBranchCommitWireFormat(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       _, err := tbl.RenameBranch(t.Context(), "dev", "review")
+       require.NoError(t, err)
+
+       require.Len(t, cat.reqs, 4)
+       sourceReq, err := json.Marshal(cat.reqs[0])
+       require.NoError(t, err)
+       require.JSONEq(t, 
`{"type":"assert-ref-snapshot-id","ref":"dev","snapshot-id":42}`, 
string(sourceReq))
+
+       destinationReq, err := json.Marshal(cat.reqs[1])
+       require.NoError(t, err)
+       require.JSONEq(t, 
`{"type":"assert-ref-snapshot-id","ref":"review","snapshot-id":null}`, 
string(destinationReq))
+
+       require.Len(t, cat.updates, 2)
+       createUpdate, err := json.Marshal(cat.updates[0])
+       require.NoError(t, err)
+       require.JSONEq(t,
+               
`{"action":"set-snapshot-ref","ref-name":"review","type":"branch","snapshot-id":42,`+
+                       
`"max-ref-age-ms":1000,"max-snapshot-age-ms":2000,"min-snapshots-to-keep":3}`,
+               string(createUpdate),
+       )
+
+       removeUpdate, err := json.Marshal(cat.updates[1])
+       require.NoError(t, err)
+       require.JSONEq(t, `{"action":"remove-snapshot-ref","ref-name":"dev"}`, 
string(removeUpdate))
+}
+
+func TestRenameBranchPreservesOptionalRetention(t *testing.T) {
+       for _, tc := range []struct {
+               name string
+               opts []setSnapshotRefOption
+       }{
+               {name: "unset"},
+               {name: "only max ref age", opts: 
[]setSnapshotRefOption{WithMaxRefAgeMs(1000)}},
+               {name: "only min snapshots", opts: 
[]setSnapshotRefOption{WithMinSnapshotsToKeep(2)}},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       tbl, cat := newRenameBranchTestTable(t)
+                       builder, err := MetadataBuilderFromBase(cat.metadata, 
"/metadata/current.json")
+                       require.NoError(t, err)
+                       require.NoError(t, builder.SetSnapshotRef("feature", 
42, BranchRef, tc.opts...))
+                       cat.metadata, err = builder.Build()
+                       require.NoError(t, err)
+                       tbl.metadata = cat.metadata
+
+                       expected := 
maps.Collect(tbl.Metadata().Refs())["feature"]
+                       renamed, err := tbl.RenameBranch(t.Context(), 
"feature", "renamed")
+                       require.NoError(t, err)
+                       refs := maps.Collect(renamed.Metadata().Refs())
+                       require.Equal(t, expected, refs["renamed"])
+                       require.NotContains(t, refs, "feature")
+
+                       // An unset policy must stay absent in the REST update, 
not become
+                       // an explicit default retention value.
+                       updateJSON, err := json.Marshal(cat.updates[0])
+                       require.NoError(t, err)
+                       var fields map[string]json.RawMessage
+                       require.NoError(t, json.Unmarshal(updateJSON, &fields))
+                       if expected.MaxRefAgeMs == nil {
+                               require.NotContains(t, fields, "max-ref-age-ms")
+                       }
+                       if expected.MaxSnapshotAgeMs == nil {
+                               require.NotContains(t, fields, 
"max-snapshot-age-ms")
+                       }
+                       if expected.MinSnapshotsToKeep == nil {
+                               require.NotContains(t, fields, 
"min-snapshots-to-keep")
+                       }
+               })
+       }
+}
+
+func TestRenameBranchRejectsInvalidRefs(t *testing.T) {
+       for _, tc := range []struct {
+               name, from, to string
+               change         func(t *testing.T, c *renameBranchTestCatalog)
+       }{
+               {name: "missing source", from: "missing", to: "review"},
+               {name: "empty source", from: "", to: "review"},
+               {name: "empty destination", from: "dev", to: ""},
+               {name: "rename main", from: MainBranch, to: "review"},
+               {name: "replace main", from: "dev", to: MainBranch},
+               {name: "same name", from: "dev", to: "dev"},
+               {name: "existing destination", from: "dev", to: "review", 
change: func(t *testing.T, cat *renameBranchTestCatalog) {
+                       t.Helper()
+                       setRenameBranchTestRef(t, cat, "review", 42, BranchRef)
+               }},
+               {name: "existing destination tag", from: "dev", to: "review", 
change: func(t *testing.T, cat *renameBranchTestCatalog) {
+                       t.Helper()
+                       setRenameBranchTestRef(t, cat, "review", 42, TagRef)
+               }},
+               {name: "source is tag", from: "dev", to: "review", change: 
func(t *testing.T, cat *renameBranchTestCatalog) {
+                       t.Helper()
+                       setRenameBranchTestRef(t, cat, "dev", 42, TagRef)
+               }},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       tbl, cat := newRenameBranchTestTable(t)
+                       if tc.change != nil {
+                               tc.change(t, cat)
+                               tbl.metadata = cat.metadata
+                       }
+                       _, err := tbl.RenameBranch(context.Background(), 
tc.from, tc.to)
+                       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+                       require.Zero(t, cat.commits)
+               })
+       }
+}
+
+func TestRenameBranchPreservesConcurrentUnrelatedRef(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       setRenameBranchTestRef(t, cat, "other", 42, TagRef)
+
+       updated, err := tbl.RenameBranch(t.Context(), "dev", "review")
+       require.NoError(t, err)
+       refs := maps.Collect(updated.Metadata().Refs())
+       require.NotContains(t, refs, "dev")
+       require.Contains(t, refs, "review")
+       require.Equal(t, TagRef, refs["other"].Type)
+}
+
+func TestRenameBranchRejectsConcurrentSourceMove(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       builder, err := MetadataBuilderFromBase(cat.metadata, 
"/metadata/current.json")
+       require.NoError(t, err)
+       schemaID := builder.currentSchemaID
+       parentID := int64(42)
+       snapshot := Snapshot{
+               SnapshotID:       43,
+               ParentSnapshotID: &parentID,
+               SequenceNumber:   builder.currentLastSequenceNumber() + 1,
+               TimestampMs:      builder.base.LastUpdatedMillis() + 1,
+               ManifestList:     "/snap-43.avro",
+               SchemaID:         &schemaID,
+               Summary:          &Summary{Operation: OpAppend},
+       }
+       require.NoError(t, builder.AddSnapshot(&snapshot))
+       require.NoError(t, builder.SetSnapshotRef("dev", 43, BranchRef))
+       cat.metadata, err = builder.Build()
+       require.NoError(t, err)
+
+       _, err = tbl.RenameBranch(context.Background(), "dev", "review")
+       require.Error(t, err)
+       require.Equal(t, 1, cat.commits)
+       require.Len(t, cat.reqs, 4)
+       refs := maps.Collect(cat.metadata.Refs())
+       require.Equal(t, int64(43), refs["dev"].SnapshotID)
+       require.NotContains(t, refs, "review")
+}
+
+func TestRenameBranchRejectsConcurrentSourceTypeChange(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       setRenameBranchTestRef(t, cat, "dev", 42, TagRef)
+
+       _, err := tbl.RenameBranch(t.Context(), "dev", "review")
+       require.Error(t, err)
+       require.Equal(t, 1, cat.commits)
+       refs := maps.Collect(cat.metadata.Refs())
+       require.Equal(t, TagRef, refs["dev"].Type)
+       require.NotContains(t, refs, "review")
+}
+
+func TestRenameBranchRejectsConcurrentSourceDeletion(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       builder, err := MetadataBuilderFromBase(cat.metadata, 
"/metadata/current.json")
+       require.NoError(t, err)
+       require.NoError(t, builder.RemoveSnapshotRef("dev"))
+       cat.metadata, err = builder.Build()
+       require.NoError(t, err)
+
+       _, err = tbl.RenameBranch(t.Context(), "dev", "review")
+       require.Error(t, err)
+       require.Equal(t, 1, cat.commits)
+       refs := maps.Collect(cat.metadata.Refs())
+       require.NotContains(t, refs, "dev")
+       require.NotContains(t, refs, "review")
+}
+
+func TestRenameBranchRejectsConcurrentDestinationCreation(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       setRenameBranchTestRef(t, cat, "review", 42, TagRef)
+
+       _, err := tbl.RenameBranch(context.Background(), "dev", "review")
+       require.Error(t, err)
+       require.Equal(t, 1, cat.commits)
+       require.Len(t, cat.reqs, 4, "source and destination checks must both 
reach the catalog")
+       refs := maps.Collect(cat.metadata.Refs())
+       require.Contains(t, refs, "dev")
+       require.Equal(t, TagRef, refs["review"].Type)
+}
+
+func setRenameBranchTestRef(t *testing.T, cat *renameBranchTestCatalog, name 
string, id int64, refType RefType) {
+       t.Helper()
+       builder, err := MetadataBuilderFromBase(cat.metadata, 
"/metadata/current.json")
+       require.NoError(t, err)
+       require.NoError(t, builder.SetSnapshotRef(name, id, refType))
+       cat.metadata, err = builder.Build()
+       require.NoError(t, err)
+}
+
+func TestRenameBranchRespectsCancellation(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       ctx, cancel := context.WithCancel(context.Background())
+       cancel()
+
+       _, err := tbl.RenameBranch(ctx, "dev", "review")
+       require.ErrorIs(t, err, context.Canceled)
+       require.Zero(t, cat.commits)
+}
+
+func TestTransactionRenameBranchWithOtherUpdates(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       tx := tbl.NewTransaction()
+
+       require.NoError(t, tx.SetProperties(iceberg.Properties{"combined": 
"true"}))
+       require.NoError(t, tx.RenameBranch("dev", "review"))
+       require.Zero(t, cat.commits, "staging must not commit the table")
+
+       updated, err := tx.Commit(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, 1, cat.commits)
+       require.Equal(t, "true", updated.Properties()["combined"])
+       require.Len(t, cat.updates, 3)
+
+       refs := maps.Collect(updated.Metadata().Refs())
+       require.NotContains(t, refs, "dev")
+       require.Contains(t, refs, "review")
+       require.Equal(t, int64(42), refs["review"].SnapshotID)
+}
+
+func TestTransactionRenameBranchFailedStageKeepsEarlierUpdates(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.SetProperties(iceberg.Properties{"keep": "yes"}))
+
+       require.ErrorIs(t, tx.RenameBranch("dev", MainBranch), 
iceberg.ErrInvalidArgument)
+       updated, err := tx.Commit(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, "yes", updated.Properties()["keep"])
+       require.Len(t, cat.updates, 1)
+       require.Contains(t, maps.Collect(updated.Metadata().Refs()), "dev")
+}
+
+func enableRenameBranchRetries(t *testing.T, tbl *Table, cat 
*renameBranchTestCatalog) {
+       t.Helper()
+       builder, err := MetadataBuilderFromBase(cat.metadata, 
"/metadata/current.json")
+       require.NoError(t, err)
+       require.NoError(t, builder.SetProperties(iceberg.Properties{
+               CommitNumRetriesKey:     "2",
+               CommitMinRetryWaitMsKey: "1",
+               CommitMaxRetryWaitMsKey: "2",
+       }))
+       meta, err := builder.Build()
+       require.NoError(t, err)
+       cat.metadata = meta
+       tbl.metadata = meta
+}
+
+func TestRenameBranchRetriesUnrelatedConflict(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       enableRenameBranchRetries(t, tbl, cat)
+       cat.failFirst = true
+
+       updated, err := tbl.RenameBranch(t.Context(), "dev", "review")
+       require.NoError(t, err)
+       require.Equal(t, 2, cat.commits)
+       require.Equal(t, 1, cat.loads)
+       refs := maps.Collect(updated.Metadata().Refs())
+       require.NotContains(t, refs, "dev")
+       require.Equal(t, int64(42), refs["review"].SnapshotID)
+}
+
+func TestRenameBranchRetryCannotOverwriteNewDestination(t *testing.T) {
+       tbl, cat := newRenameBranchTestTable(t)
+       enableRenameBranchRetries(t, tbl, cat)
+       cat.failFirst = true
+       cat.firstConflict = func() {
+               setRenameBranchTestRef(t, cat, "review", 42, TagRef)
+       }
+
+       _, err := tbl.RenameBranch(t.Context(), "dev", "review")
+       require.ErrorIs(t, err, ErrTransactionUnusable)
+       require.Equal(t, 1, cat.commits, "the pinned destination must fail 
before a retry")
+       require.Equal(t, 1, cat.loads)
+       refs := maps.Collect(cat.metadata.Refs())
+       require.Contains(t, refs, "dev")
+       require.Equal(t, TagRef, refs["review"].Type)
+}

Reply via email to