From 5ba5e38e40a2d969ae4ffe6c03e993db654f771b Mon Sep 17 00:00:00 2001 From: elianddb Date: Mon, 11 May 2026 22:57:32 +0000 Subject: [PATCH 01/23] test: dolt_reset --hard preserves untracked tables with tag collisions --- .../doltcore/env/actions/reset_test.go | 87 +++++++++++++++++++ .../doltcore/sqle/enginetest/dolt_queries.go | 78 +++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 go/libraries/doltcore/env/actions/reset_test.go diff --git a/go/libraries/doltcore/env/actions/reset_test.go b/go/libraries/doltcore/env/actions/reset_test.go new file mode 100644 index 00000000000..820a1e20964 --- /dev/null +++ b/go/libraries/doltcore/env/actions/reset_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed 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 actions + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/dolt/go/libraries/doltcore/schema" + "github.com/dolthub/dolt/go/store/types" +) + +// TestMoveUntrackedTables verifies that MoveUntrackedTables correctly copies untracked tables +// into the target root, retagging any column whose tag collides with an existing tag in the target. +// See https://github.com/dolthub/dolt/issues/11007 +func TestMoveUntrackedTables(t *testing.T) { + dEnv, _ := createTestEnv() + ctx := context.Background() + require.NoError(t, dEnv.InitRepo(ctx, types.Format_Default, "test user", "test@test.com", "main")) + + emptyRoot, err := dEnv.WorkingRoot(ctx) + require.NoError(t, err) + + const barCodeTag uint64 = 9815 + const usersNameTag uint64 = 9815 // deliberate collision with barCodeTag + const postsTitleTag uint64 = 13593 + + mkSch := func(colName string, tag uint64) schema.Schema { + col := schema.NewColumn(colName, tag, types.StringKind, true, schema.NotNullConstraint{}) + sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) + require.NoError(t, err) + require.NoError(t, sch.SetPkOrdinals([]int{0})) + return sch + } + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, mkSch("code", barCodeTag)) + require.NoError(t, err) + + working := emptyRoot + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, mkSch("name", usersNameTag)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, mkSch("title", postsTitleTag)) + require.NoError(t, err) + + result, err := MoveUntrackedTables(ctx, working, emptyRoot, target) + require.NoError(t, err) + + t.Run("preserves tag on non-collision", func(t *testing.T) { + tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "posts"}) + require.NoError(t, err) + require.True(t, ok, "posts table must be present in the result") + sch, err := tbl.GetSchema(ctx) + require.NoError(t, err) + col, ok := sch.GetAllCols().GetByName("title") + require.True(t, ok) + require.Equal(t, postsTitleTag, col.Tag, "posts.title tag must not change when there is no collision") + }) + + t.Run("retags colliding column", func(t *testing.T) { + // AutoGenerateTag deterministically picks this tag when 9815 is already occupied. + // The value is stable even though Go maps are iterated in random order each test run. + const wantTag uint64 = 12204 + tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) + require.NoError(t, err) + require.True(t, ok, "users table must be present in the result") + sch, err := tbl.GetSchema(ctx) + require.NoError(t, err) + col, ok := sch.GetAllCols().GetByName("name") + require.True(t, ok) + require.Equal(t, wantTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") + }) +} diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index 3d1ccbfc9fd..9f8947e7235 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -4674,6 +4674,84 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, }, }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') preserves untracked tables after resetting to a divergent branch", + SetUpScript: []string{ + "call dolt_checkout('-b', 'feat');", + "create table bar (code varchar(64) primary key);", + "call dolt_commit('-Am', 'add bar');", + "call dolt_checkout('main');", + "create table users (name varchar(64) primary key);", + "insert into users values ('alice');", + "create table posts (title varchar(64) primary key);", + "insert into posts values ('hello');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'feat');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select name from users;", + Expected: []sql.Row{{"alice"}}, + }, + { + Query: "select title from posts;", + Expected: []sql.Row{{"hello"}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + // Both untracked tables happen to share the same internal column tag so each is at risk of being dropped. + Name: "dolt_reset('--hard') preserves two untracked tables that share a column tag", + SetUpScript: []string{ + "call dolt_branch('empty');", + "create table c (raw varchar(64) primary key);", + "insert into c values ('c_data');", + "create table e (str varchar(64) primary key);", + "insert into e values ('e_data');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'empty');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select raw from c;", + Expected: []sql.Row{{"c_data"}}, + }, + { + Query: "select str from e;", + Expected: []sql.Row{{"e_data"}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') replaces an untracked table when the target has the same name", + SetUpScript: []string{ + "call dolt_checkout('-b', 'feat');", + "create table overlap (code varchar(64) primary key);", + "insert into overlap values ('feat_data');", + "call dolt_commit('-Am', 'add overlap');", + "call dolt_checkout('main');", + "create table overlap (code varchar(64) primary key);", + "insert into overlap values ('main_data');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'feat');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select code from overlap;", + // The target branch version wins over the untracked copy. + Expected: []sql.Row{{"feat_data"}}, + }, + }, + }, } func gcSetup() []string { From ba3dbeb4f2f14e81ba9eab88a651cef959ca991a Mon Sep 17 00:00:00 2001 From: elianddb Date: Mon, 11 May 2026 22:57:36 +0000 Subject: [PATCH 02/23] fix: retag colliding columns when moving untracked tables on reset --hard retagging only updates the schema; row data remains keyed by the old tag, so non-empty untracked tables with a collision will have inaccessible rows. --- go/cmd/dolt/commands/schcmds/copy-tags.go | 2 +- go/cmd/dolt/commands/schcmds/update-tag.go | 33 +------ go/libraries/doltcore/env/actions/reset.go | 66 +++++++++---- go/libraries/doltcore/schema/schema_impl.go | 97 +++++++++++++++++++ .../dprocedures/dolt_update_column_tag.go | 33 +------ 5 files changed, 146 insertions(+), 85 deletions(-) diff --git a/go/cmd/dolt/commands/schcmds/copy-tags.go b/go/cmd/dolt/commands/schcmds/copy-tags.go index 7eb4778330a..45db102b137 100644 --- a/go/cmd/dolt/commands/schcmds/copy-tags.go +++ b/go/cmd/dolt/commands/schcmds/copy-tags.go @@ -334,7 +334,7 @@ func updateRootWithNewColumnTag(ctx context.Context, root doltdb.RootValue, tabl } // Update the column tag in the schema - updatedSchema, err := updateColumnTag(sch, columnName, tag) + updatedSchema, err := schema.UpdateColumnTag(sch, columnName, tag) if err != nil { return nil, err } diff --git a/go/cmd/dolt/commands/schcmds/update-tag.go b/go/cmd/dolt/commands/schcmds/update-tag.go index ad6d2da69d1..e8152f897eb 100644 --- a/go/cmd/dolt/commands/schcmds/update-tag.go +++ b/go/cmd/dolt/commands/schcmds/update-tag.go @@ -16,7 +16,6 @@ package schcmds import ( "context" - "fmt" "strconv" "github.com/dolthub/dolt/go/cmd/dolt/cli" @@ -99,7 +98,7 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get schema").Build(), usage) } - newSch, err := updateColumnTag(sch, columnName, tag) + newSch, err := schema.UpdateColumnTag(sch, columnName, tag) if err != nil { return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to update column tag").AddCause(err).Build(), usage) } @@ -122,33 +121,3 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri return commands.HandleVErrAndExitCode(nil, usage) } -func updateColumnTag(sch schema.Schema, name string, tag uint64) (schema.Schema, error) { - var found bool - columns := sch.GetAllCols().GetColumns() - // Find column and update its tag - for i, col := range columns { - if col.Name == name { - col.Tag = tag - columns[i] = col - found = true - break - } - } - - if !found { - return nil, fmt.Errorf("column %s does not exist", name) - } - - newSch, err := schema.SchemaFromCols(schema.NewColCollection(columns...)) - if err != nil { - return nil, err - } - - err = newSch.SetPkOrdinals(sch.GetPkOrdinals()) - if err != nil { - return nil, err - } - newSch.SetCollation(sch.GetCollation()) - - return newSch, nil -} diff --git a/go/libraries/doltcore/env/actions/reset.go b/go/libraries/doltcore/env/actions/reset.go index d3b664ea390..7eb5632ce3c 100644 --- a/go/libraries/doltcore/env/actions/reset.go +++ b/go/libraries/doltcore/env/actions/reset.go @@ -27,6 +27,7 @@ import ( "github.com/dolthub/dolt/go/libraries/doltcore/schema" "github.com/dolthub/dolt/go/libraries/doltcore/sqle/resolve" "github.com/dolthub/dolt/go/store/datas" + "github.com/dolthub/dolt/go/store/types" ) // resetHardTables resolves a new HEAD commit from a refSpec and updates working set roots by @@ -70,11 +71,10 @@ func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr str return newHead, doltdb.Roots{Head: roots.Head, Working: newWorking, Staged: roots.Head}, nil } -// MoveUntrackedTables copies tables present in |sourceWorking| but absent from |sourceStaged| -// onto |target|, returning the resulting root. Tables that name-collide with one already in -// |target| are skipped so the target version wins. Tables whose column tags collide with one -// in |target| are dropped silently. -// TODO(elianddb): retag colliding columns instead of dropping the table. +// MoveUntrackedTables copies tables that are in |sourceWorking| but not in |sourceStaged| +// into |target| and returns the updated root. If a table shares a name with one already in +// |target|, the version in |target| is kept. If a table has column tags that conflict with +// those in |target|, the conflicting tags are replaced before the table is copied. func MoveUntrackedTables(ctx context.Context, sourceWorking, sourceStaged, target doltdb.RootValue) (doltdb.RootValue, error) { untracked, err := doltdb.GetAllSchemas(ctx, sourceWorking) if err != nil { @@ -93,24 +93,17 @@ func MoveUntrackedTables(ctx context.Context, sourceWorking, sourceStaged, targe if err != nil { return nil, err } - targetTags := make(map[uint64]struct{}) - for _, sch := range targetSchemas { - for _, tag := range sch.GetAllCols().Tags { - targetTags[tag] = struct{}{} - } - } - for name, sch := range untracked { - for _, col := range sch.GetAllCols().GetColumns() { - if _, ok := targetTags[col.Tag]; ok { - delete(untracked, name) - break - } + // occupiedTags starts with all column tags in |target| and grows as each untracked + // table is processed so no two copied tables end up sharing a tag. + occupiedTags := make(schema.TagMapping) + for tblName, tsch := range targetSchemas { + for _, t := range tsch.GetAllCols().Tags { + occupiedTags.Add(t, tblName.Name) } } - for name := range untracked { - // Skip when target has a table of the same name so the target version wins. + for name, sch := range untracked { if _, exists := targetSchemas[name]; exists { continue } @@ -121,15 +114,48 @@ func MoveUntrackedTables(ctx context.Context, sourceWorking, sourceStaged, targe if !exists { return nil, fmt.Errorf("untracked table %s does not exist in working set", name) } + tbl, err = retagCollidingColumns(ctx, tbl, sch, name.Name, occupiedTags) + if err != nil { + return nil, err + } target, err = target.PutTable(ctx, name, tbl) if err != nil { - return nil, fmt.Errorf("failed to write table back to database: %s", err) + return nil, fmt.Errorf("failed to write table back to database: %w", err) } } return target, nil } +// retagCollidingColumns returns |tbl| with any column tag that conflicts with |occupiedTags| +// replaced by a new unique tag. |tableName| is passed to the tag generator as a seed input. +// Every column's final tag (original or replacement) is registered in |occupiedTags| so that +// subsequent calls in the same pass never collide with any tag belonging to this table. +func retagCollidingColumns(ctx context.Context, tbl *doltdb.Table, sch schema.Schema, tableName string, occupiedTags schema.TagMapping) (*doltdb.Table, error) { + columns := sch.GetAllCols().GetColumns() + var retagged bool + for idx, column := range columns { + if occupiedTags.Contains(column.Tag) { + precedingKinds := make([]types.NomsKind, idx) + for j, preceding := range columns[:idx] { + precedingKinds[j] = preceding.Kind + } + column.Tag = schema.AutoGenerateTag(occupiedTags, tableName, precedingKinds, column.Name, column.Kind) + columns[idx] = column + retagged = true + } + occupiedTags.Add(column.Tag, tableName) + } + if !retagged { + return tbl, nil + } + newSch, err := schema.RebuildWithColumns(sch, columns) + if err != nil { + return nil, err + } + return tbl.UpdateSchema(ctx, newSch) +} + func GetAllTableNames(ctx context.Context, root doltdb.RootValue) []doltdb.TableName { tableNames := make([]doltdb.TableName, 0) _ = root.IterTables(ctx, func(name doltdb.TableName, table *doltdb.Table, sch schema.Schema) (stop bool, err error) { diff --git a/go/libraries/doltcore/schema/schema_impl.go b/go/libraries/doltcore/schema/schema_impl.go index ba045714164..f6e24b48ae0 100644 --- a/go/libraries/doltcore/schema/schema_impl.go +++ b/go/libraries/doltcore/schema/schema_impl.go @@ -131,6 +131,103 @@ func SchemaFromCols(allCols *ColCollection) (Schema, error) { return sch, nil } +// RebuildWithColumns returns a copy of |sch| that uses |cols| as its column set. +// All schema-level metadata (comment, collation, primary key order, target row size, +// content-hashed fields) is preserved. Secondary index tag references are remapped to +// match any tag changes present in |cols| relative to |sch|. Check constraints are +// preserved unchanged because they reference column names, not tags. +func RebuildWithColumns(sch Schema, cols []Column) (Schema, error) { + tagRemap := buildColumnTagRemap(sch.GetAllCols().GetColumns(), cols) + + // Copy preserves all schema-level metadata fields including comment and + // contentHashedFields which are not exposed on the Schema interface. + cp := sch.Copy().(*schemaImpl) + + newColColl := NewColCollection(cols...) + var pkCols, nonPKCols []Column + for _, col := range cols { + if col.IsPartOfPK { + pkCols = append(pkCols, col) + } else { + nonPKCols = append(nonPKCols, col) + } + } + cp.allCols = newColColl + cp.pkCols = NewColCollection(pkCols...) + cp.nonPKCols = NewColCollection(nonPKCols...) + + cp.indexCollection = NewIndexCollection(newColColl, cp.pkCols) + err := sch.Indexes().Iter(func(idx Index) (stop bool, err error) { + props := IndexProperties{ + IsUnique: idx.IsUnique(), + IsSpatial: idx.IsSpatial(), + IsFullText: idx.IsFullText(), + IsUserDefined: idx.IsUserDefined(), + Comment: idx.Comment(), + FullTextProperties: idx.FullTextProperties(), + IsVector: idx.IsVector(), + VectorProperties: idx.VectorProperties(), + } + _, err = cp.indexCollection.AddIndexByColTags(idx.Name(), remapColumnTags(idx.IndexedColumnTags(), tagRemap), idx.PrefixLengths(), props) + return false, err + }) + if err != nil { + return nil, err + } + + return cp, nil +} + +// UpdateColumnTag returns a copy of |sch| with the tag of the column named |name| set to |tag|. +func UpdateColumnTag(sch Schema, name string, tag uint64) (Schema, error) { + columns := sch.GetAllCols().GetColumns() + var found bool + for i, col := range columns { + if col.Name == name { + col.Tag = tag + columns[i] = col + found = true + break + } + } + if !found { + return nil, fmt.Errorf("column %s does not exist", name) + } + return RebuildWithColumns(sch, columns) +} + +// buildColumnTagRemap returns a map from old tag to new tag for every position where +// origCols[i].Tag differs from newCols[i].Tag. +func buildColumnTagRemap(origCols, newCols []Column) map[uint64]uint64 { + var remap map[uint64]uint64 + for i, col := range newCols { + if i < len(origCols) && origCols[i].Tag != col.Tag { + if remap == nil { + remap = make(map[uint64]uint64) + } + remap[origCols[i].Tag] = col.Tag + } + } + return remap +} + +// remapColumnTags returns a copy of |tags| with each entry replaced by its value in +// |remap|, falling back to the original tag when no mapping exists. +// Returns |tags| unchanged when |remap| is nil or empty. +func remapColumnTags(tags []uint64, remap map[uint64]uint64) []uint64 { + if len(remap) == 0 { + return tags + } + out := make([]uint64, len(tags)) + for i, t := range tags { + out[i] = t + if r, ok := remap[t]; ok { + out[i] = r + } + } + return out +} + // SchemaFromColCollections creates a schema from the three collections. // // Deprecated: Use NewSchema instead. diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go index 299107fead3..d4696f09373 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go @@ -57,7 +57,7 @@ func doltUpdateColumnTag(ctx *sql.Context, args ...string) (sql.RowIter, error) return nil, err } - newSch, err := updateColumnTag(sch, columnName, tag) + newSch, err := schema.UpdateColumnTag(sch, columnName, tag) if err != nil { return nil, fmt.Errorf("failed to update column tag: %w", err) } @@ -101,34 +101,3 @@ func parseUpdateColumnTagArgs(args ...string) (tableName, columnName string, tag return tableName, columnName, tag, nil } -// updateColumnTag updates |sch| by setting the tag for the column named |name| to |tag|. -func updateColumnTag(sch schema.Schema, name string, tag uint64) (schema.Schema, error) { - var found bool - columns := sch.GetAllCols().GetColumns() - // Find column and update its tag - for i, col := range columns { - if col.Name == name { - col.Tag = tag - columns[i] = col - found = true - break - } - } - - if !found { - return nil, fmt.Errorf("column %s does not exist", name) - } - - newSch, err := schema.SchemaFromCols(schema.NewColCollection(columns...)) - if err != nil { - return nil, err - } - - if err = newSch.SetPkOrdinals(sch.GetPkOrdinals()); err != nil { - return nil, err - } - newSch.SetCollation(sch.GetCollation()) - newSch.SetTargetRowSize(sch.GetTargetRowSize()) - - return newSch, nil -} From ef677bd29d218a153d70f66f5d9173bf661239c0 Mon Sep 17 00:00:00 2001 From: elianddb Date: Tue, 12 May 2026 23:14:02 +0000 Subject: [PATCH 03/23] test: add regression tests for untracked table preservation --- .../doltcore/env/actions/reset_test.go | 69 +++- .../doltcore/sqle/enginetest/dolt_queries.go | 373 +++++++++++++++++- 2 files changed, 427 insertions(+), 15 deletions(-) diff --git a/go/libraries/doltcore/env/actions/reset_test.go b/go/libraries/doltcore/env/actions/reset_test.go index 820a1e20964..a6bf2876cbe 100644 --- a/go/libraries/doltcore/env/actions/reset_test.go +++ b/go/libraries/doltcore/env/actions/reset_test.go @@ -25,10 +25,16 @@ import ( "github.com/dolthub/dolt/go/store/types" ) +const ( + barCodeTag uint64 = 9815 + usersNameTag uint64 = 9815 // deliberate collision with barCodeTag + postsTitleTag uint64 = 13593 +) + // TestMoveUntrackedTables verifies that MoveUntrackedTables correctly copies untracked tables // into the target root, retagging any column whose tag collides with an existing tag in the target. -// See https://github.com/dolthub/dolt/issues/11007 func TestMoveUntrackedTables(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 dEnv, _ := createTestEnv() ctx := context.Background() require.NoError(t, dEnv.InitRepo(ctx, types.Format_Default, "test user", "test@test.com", "main")) @@ -36,10 +42,6 @@ func TestMoveUntrackedTables(t *testing.T) { emptyRoot, err := dEnv.WorkingRoot(ctx) require.NoError(t, err) - const barCodeTag uint64 = 9815 - const usersNameTag uint64 = 9815 // deliberate collision with barCodeTag - const postsTitleTag uint64 = 13593 - mkSch := func(colName string, tag uint64) schema.Schema { col := schema.NewColumn(colName, tag, types.StringKind, true, schema.NotNullConstraint{}) sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) @@ -48,16 +50,23 @@ func TestMoveUntrackedTables(t *testing.T) { return sch } + mkSchWithIndex := func(colName string, tag uint64, indexName string) schema.Schema { + sch := mkSch(colName, tag) + _, err := sch.Indexes().AddIndexByColTags(indexName, []uint64{tag}, nil, schema.IndexProperties{IsUserDefined: true, IsUnique: true}) + require.NoError(t, err) + return sch + } + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, mkSch("code", barCodeTag)) require.NoError(t, err) working := emptyRoot - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, mkSch("name", usersNameTag)) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, mkSchWithIndex("name", usersNameTag, "idx_name")) require.NoError(t, err) working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, mkSch("title", postsTitleTag)) require.NoError(t, err) - result, err := MoveUntrackedTables(ctx, working, emptyRoot, target) + result, allRemaps, err := MoveUntrackedTables(ctx, working, emptyRoot, target) require.NoError(t, err) t.Run("preserves tag on non-collision", func(t *testing.T) { @@ -71,6 +80,22 @@ func TestMoveUntrackedTables(t *testing.T) { require.Equal(t, postsTitleTag, col.Tag, "posts.title tag must not change when there is no collision") }) + t.Run("WithUpdatedColumnTags preserves schema metadata", func(t *testing.T) { + col := schema.NewColumn("val", 100, types.StringKind, true, schema.NotNullConstraint{}) + sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) + require.NoError(t, err) + require.NoError(t, sch.SetPkOrdinals([]int{0})) + sch.SetCollation(schema.Collation_utf8mb4_general_ci) + sch.SetComment("test comment") + sch.SetTargetRowSize(4096) + + remapped, err := schema.WithUpdatedColumnTags(sch, map[uint64]uint64{100: 200}) + require.NoError(t, err) + require.Equal(t, schema.Collation_utf8mb4_general_ci, remapped.GetCollation(), "collation must survive WithUpdatedColumnTags") + require.Equal(t, "test comment", remapped.GetComment(), "comment must survive WithUpdatedColumnTags") + require.Equal(t, uint16(4096), remapped.GetTargetRowSize(), "target row size must survive WithUpdatedColumnTags") + }) + t.Run("retags colliding column", func(t *testing.T) { // AutoGenerateTag deterministically picks this tag when 9815 is already occupied. // The value is stable even though Go maps are iterated in random order each test run. @@ -83,5 +108,35 @@ func TestMoveUntrackedTables(t *testing.T) { col, ok := sch.GetAllCols().GetByName("name") require.True(t, ok) require.Equal(t, wantTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") + + idx, ok := sch.Indexes().GetByNameCaseInsensitive("idx_name") + require.True(t, ok, "idx_name must survive the retag") + require.Equal(t, []uint64{wantTag}, idx.IndexedColumnTags(), "idx_name must reference the retagged column, not the old tag") + require.True(t, idx.IsUnique(), "idx_name is unique and must remain so after retag") + require.True(t, idx.IsUserDefined(), "idx_name is user-defined and must remain so after retag") + }) + + t.Run("ApplyForeignKeyTagRemaps patches child side only when parent is committed", func(t *testing.T) { + // users.name and bar.code share tag 9815 so MoveUntrackedTables retagged users.name. + // The FK links users.name to bar.code so only the child side should change. + const wantTag uint64 = 12204 + + fks, err := doltdb.NewForeignKeyCollection(doltdb.ForeignKey{ + Name: "fk_bar", + TableName: doltdb.TableName{Name: "users"}, + TableColumns: []uint64{usersNameTag}, + ReferencedTableName: doltdb.TableName{Name: "bar"}, + ReferencedTableColumns: []uint64{barCodeTag}, + }) + require.NoError(t, err) + + require.NoError(t, ApplyForeignKeyTagRemaps(fks, allRemaps)) + + got, ok := fks.GetByNameCaseInsensitive("fk_bar", doltdb.TableName{Name: "users"}) + require.True(t, ok) + require.Equal(t, []uint64{wantTag}, got.TableColumns, + "child column must be updated to the retagged value") + require.Equal(t, []uint64{barCodeTag}, got.ReferencedTableColumns, + "committed parent column tag must not change") }) } diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index 9f8947e7235..9395c3aec93 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -3992,6 +3992,182 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, }, }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout preserves untracked tables with secondary indexes and foreign keys", + SetUpScript: []string{ + "call dolt_branch('empty');", + "create table parent (id int primary key, name varchar(64));", + "create table child (id int primary key, parent_id int, val varchar(64));", + "create unique index idx_val on child (val);", + "alter table child add constraint fk_parent foreign key (parent_id) references parent(id);", + "insert into parent values (1, 'alice');", + "insert into child values (10, 1, 'c_val');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('empty');", + Expected: []sql.Row{{0, "Switched to branch 'empty'"}}, + }, + { + Query: "select id, name from parent;", + Expected: []sql.Row{{1, "alice"}}, + }, + { + Query: "select id, parent_id, val from child;", + Expected: []sql.Row{{10, 1, "c_val"}}, + }, + { + Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", + // non_unique=0 confirms the index remained unique after the checkout transition. + Expected: []sql.Row{{"idx_val", 0, 1, "val"}}, + }, + { + Query: "select id from child where val = 'c_val';", + Expected: []sql.Row{{10}}, + }, + { + Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Expected: []sql.Row{{"fk_parent", "child"}}, + }, + { + Query: "insert into child values (99, 999, 'orphan');", + ExpectedErr: sql.ErrForeignKeyChildViolation, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout preserves FK on untracked child when parent is committed on target", + SetUpScript: []string{ + "call dolt_branch('feat');", + "call dolt_checkout('feat');", + "create table parent (id int primary key, name varchar(64));", + "insert into parent values (1, 'alice'), (2, 'bob');", + "call dolt_commit('-Am', 'add parent on feat');", + "call dolt_checkout('main');", + // A local copy of parent is needed for the FK definition; the committed version wins after checkout. + "create table parent (id int primary key, name varchar(64));", + "create table child (id int primary key, parent_id int, constraint fk_parent foreign key (parent_id) references parent(id));", + "insert into parent values (1, 'alice'), (2, 'bob');", + "insert into child values (10, 1), (20, 2);", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('feat');", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "select id, name from parent order by id;", + // The committed parent from feat replaces the untracked local copy. + Expected: []sql.Row{{1, "alice"}, {2, "bob"}}, + }, + { + Query: "select id, parent_id from child order by id;", + Expected: []sql.Row{{10, 1}, {20, 2}}, + }, + { + Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Expected: []sql.Row{{"fk_parent", "child"}}, + }, + { + Query: "insert into child values (99, 999);", + ExpectedErr: sql.ErrForeignKeyChildViolation, + }, + }, + }, + { + Name: "dolt_checkout('--move') aborts when untracked table conflicts with committed table on target branch", + SetUpScript: []string{ + "call dolt_branch('feat');", + "call dolt_checkout('--move', 'feat');", + "create table conflict_tbl (id int primary key, val int);", + "call dolt_commit('-Am', 'add conflict_tbl on feat');", + "call dolt_checkout('--move', 'main');", + // conflict_tbl is now an untracked table on main (not committed to main) + "create table conflict_tbl (id int primary key);", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('--move', 'feat');", + ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", + }, + { + // active_branch confirms the checkout was aborted before switching branches. + Query: "select active_branch();", + Expected: []sql.Row{{"main"}}, + }, + }, + }, + { + Name: "dolt_checkout('--move') aborts when staged table conflicts with committed table on target branch", + SetUpScript: []string{ + "call dolt_branch('feat');", + "call dolt_checkout('--move', 'feat');", + "create table conflict_tbl (id int primary key, val int);", + "call dolt_commit('-Am', 'add conflict_tbl on feat');", + "call dolt_checkout('--move', 'main');", + // conflict_tbl is staged on main but not yet committed + "create table conflict_tbl (id int primary key);", + "call dolt_add('conflict_tbl');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('--move', 'feat');", + ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", + }, + { + // active_branch confirms the checkout was aborted before switching branches. + Query: "select active_branch();", + Expected: []sql.Row{{"main"}}, + }, + }, + }, + { + Name: "dolt_checkout aborts when untracked table conflicts with committed table on target branch", + SetUpScript: []string{ + "call dolt_branch('feat');", + "call dolt_checkout('feat');", + "create table conflict_tbl (id int primary key, val int);", + "call dolt_commit('-Am', 'add conflict_tbl on feat');", + "call dolt_checkout('main');", + // conflict_tbl is now an untracked table on main (not committed to main) + "create table conflict_tbl (id int primary key);", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('feat');", + ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", + }, + { + Query: "select active_branch();", + Expected: []sql.Row{{"main"}}, + }, + }, + }, + { + Name: "dolt_checkout aborts when staged table conflicts with committed table on target branch", + SetUpScript: []string{ + "call dolt_branch('feat');", + "call dolt_checkout('feat');", + "create table conflict_tbl (id int primary key, val int);", + "call dolt_commit('-Am', 'add conflict_tbl on feat');", + "call dolt_checkout('main');", + // conflict_tbl is staged on main but not committed + "create table conflict_tbl (id int primary key);", + "call dolt_add('conflict_tbl');", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('feat');", + ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", + }, + { + Query: "select active_branch();", + Expected: []sql.Row{{"main"}}, + }, + }, + }, } var DoltCheckoutReadOnlyScripts = []queries.ScriptTest{ @@ -4682,8 +4858,9 @@ var DoltResetTestScripts = []queries.ScriptTest{ "create table bar (code varchar(64) primary key);", "call dolt_commit('-Am', 'add bar');", "call dolt_checkout('main');", - "create table users (name varchar(64) primary key);", - "insert into users values ('alice');", + "create table users (name varchar(64) primary key, email varchar(64));", + "create index idx_email on users (email);", + "insert into users values ('alice', 'alice@example.com');", "create table posts (title varchar(64) primary key);", "insert into posts values ('hello');", }, @@ -4696,6 +4873,14 @@ var DoltResetTestScripts = []queries.ScriptTest{ Query: "select name from users;", Expected: []sql.Row{{"alice"}}, }, + { + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'users' and index_name = 'idx_email';", + Expected: []sql.Row{{"idx_email", 1, 1, "email", "YES", "BTREE", ""}}, + }, + { + Query: "select name from users where email = 'alice@example.com';", + Expected: []sql.Row{{"alice"}}, + }, { Query: "select title from posts;", Expected: []sql.Row{{"hello"}}, @@ -4704,14 +4889,19 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { // See https://github.com/dolthub/dolt/issues/11007 - // Both untracked tables happen to share the same internal column tag so each is at risk of being dropped. - Name: "dolt_reset('--hard') preserves two untracked tables that share a column tag", + Name: "dolt_reset('--hard') preserves secondary indexes on two untracked tables that share column tags", SetUpScript: []string{ "call dolt_branch('empty');", - "create table c (raw varchar(64) primary key);", - "insert into c values ('c_data');", - "create table e (str varchar(64) primary key);", - "insert into e values ('e_data');", + "create table c (raw varchar(64) primary key, code varchar(64), constraint chk_code check (code like 'c_%'));", + "create unique index idx_code on c (code);", + "insert into c values ('c_data', 'c_code');", + "create table e (str varchar(64) primary key, label varchar(64), tag varchar(64));", + "create index idx_label on e (label);", + "create index idx_composite on e (label, tag);", + "insert into e values ('e_data', 'e_label', 'e_tag');", + "create table kl (val varchar(64));", + "create index idx_val on kl (val);", + "insert into kl values ('kl_data');", }, Assertions: []queries.ScriptTestAssertion{ { @@ -4722,10 +4912,177 @@ var DoltResetTestScripts = []queries.ScriptTest{ Query: "select raw from c;", Expected: []sql.Row{{"c_data"}}, }, + { + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'c' and index_name = 'idx_code';", + // non_unique=0 confirms the UNIQUE flag survived the retag. + Expected: []sql.Row{{"idx_code", 0, 1, "code", "YES", "BTREE", ""}}, + }, + { + Query: "select raw from c where code = 'c_code';", + Expected: []sql.Row{{"c_data"}}, + }, + { + Query: "insert into c values ('bad', 'x_bad');", + ExpectedErr: sql.ErrCheckConstraintViolated, + }, { Query: "select str from e;", Expected: []sql.Row{{"e_data"}}, }, + { + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'e' and index_name = 'idx_label';", + Expected: []sql.Row{{"idx_label", 1, 1, "label", "YES", "BTREE", ""}}, + }, + { + Query: "select str from e where label = 'e_label';", + Expected: []sql.Row{{"e_data"}}, + }, + { + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'e' and index_name = 'idx_composite' order by seq_in_index;", + // Two rows verify both column positions of the composite index were remapped correctly. + Expected: []sql.Row{{"idx_composite", 1, 1, "label", "YES", "BTREE", ""}, {"idx_composite", 1, 2, "tag", "YES", "BTREE", ""}}, + }, + { + Query: "select str from e where label = 'e_label' and tag = 'e_tag';", + Expected: []sql.Row{{"e_data"}}, + }, + { + Query: "select val from kl;", + Expected: []sql.Row{{"kl_data"}}, + }, + { + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'kl' and index_name = 'idx_val';", + Expected: []sql.Row{{"idx_val", 1, 1, "val", "YES", "BTREE", ""}}, + }, + { + Query: "select val from kl where val = 'kl_data';", + Expected: []sql.Row{{"kl_data"}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') preserves foreign key constraints on untracked tables", + SetUpScript: []string{ + "call dolt_branch('empty');", + "create table parent (id int primary key, name varchar(64));", + "create table child (id int primary key, parent_id int, constraint fk_parent foreign key (parent_id) references parent(id));", + "insert into parent values (1, 'alice');", + "insert into child values (10, 1);", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'empty');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select id, name from parent;", + Expected: []sql.Row{{1, "alice"}}, + }, + { + Query: "select id, parent_id from child;", + Expected: []sql.Row{{10, 1}}, + }, + { + Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Expected: []sql.Row{{"fk_parent", "child"}}, + }, + { + Query: "insert into child values (99, 999);", + ExpectedErr: sql.ErrForeignKeyChildViolation, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') preserves FK on untracked child when parent is committed on target", + SetUpScript: []string{ + "call dolt_checkout('-b', 'feat');", + "create table parent (id int primary key, name varchar(64));", + "insert into parent values (1, 'alice'), (2, 'bob');", + "call dolt_commit('-Am', 'add parent on feat');", + "call dolt_checkout('main');", + // A local copy of parent is needed for the FK definition; the committed version wins after reset. + "create table parent (id int primary key, name varchar(64));", + "create table child (id int primary key, parent_id int, constraint fk_parent foreign key (parent_id) references parent(id));", + "insert into parent values (1, 'alice'), (2, 'bob');", + "insert into child values (10, 1), (20, 2);", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'feat');", + Expected: []sql.Row{{0}}, + }, + { + Query: "select id, name from parent order by id;", + // The committed parent from feat replaces the untracked local copy. + Expected: []sql.Row{{1, "alice"}, {2, "bob"}}, + }, + { + Query: "select id, parent_id from child order by id;", + Expected: []sql.Row{{10, 1}, {20, 2}}, + }, + { + Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Expected: []sql.Row{{"fk_parent", "child"}}, + }, + { + Query: "insert into child values (99, 999);", + ExpectedErr: sql.ErrForeignKeyChildViolation, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_reset('--hard') preserves untracked table schema properties", + SetUpScript: []string{ + "call dolt_branch('empty');", + "create table ai (id int auto_increment primary key, val varchar(64));", + "insert into ai (val) values ('row1'), ('row2');", + "create table meta (id int primary key) comment 'my comment' collate utf8mb4_general_ci;", + "create table defaults_tbl (id int primary key, score int default 99, label varchar(64) default 'n/a');", + "insert into defaults_tbl (id) values (1);", + "create table gen (id int primary key, base int, doubled int generated always as (base * 2));", + "insert into gen (id, base) values (1, 7);", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_reset('--hard', 'empty');", + Expected: []sql.Row{{0}}, + }, + { + Query: "insert into ai (val) values ('row3');", + // InsertID=3 confirms the auto-increment counter was not reset to 1 after the move. + Expected: []sql.Row{{types.OkResult{RowsAffected: 1, InsertID: 3}}}, + }, + { + Query: "select id from ai where val = 'row3';", + Expected: []sql.Row{{3}}, + }, + { + Query: "select table_comment from information_schema.tables where table_schema = database() and table_name = 'meta';", + Expected: []sql.Row{{"my comment"}}, + }, + { + Query: "select table_collation from information_schema.tables where table_schema = database() and table_name = 'meta';", + Expected: []sql.Row{{"utf8mb4_general_ci"}}, + }, + { + Query: "select score, label from defaults_tbl where id = 1;", + Expected: []sql.Row{{99, "n/a"}}, + }, + { + Query: "insert into defaults_tbl (id) values (2);", + Expected: []sql.Row{{types.OkResult{RowsAffected: 1}}}, + }, + { + Query: "select score, label from defaults_tbl where id = 2;", + Expected: []sql.Row{{99, "n/a"}}, + }, + { + Query: "select doubled from gen where id = 1;", + Expected: []sql.Row{{14}}, + }, }, }, { From e6ad5a13bf54db3e9ecfe3e8f6998d8fdd1b17d2 Mon Sep 17 00:00:00 2001 From: elianddb Date: Tue, 12 May 2026 23:14:05 +0000 Subject: [PATCH 04/23] refactor(schema): centralize column tag updates in WithUpdatedColumnTags --- go/libraries/doltcore/schema/schema_impl.go | 125 +++++++++----------- 1 file changed, 54 insertions(+), 71 deletions(-) diff --git a/go/libraries/doltcore/schema/schema_impl.go b/go/libraries/doltcore/schema/schema_impl.go index f6e24b48ae0..7e262a76087 100644 --- a/go/libraries/doltcore/schema/schema_impl.go +++ b/go/libraries/doltcore/schema/schema_impl.go @@ -131,36 +131,54 @@ func SchemaFromCols(allCols *ColCollection) (Schema, error) { return sch, nil } -// RebuildWithColumns returns a copy of |sch| that uses |cols| as its column set. -// All schema-level metadata (comment, collation, primary key order, target row size, -// content-hashed fields) is preserved. Secondary index tag references are remapped to -// match any tag changes present in |cols| relative to |sch|. Check constraints are -// preserved unchanged because they reference column names, not tags. -func RebuildWithColumns(sch Schema, cols []Column) (Schema, error) { - tagRemap := buildColumnTagRemap(sch.GetAllCols().GetColumns(), cols) - - // Copy preserves all schema-level metadata fields including comment and - // contentHashedFields which are not exposed on the Schema interface. - cp := sch.Copy().(*schemaImpl) - - newColColl := NewColCollection(cols...) - var pkCols, nonPKCols []Column - for _, col := range cols { - if col.IsPartOfPK { - pkCols = append(pkCols, col) - } else { - nonPKCols = append(nonPKCols, col) +// WithUpdatedColumnTags returns a copy of |sch| with column and index tags remapped +// according to |tagRemap| (old tag -> new tag). All other schema-level metadata +// (comment, collation, primary key order, target row size) is preserved. Check +// constraints are preserved unchanged because they reference column names, not tags. +func WithUpdatedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, error) { + if len(tagRemap) == 0 { + return sch, nil + } + + allCols := sch.GetAllCols() + cols := allCols.GetColumns() + for oldTag, newTag := range tagRemap { + if idx, ok := allCols.TagToIdx[oldTag]; ok { + cols[idx].Tag = newTag } } - cp.allCols = newColColl - cp.pkCols = NewColCollection(pkCols...) - cp.nonPKCols = NewColCollection(nonPKCols...) - cp.indexCollection = NewIndexCollection(newColColl, cp.pkCols) - err := sch.Indexes().Iter(func(idx Index) (stop bool, err error) { + pkOrdinals := append([]int{}, sch.GetPkOrdinals()...) + newSch, err := NewSchema(NewColCollection(cols...), pkOrdinals, sch.GetCollation(), nil, nil) + if err != nil { + return nil, err + } + newSch.SetTargetRowSize(sch.GetTargetRowSize()) + newSch.SetComment(sch.GetComment()) + + affected := make(map[string]struct{}) + for oldTag := range tagRemap { + for _, idx := range sch.Indexes().IndexesWithTag(oldTag) { + affected[idx.Name()] = struct{}{} + } + } + + err = sch.Indexes().Iter(func(idx Index) (stop bool, err error) { + tags := idx.IndexedColumnTags() + if _, ok := affected[idx.Name()]; ok { + remapped := make([]uint64, len(tags)) + for i, t := range tags { + if newTag, ok := tagRemap[t]; ok { + remapped[i] = newTag + } else { + remapped[i] = t + } + } + tags = remapped + } props := IndexProperties{ IsUnique: idx.IsUnique(), - IsSpatial: idx.IsSpatial(), + IsSpatial: idx.IsSpatial(), IsFullText: idx.IsFullText(), IsUserDefined: idx.IsUserDefined(), Comment: idx.Comment(), @@ -168,64 +186,29 @@ func RebuildWithColumns(sch Schema, cols []Column) (Schema, error) { IsVector: idx.IsVector(), VectorProperties: idx.VectorProperties(), } - _, err = cp.indexCollection.AddIndexByColTags(idx.Name(), remapColumnTags(idx.IndexedColumnTags(), tagRemap), idx.PrefixLengths(), props) + _, err = newSch.Indexes().AddIndexByColTags(idx.Name(), tags, idx.PrefixLengths(), props) return false, err }) if err != nil { return nil, err } - return cp, nil -} - -// UpdateColumnTag returns a copy of |sch| with the tag of the column named |name| set to |tag|. -func UpdateColumnTag(sch Schema, name string, tag uint64) (Schema, error) { - columns := sch.GetAllCols().GetColumns() - var found bool - for i, col := range columns { - if col.Name == name { - col.Tag = tag - columns[i] = col - found = true - break + for _, chk := range sch.Checks().AllChecks() { + if _, err = newSch.Checks().AddCheck(chk.Name(), chk.Expression(), chk.Enforced()); err != nil { + return nil, err } } - if !found { - return nil, fmt.Errorf("column %s does not exist", name) - } - return RebuildWithColumns(sch, columns) -} -// buildColumnTagRemap returns a map from old tag to new tag for every position where -// origCols[i].Tag differs from newCols[i].Tag. -func buildColumnTagRemap(origCols, newCols []Column) map[uint64]uint64 { - var remap map[uint64]uint64 - for i, col := range newCols { - if i < len(origCols) && origCols[i].Tag != col.Tag { - if remap == nil { - remap = make(map[uint64]uint64) - } - remap[origCols[i].Tag] = col.Tag - } - } - return remap + return newSch, nil } -// remapColumnTags returns a copy of |tags| with each entry replaced by its value in -// |remap|, falling back to the original tag when no mapping exists. -// Returns |tags| unchanged when |remap| is nil or empty. -func remapColumnTags(tags []uint64, remap map[uint64]uint64) []uint64 { - if len(remap) == 0 { - return tags - } - out := make([]uint64, len(tags)) - for i, t := range tags { - out[i] = t - if r, ok := remap[t]; ok { - out[i] = r - } +// UpdateColumnTag returns a copy of |sch| with the tag of the column named |name| set to |tag|. +func UpdateColumnTag(sch Schema, name string, tag uint64) (Schema, error) { + col, ok := sch.GetAllCols().GetByName(name) + if !ok { + return nil, fmt.Errorf("column %s does not exist", name) } - return out + return WithUpdatedColumnTags(sch, map[uint64]uint64{col.Tag: tag}) } // SchemaFromColCollections creates a schema from the three collections. From f65cc1e758604fac9ed38071bc77e3a2d6615ab9 Mon Sep 17 00:00:00 2001 From: elianddb Date: Tue, 12 May 2026 23:14:30 +0000 Subject: [PATCH 05/23] fix: preserve and retag untracked tables across root transitions --- go/libraries/doltcore/env/actions/checkout.go | 121 +++++++++++++++++- go/libraries/doltcore/env/actions/reset.go | 101 ++++++++------- 2 files changed, 172 insertions(+), 50 deletions(-) diff --git a/go/libraries/doltcore/env/actions/checkout.go b/go/libraries/doltcore/env/actions/checkout.go index fa3992e7d60..ccda70861e4 100644 --- a/go/libraries/doltcore/env/actions/checkout.go +++ b/go/libraries/doltcore/env/actions/checkout.go @@ -16,6 +16,7 @@ package actions import ( "context" + "sort" "strings" "time" @@ -119,8 +120,45 @@ func FindTableInRoots(ctx *sql.Context, roots doltdb.Roots, name string) (doltdb return doltdb.TableName{}, nil, false, nil } -// RootsForBranch returns the roots needed for a branch checkout. |roots.Head| should be the pre-checkout head. The -// returned roots struct has |Head| set to |branchRoot|. +// CheckUntrackedConflicts returns ErrCheckoutWouldOverwrite if any table that is untracked +// in |roots| (present in Working but absent from Head) would be overwritten by a committed +// table of the same name on |targetRoot|. +func CheckUntrackedConflicts(ctx context.Context, roots doltdb.Roots, targetRoot doltdb.RootValue) error { + workingSchemas, err := doltdb.GetAllSchemas(ctx, roots.Working) + if err != nil { + return err + } + headSchemas, err := doltdb.GetAllSchemas(ctx, roots.Head) + if err != nil { + return err + } + + var conflicts []string + for name := range workingSchemas { + if _, isTracked := headSchemas[name]; isTracked { + continue + } + h, _, err := targetRoot.GetTableHash(ctx, name) + if err != nil { + return err + } + if !h.IsEmpty() { + conflicts = append(conflicts, name.Name) + } + } + + if len(conflicts) > 0 { + sort.Strings(conflicts) + return ErrCheckoutWouldOverwrite{conflicts} + } + return nil +} + +// RootsForBranch returns the roots needed for a branch checkout. |roots.Head| should be the +// pre-checkout head. The returned roots struct has |Head| set to |branchRoot|. +// +// Untracked tables (present in working or staged but absent from the old Head) are moved into +// the new root via [MoveUntrackedTables], which resolves any column tag collisions. func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.RootValue, force bool) (doltdb.Roots, error) { conflicts := doltdb.NewTableNameSet(nil) if roots.Head == nil { @@ -130,6 +168,11 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return roots, nil } + // Snapshot roots before the tracked-table merge so MoveUntrackedTables below has the original sources. + preCheckoutWorking := roots.Working + preCheckoutStaged := roots.Staged + preCheckoutHead := roots.Head + wrkTblHashes, err := moveModifiedTables(ctx, roots.Head, branchRoot, roots.Working, conflicts, force) if err != nil { return doltdb.Roots{}, err @@ -144,12 +187,12 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, ErrCheckoutWouldOverwrite{conflicts.AsStringSlice()} } - workingForeignKeys, err := moveForeignKeys(ctx, roots.Head, branchRoot, roots.Working, force) + workingForeignKeys, err := MoveForeignKeys(ctx, roots.Head, branchRoot, roots.Working, force) if err != nil { return doltdb.Roots{}, err } - stagedForeignKeys, err := moveForeignKeys(ctx, roots.Head, branchRoot, roots.Staged, force) + stagedForeignKeys, err := MoveForeignKeys(ctx, roots.Head, branchRoot, roots.Staged, force) if err != nil { return doltdb.Roots{}, err } @@ -164,11 +207,28 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } + // MoveUntrackedTables must run before PutForeignKeyCollection so remaps are available. + var workingRemaps, stagedRemaps map[doltdb.TableName]map[uint64]uint64 + roots.Working, workingRemaps, err = MoveUntrackedTables(ctx, preCheckoutWorking, preCheckoutHead, roots.Working) + if err != nil { + return doltdb.Roots{}, err + } + roots.Staged, stagedRemaps, err = MoveUntrackedTables(ctx, preCheckoutStaged, preCheckoutHead, roots.Staged) + if err != nil { + return doltdb.Roots{}, err + } + + if err = ApplyForeignKeyTagRemaps(workingForeignKeys, workingRemaps); err != nil { + return doltdb.Roots{}, err + } roots.Working, err = roots.Working.PutForeignKeyCollection(ctx, workingForeignKeys) if err != nil { return doltdb.Roots{}, err } + if err = ApplyForeignKeyTagRemaps(stagedForeignKeys, stagedRemaps); err != nil { + return doltdb.Roots{}, err + } roots.Staged, err = roots.Staged.PutForeignKeyCollection(ctx, stagedForeignKeys) if err != nil { return doltdb.Roots{}, err @@ -326,8 +386,9 @@ func moveModifiedTables(ctx context.Context, oldRoot, newRoot, changedRoot doltd return nil, err } + // Untracked tables are carried forward by MoveUntrackedTables instead. if oldHash == emptyHash { - resultMap[tblName] = changedHash + continue } else if force { resultMap[tblName] = oldHash } else if oldHash != changedHash { @@ -388,8 +449,8 @@ func CheckOverwrittenIgnoredTables(ctx context.Context, roots doltdb.Roots, bran return nil } -// moveForeignKeys returns the foreign key collection that should be used for the new working set. -func moveForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, force bool) (*doltdb.ForeignKeyCollection, error) { +// MoveForeignKeys returns the foreign key collection that should be used for the new working set. +func MoveForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, force bool) (*doltdb.ForeignKeyCollection, error) { oldFks, err := oldRoot.GetForeignKeyCollection(ctx) if err != nil { return nil, err @@ -431,6 +492,52 @@ func moveForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.R } } +// ApplyForeignKeyTagRemaps updates column tags in |fks| for any foreign key whose child or parent +// table appears in |remaps|. It is called after MoveUntrackedTables to fix up tags that were +// reassigned to avoid collisions during the table copy. +func ApplyForeignKeyTagRemaps(fks *doltdb.ForeignKeyCollection, remaps map[doltdb.TableName]map[uint64]uint64) error { + if len(remaps) == 0 { + return nil + } + + remapSlice := func(tags []uint64, remap map[uint64]uint64) []uint64 { + if len(remap) == 0 { + return tags + } + out := make([]uint64, len(tags)) + for i, t := range tags { + if newTag, ok := remap[t]; ok { + out[i] = newTag + } else { + out[i] = t + } + } + return out + } + + var toUpdate []doltdb.ForeignKey + err := fks.Iter(func(fk doltdb.ForeignKey) (stop bool, err error) { + if len(remaps[fk.TableName]) == 0 && len(remaps[fk.ReferencedTableName]) == 0 { + return false, nil + } + toUpdate = append(toUpdate, fk) + return false, nil + }) + if err != nil { + return err + } + + for _, fk := range toUpdate { + fks.RemoveKeyByName(fk.Name, fk.TableName) + fk.TableColumns = remapSlice(fk.TableColumns, remaps[fk.TableName]) + fk.ReferencedTableColumns = remapSlice(fk.ReferencedTableColumns, remaps[fk.ReferencedTableName]) + if err = fks.AddKeys(fk); err != nil { + return err + } + } + return nil +} + // mergeForeignKeyChanges merges the foreign key changes from the old and changed roots into a new foreign key // collection, or returns an error if the changes are incompatible. Changes are incompatible if the changed root // and new root both altered foreign keys on the same table. diff --git a/go/libraries/doltcore/env/actions/reset.go b/go/libraries/doltcore/env/actions/reset.go index 7eb5632ce3c..98b14f6ae9a 100644 --- a/go/libraries/doltcore/env/actions/reset.go +++ b/go/libraries/doltcore/env/actions/reset.go @@ -64,7 +64,18 @@ func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr str } } - newWorking, err := MoveUntrackedTables(ctx, roots.Working, roots.Staged, roots.Head) + fks, err := MoveForeignKeys(ctx, roots.Head, roots.Head, roots.Working, false) + if err != nil { + return nil, doltdb.Roots{}, err + } + newWorking, remaps, err := MoveUntrackedTables(ctx, roots.Working, roots.Staged, roots.Head) + if err != nil { + return nil, doltdb.Roots{}, err + } + if err = ApplyForeignKeyTagRemaps(fks, remaps); err != nil { + return nil, doltdb.Roots{}, err + } + newWorking, err = newWorking.PutForeignKeyCollection(ctx, fks) if err != nil { return nil, doltdb.Roots{}, err } @@ -74,86 +85,90 @@ func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr str // MoveUntrackedTables copies tables that are in |sourceWorking| but not in |sourceStaged| // into |target| and returns the updated root. If a table shares a name with one already in // |target|, the version in |target| is kept. If a table has column tags that conflict with -// those in |target|, the conflicting tags are replaced before the table is copied. -func MoveUntrackedTables(ctx context.Context, sourceWorking, sourceStaged, target doltdb.RootValue) (doltdb.RootValue, error) { +// those in |target|, the conflicting tags are replaced before the table is copied. Foreign +// key constraints declared on moved tables are also transferred to |target|, with column +// tags updated to match any retagging that was applied. +// +// This is the shared implementation used by both dolt reset --hard ([ResetHardTables]) and +// dolt checkout ([RootsForBranch]) to carry untracked tables across a root transition. +func MoveUntrackedTables(ctx context.Context, sourceWorking, sourceStaged, target doltdb.RootValue) (doltdb.RootValue, map[doltdb.TableName]map[uint64]uint64, error) { untracked, err := doltdb.GetAllSchemas(ctx, sourceWorking) if err != nil { - return nil, err + return nil, nil, err } - staged, err := doltdb.GetAllSchemas(ctx, sourceStaged) + stagedNames, err := sourceStaged.GetAllTableNames(ctx, false) if err != nil { - return nil, err + return nil, nil, err } - for name := range staged { + for _, name := range stagedNames { delete(untracked, name) } targetSchemas, err := doltdb.GetAllSchemas(ctx, target) if err != nil { - return nil, err + return nil, nil, err } - // occupiedTags starts with all column tags in |target| and grows as each untracked - // table is processed so no two copied tables end up sharing a tag. - occupiedTags := make(schema.TagMapping) + // heldTags starts with all column tags in |target| and grows as each untracked + // table is processed so no two moved tables end up sharing a tag. + heldTags := make(schema.TagMapping) for tblName, tsch := range targetSchemas { for _, t := range tsch.GetAllCols().Tags { - occupiedTags.Add(t, tblName.Name) + heldTags.Add(t, tblName.Name) } } + allTagRemaps := make(map[doltdb.TableName]map[uint64]uint64) for name, sch := range untracked { if _, exists := targetSchemas[name]; exists { continue } tbl, exists, err := sourceWorking.GetTable(ctx, name) if err != nil { - return nil, err + return nil, nil, err } if !exists { - return nil, fmt.Errorf("untracked table %s does not exist in working set", name) + return nil, nil, fmt.Errorf("untracked table %s does not exist in working set", name) } - tbl, err = retagCollidingColumns(ctx, tbl, sch, name.Name, occupiedTags) - if err != nil { - return nil, err + tagRemap := collidingTagRemap(sch, name.Name, heldTags) + if len(tagRemap) > 0 { + allTagRemaps[name] = tagRemap + newSch, err := schema.WithUpdatedColumnTags(sch, tagRemap) + if err != nil { + return nil, nil, err + } + tbl, err = tbl.UpdateSchema(ctx, newSch) + if err != nil { + return nil, nil, err + } } target, err = target.PutTable(ctx, name, tbl) if err != nil { - return nil, fmt.Errorf("failed to write table back to database: %w", err) + return nil, nil, fmt.Errorf("failed to write table back to database: %w", err) } } - return target, nil + return target, allTagRemaps, nil } -// retagCollidingColumns returns |tbl| with any column tag that conflicts with |occupiedTags| -// replaced by a new unique tag. |tableName| is passed to the tag generator as a seed input. -// Every column's final tag (original or replacement) is registered in |occupiedTags| so that -// subsequent calls in the same pass never collide with any tag belonging to this table. -func retagCollidingColumns(ctx context.Context, tbl *doltdb.Table, sch schema.Schema, tableName string, occupiedTags schema.TagMapping) (*doltdb.Table, error) { +// collidingTagRemap scans |sch| for column tags that collide with |heldTags| and returns +// a map of old tag to new tag for each colliding column. The map is empty when no columns +// collide. |heldTags| is updated in place with every column's final tag so that no two +// tables moved in sequence end up sharing a tag. |tableName| seeds the tag generator. +func collidingTagRemap(sch schema.Schema, tableName string, heldTags schema.TagMapping) map[uint64]uint64 { columns := sch.GetAllCols().GetColumns() - var retagged bool - for idx, column := range columns { - if occupiedTags.Contains(column.Tag) { - precedingKinds := make([]types.NomsKind, idx) - for j, preceding := range columns[:idx] { - precedingKinds[j] = preceding.Kind - } - column.Tag = schema.AutoGenerateTag(occupiedTags, tableName, precedingKinds, column.Name, column.Kind) - columns[idx] = column - retagged = true + tagRemap := make(map[uint64]uint64) + priorKinds := make([]types.NomsKind, 0, len(columns)) + for _, column := range columns { + if heldTags.Contains(column.Tag) { + tagRemap[column.Tag] = schema.AutoGenerateTag(heldTags, tableName, priorKinds, column.Name, column.Kind) + column.Tag = tagRemap[column.Tag] } - occupiedTags.Add(column.Tag, tableName) - } - if !retagged { - return tbl, nil - } - newSch, err := schema.RebuildWithColumns(sch, columns) - if err != nil { - return nil, err + priorKinds = append(priorKinds, column.Kind) + heldTags.Add(column.Tag, tableName) } - return tbl.UpdateSchema(ctx, newSch) + return tagRemap } func GetAllTableNames(ctx context.Context, root doltdb.RootValue) []doltdb.TableName { From 4d9e88b70509153734166657695d793a95e7a770 Mon Sep 17 00:00:00 2001 From: elianddb Date: Tue, 12 May 2026 23:14:36 +0000 Subject: [PATCH 06/23] fix: carry untracked tables on sql checkout and rebase --- go/cmd/dolt/commands/schcmds/update-tag.go | 1 - .../sqle/dprocedures/dolt_checkout.go | 63 ++++++++++++++++--- .../sqle/dprocedures/dolt_checkout_helpers.go | 4 ++ .../doltcore/sqle/dprocedures/dolt_rebase.go | 13 +++- .../dprocedures/dolt_update_column_tag.go | 1 - .../doltcore/sqle/enginetest/dolt_queries.go | 6 +- 6 files changed, 73 insertions(+), 15 deletions(-) diff --git a/go/cmd/dolt/commands/schcmds/update-tag.go b/go/cmd/dolt/commands/schcmds/update-tag.go index e8152f897eb..1cad8978ff7 100644 --- a/go/cmd/dolt/commands/schcmds/update-tag.go +++ b/go/cmd/dolt/commands/schcmds/update-tag.go @@ -120,4 +120,3 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri return commands.HandleVErrAndExitCode(nil, usage) } - diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go index a4c3947df3e..d3ec49103f8 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go @@ -550,15 +550,20 @@ func checkoutExistingBranch( return err } - if !overwriteIgnore { - if currentRoots, hasRoots := dSess.GetRoots(ctx, dbName); hasRoots { - branchHead, err := actions.BranchHeadRoot(ctx, ddb, branchName) - if err != nil { - return err - } - if err := actions.CheckOverwrittenIgnoredTables(ctx, currentRoots, branchHead, overwriteIgnore); err != nil { - return err - } + currentRoots, hasCurrentRoots := dSess.GetRoots(ctx, dbName) + + if hasCurrentRoots { + branchHead, err := actions.BranchHeadRoot(ctx, ddb, branchName) + if err != nil { + return err + } + + if err := actions.CheckOverwrittenIgnoredTables(ctx, currentRoots, branchHead, overwriteIgnore); err != nil { + return err + } + + if err := actions.CheckUntrackedConflicts(ctx, currentRoots, branchHead); err != nil { + return err } } @@ -566,6 +571,46 @@ func checkoutExistingBranch( if err != nil { return err } + + if hasCurrentRoots { + ws, err := dSess.WorkingSet(ctx, dbName) + if err != nil { + return err + } + workingFKs, err := actions.MoveForeignKeys(ctx, currentRoots.Head, ws.WorkingRoot(), currentRoots.Working, false) + if err != nil { + return err + } + stagedFKs, err := actions.MoveForeignKeys(ctx, currentRoots.Head, ws.StagedRoot(), currentRoots.Staged, false) + if err != nil { + return err + } + newWorking, workingRemaps, err := actions.MoveUntrackedTables(ctx, currentRoots.Working, currentRoots.Head, ws.WorkingRoot()) + if err != nil { + return err + } + newStaged, stagedRemaps, err := actions.MoveUntrackedTables(ctx, currentRoots.Staged, currentRoots.Head, ws.StagedRoot()) + if err != nil { + return err + } + if err = actions.ApplyForeignKeyTagRemaps(workingFKs, workingRemaps); err != nil { + return err + } + if err = actions.ApplyForeignKeyTagRemaps(stagedFKs, stagedRemaps); err != nil { + return err + } + newWorking, err = newWorking.PutForeignKeyCollection(ctx, workingFKs) + if err != nil { + return err + } + newStaged, err = newStaged.PutForeignKeyCollection(ctx, stagedFKs) + if err != nil { + return err + } + if err = dSess.SetWorkingSet(ctx, dbName, ws.WithWorkingRoot(newWorking).WithStagedRoot(newStaged)); err != nil { + return err + } + } } return nil diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go index c1259431782..7eadd599af0 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go @@ -110,6 +110,10 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return err } + if err := actions.CheckUntrackedConflicts(ctx, initialRoots, branchHead); err != nil { + return err + } + // Only if the current working set has uncommitted changes do we carry them forward to the branch being checked out. // If this is the case, then the destination branch must *not* have any uncommitted changes, as checked by // checkoutWouldStompWorkingSetChanges diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go index c42eb4eb9c8..3e0283c16e1 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go @@ -809,7 +809,18 @@ func continueRebase(ctx *sql.Context) rebaseResult { if err != nil { return newRebaseError(err) } - restoredWorking, err := actions.MoveUntrackedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot()) + fks, err := actions.MoveForeignKeys(ctx, preRebaseStagedRoot, postCopyWS.WorkingRoot(), preRebaseWorkingRoot, false) + if err != nil { + return newRebaseError(err) + } + restoredWorking, remaps, err := actions.MoveUntrackedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot()) + if err != nil { + return newRebaseError(err) + } + if err = actions.ApplyForeignKeyTagRemaps(fks, remaps); err != nil { + return newRebaseError(err) + } + restoredWorking, err = restoredWorking.PutForeignKeyCollection(ctx, fks) if err != nil { return newRebaseError(err) } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go index d4696f09373..ec78f7e5ea9 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go @@ -100,4 +100,3 @@ func parseUpdateColumnTagArgs(args ...string) (tableName, columnName string, tag return tableName, columnName, tag, nil } - diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index 9395c3aec93..f5794aa18fb 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -4058,7 +4058,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, }, { - Query: "select id, name from parent order by id;", + Query: "select id, name from parent order by id;", // The committed parent from feat replaces the untracked local copy. Expected: []sql.Row{{1, "alice"}, {2, "bob"}}, }, @@ -5014,7 +5014,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ Expected: []sql.Row{{0}}, }, { - Query: "select id, name from parent order by id;", + Query: "select id, name from parent order by id;", // The committed parent from feat replaces the untracked local copy. Expected: []sql.Row{{1, "alice"}, {2, "bob"}}, }, @@ -5103,7 +5103,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ Expected: []sql.Row{{0}}, }, { - Query: "select code from overlap;", + Query: "select code from overlap;", // The target branch version wins over the untracked copy. Expected: []sql.Row{{"feat_data"}}, }, From 54457950917fdde63dbaf3a5cb25738eca0da13a Mon Sep 17 00:00:00 2001 From: elianddb Date: Wed, 13 May 2026 21:14:14 +0000 Subject: [PATCH 07/23] test: cover uncommitted-table carry across checkout and reset --- .../doltcore/env/actions/carry_tables_test.go | 238 ++++++++++++++++++ .../doltcore/env/actions/reset_test.go | 142 ----------- .../doltcore/sqle/enginetest/dolt_queries.go | 123 ++++++++- .../sqle/enginetest/dolt_queries_dtables.go | 14 +- .../sqle/enginetest/dolt_queries_nonlocal.go | 6 +- integration-tests/bats/checkout.bats | 38 +++ integration-tests/bats/reset.bats | 22 ++ 7 files changed, 421 insertions(+), 162 deletions(-) create mode 100644 go/libraries/doltcore/env/actions/carry_tables_test.go delete mode 100644 go/libraries/doltcore/env/actions/reset_test.go diff --git a/go/libraries/doltcore/env/actions/carry_tables_test.go b/go/libraries/doltcore/env/actions/carry_tables_test.go new file mode 100644 index 00000000000..62d4df80f0c --- /dev/null +++ b/go/libraries/doltcore/env/actions/carry_tables_test.go @@ -0,0 +1,238 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed 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 actions + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/dolt/go/libraries/doltcore/schema" + "github.com/dolthub/dolt/go/store/types" +) + +const ( + barCodeTag uint64 = 9815 + usersNameTag uint64 = 9815 // deliberate collision with barCodeTag + postsTitleTag uint64 = 13593 +) + +// newEmptyRoot returns a context and the empty working root of a freshly initialised repo. +func newEmptyRoot(t *testing.T) (context.Context, doltdb.RootValue) { + dEnv, _ := createTestEnv() + ctx := context.Background() + require.NoError(t, dEnv.InitRepo(ctx, types.Format_Default, "test user", "test@test.com", "main")) + emptyRoot, err := dEnv.WorkingRoot(ctx) + require.NoError(t, err) + return ctx, emptyRoot +} + +// singleColPkSchema builds a one-column primary key schema using |colName| and |tag|. +func singleColPkSchema(t *testing.T, colName string, tag uint64) schema.Schema { + col := schema.NewColumn(colName, tag, types.StringKind, true, schema.NotNullConstraint{}) + sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) + require.NoError(t, err) + require.NoError(t, sch.SetPkOrdinals([]int{0})) + return sch +} + +// TestCarryUncommittedTablesPreexistingFK verifies that CarryUncommittedTables does not return an error +// when the target foreign key collection already contains a key being carried. +func TestCarryUncommittedTablesPreexistingFK(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 + ctx, emptyRoot := newEmptyRoot(t) + + // Use non-colliding tags so no retag happens. This isolates the duplicate-FK guard. + const parentTag uint64 = 50001 + const childTag uint64 = 50002 + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPkSchema(t, "id", parentTag)) + require.NoError(t, err) + + working := emptyRoot + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPkSchema(t, "pid", childTag)) + require.NoError(t, err) + + fks, err := doltdb.NewForeignKeyCollection(doltdb.ForeignKey{ + Name: "fk_pre", + TableName: doltdb.TableName{Name: "child"}, + TableColumns: []uint64{childTag}, + ReferencedTableName: doltdb.TableName{Name: "parent"}, + ReferencedTableColumns: []uint64{parentTag}, + }) + require.NoError(t, err) + working, err = working.PutForeignKeyCollection(ctx, fks) + require.NoError(t, err) + + // Simulate the FK pre-merge that RootsForBranch performs before calling CarryUncommittedTables. + target, err = target.PutForeignKeyCollection(ctx, fks) + require.NoError(t, err) + + result, err := CarryUncommittedTables(ctx, working, emptyRoot, target) + require.NoError(t, err) + + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_pre", doltdb.TableName{Name: "child"}) + require.True(t, ok, "fk_pre must be present in result FK collection") + require.Equal(t, []uint64{childTag}, got.TableColumns) +} + +// TestCarryUncommittedTablesParentTagMismatch verifies that a foreign key carried from source onto +// target gets its referenced column tags rewritten to match the target parent schema when the +// same parent column has a different tag on source and target. +func TestCarryUncommittedTablesParentTagMismatch(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 + ctx, emptyRoot := newEmptyRoot(t) + + const parentTagOnSource uint64 = 60001 + const parentTagOnTarget uint64 = 60002 + const childTag uint64 = 60003 + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPkSchema(t, "id", parentTagOnTarget)) + require.NoError(t, err) + + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPkSchema(t, "id", parentTagOnSource)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPkSchema(t, "pid", childTag)) + require.NoError(t, err) + + fks, err := doltdb.NewForeignKeyCollection(doltdb.ForeignKey{ + Name: "fk_cross", + TableName: doltdb.TableName{Name: "child"}, + TableColumns: []uint64{childTag}, + ReferencedTableName: doltdb.TableName{Name: "parent"}, + ReferencedTableColumns: []uint64{parentTagOnSource}, + }) + require.NoError(t, err) + working, err = working.PutForeignKeyCollection(ctx, fks) + require.NoError(t, err) + + // sourceStaged is emptyRoot so child is untracked and parent (in working) is treated the same way. + // parent already exists in target, so carryTables skips it and only child is carried. + result, err := CarryUncommittedTables(ctx, working, target, target) + require.NoError(t, err) + + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_cross", doltdb.TableName{Name: "child"}) + require.True(t, ok, "fk_cross must be present in result FK collection") + require.Equal(t, []uint64{parentTagOnTarget}, got.ReferencedTableColumns, + "referenced column tag must be rewritten to match target's parent schema") + require.Equal(t, []uint64{childTag}, got.TableColumns, + "child column tag must be unchanged because there was no collision") +} + +// TestCarryUncommittedTables verifies that CarryUncommittedTables carries untracked tables into the +// target root, resolves tag collisions, and propagates foreign keys. +func TestCarryUncommittedTables(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 + ctx, emptyRoot := newEmptyRoot(t) + + withUniqueIndex := func(sch schema.Schema, indexName string, tag uint64) schema.Schema { + _, err := sch.Indexes().AddIndexByColTags(indexName, []uint64{tag}, nil, schema.IndexProperties{IsUserDefined: true, IsUnique: true}) + require.NoError(t, err) + return sch + } + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPkSchema(t, "code", barCodeTag)) + require.NoError(t, err) + + // Source has bar (so the foreign key can reference it), plus untracked users and posts. + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPkSchema(t, "code", barCodeTag)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, withUniqueIndex(singleColPkSchema(t, "name", usersNameTag), "idx_name", usersNameTag)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, singleColPkSchema(t, "title", postsTitleTag)) + require.NoError(t, err) + + fks, err := doltdb.NewForeignKeyCollection(doltdb.ForeignKey{ + Name: "fk_bar", + TableName: doltdb.TableName{Name: "users"}, + TableColumns: []uint64{usersNameTag}, + ReferencedTableName: doltdb.TableName{Name: "bar"}, + ReferencedTableColumns: []uint64{barCodeTag}, + }) + require.NoError(t, err) + working, err = working.PutForeignKeyCollection(ctx, fks) + require.NoError(t, err) + + // sourceStaged is target so bar is treated as already-tracked on source and only users and posts are carried. + result, err := CarryUncommittedTables(ctx, working, target, target) + require.NoError(t, err) + + t.Run("preserves tag on non-collision", func(t *testing.T) { + tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "posts"}) + require.NoError(t, err) + require.True(t, ok, "posts table must be present in the result") + sch, err := tbl.GetSchema(ctx) + require.NoError(t, err) + col, ok := sch.GetAllCols().GetByName("title") + require.True(t, ok) + require.Equal(t, postsTitleTag, col.Tag, "posts.title tag must not change when there is no collision") + }) + + t.Run("WithRemappedColumnTags preserves schema metadata", func(t *testing.T) { + col := schema.NewColumn("val", 100, types.StringKind, true, schema.NotNullConstraint{}) + sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) + require.NoError(t, err) + require.NoError(t, sch.SetPkOrdinals([]int{0})) + sch.SetCollation(schema.Collation_utf8mb4_general_ci) + sch.SetComment("test comment") + sch.SetTargetRowSize(4096) + + remapped, err := schema.WithRemappedColumnTags(sch, map[uint64]uint64{100: 200}) + require.NoError(t, err) + require.Equal(t, schema.Collation_utf8mb4_general_ci, remapped.GetCollation(), "collation must survive WithRemappedColumnTags") + require.Equal(t, "test comment", remapped.GetComment(), "comment must survive WithRemappedColumnTags") + require.Equal(t, uint16(4096), remapped.GetTargetRowSize(), "target row size must survive WithRemappedColumnTags") + }) + + t.Run("retags colliding column", func(t *testing.T) { + // AutoGenerateTag deterministically picks this tag when 9815 is already occupied. + // The value is stable even though Go maps are iterated in random order each test run. + const wantTag uint64 = 12204 + tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) + require.NoError(t, err) + require.True(t, ok, "users table must be present in the result") + sch, err := tbl.GetSchema(ctx) + require.NoError(t, err) + col, ok := sch.GetAllCols().GetByName("name") + require.True(t, ok) + require.Equal(t, wantTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") + + idx, ok := sch.Indexes().GetByNameCaseInsensitive("idx_name") + require.True(t, ok, "idx_name must survive the retag") + require.Equal(t, []uint64{wantTag}, idx.IndexedColumnTags(), "idx_name must reference the retagged column, not the old tag") + require.True(t, idx.IsUnique(), "idx_name is unique and must remain so after retag") + require.True(t, idx.IsUserDefined(), "idx_name is user-defined and must remain so after retag") + }) + + t.Run("FK child column retagged, parent column unchanged", func(t *testing.T) { + // users.name and bar.code share tag 9815 so CarryUncommittedTables retagged users.name. + // Only the child side of the FK changes because bar.code is committed and its tag is not affected by the carry. + const wantTag uint64 = 12204 + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_bar", doltdb.TableName{Name: "users"}) + require.True(t, ok, "fk_bar must be present in result FK collection") + require.Equal(t, []uint64{wantTag}, got.TableColumns, + "child column must be updated to the retagged value") + require.Equal(t, []uint64{barCodeTag}, got.ReferencedTableColumns, + "committed parent column tag must not change") + }) +} diff --git a/go/libraries/doltcore/env/actions/reset_test.go b/go/libraries/doltcore/env/actions/reset_test.go deleted file mode 100644 index a6bf2876cbe..00000000000 --- a/go/libraries/doltcore/env/actions/reset_test.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright 2026 Dolthub, Inc. -// -// Licensed 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 actions - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" - "github.com/dolthub/dolt/go/libraries/doltcore/schema" - "github.com/dolthub/dolt/go/store/types" -) - -const ( - barCodeTag uint64 = 9815 - usersNameTag uint64 = 9815 // deliberate collision with barCodeTag - postsTitleTag uint64 = 13593 -) - -// TestMoveUntrackedTables verifies that MoveUntrackedTables correctly copies untracked tables -// into the target root, retagging any column whose tag collides with an existing tag in the target. -func TestMoveUntrackedTables(t *testing.T) { - // See https://github.com/dolthub/dolt/issues/11007 - dEnv, _ := createTestEnv() - ctx := context.Background() - require.NoError(t, dEnv.InitRepo(ctx, types.Format_Default, "test user", "test@test.com", "main")) - - emptyRoot, err := dEnv.WorkingRoot(ctx) - require.NoError(t, err) - - mkSch := func(colName string, tag uint64) schema.Schema { - col := schema.NewColumn(colName, tag, types.StringKind, true, schema.NotNullConstraint{}) - sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) - require.NoError(t, err) - require.NoError(t, sch.SetPkOrdinals([]int{0})) - return sch - } - - mkSchWithIndex := func(colName string, tag uint64, indexName string) schema.Schema { - sch := mkSch(colName, tag) - _, err := sch.Indexes().AddIndexByColTags(indexName, []uint64{tag}, nil, schema.IndexProperties{IsUserDefined: true, IsUnique: true}) - require.NoError(t, err) - return sch - } - - target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, mkSch("code", barCodeTag)) - require.NoError(t, err) - - working := emptyRoot - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, mkSchWithIndex("name", usersNameTag, "idx_name")) - require.NoError(t, err) - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, mkSch("title", postsTitleTag)) - require.NoError(t, err) - - result, allRemaps, err := MoveUntrackedTables(ctx, working, emptyRoot, target) - require.NoError(t, err) - - t.Run("preserves tag on non-collision", func(t *testing.T) { - tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "posts"}) - require.NoError(t, err) - require.True(t, ok, "posts table must be present in the result") - sch, err := tbl.GetSchema(ctx) - require.NoError(t, err) - col, ok := sch.GetAllCols().GetByName("title") - require.True(t, ok) - require.Equal(t, postsTitleTag, col.Tag, "posts.title tag must not change when there is no collision") - }) - - t.Run("WithUpdatedColumnTags preserves schema metadata", func(t *testing.T) { - col := schema.NewColumn("val", 100, types.StringKind, true, schema.NotNullConstraint{}) - sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) - require.NoError(t, err) - require.NoError(t, sch.SetPkOrdinals([]int{0})) - sch.SetCollation(schema.Collation_utf8mb4_general_ci) - sch.SetComment("test comment") - sch.SetTargetRowSize(4096) - - remapped, err := schema.WithUpdatedColumnTags(sch, map[uint64]uint64{100: 200}) - require.NoError(t, err) - require.Equal(t, schema.Collation_utf8mb4_general_ci, remapped.GetCollation(), "collation must survive WithUpdatedColumnTags") - require.Equal(t, "test comment", remapped.GetComment(), "comment must survive WithUpdatedColumnTags") - require.Equal(t, uint16(4096), remapped.GetTargetRowSize(), "target row size must survive WithUpdatedColumnTags") - }) - - t.Run("retags colliding column", func(t *testing.T) { - // AutoGenerateTag deterministically picks this tag when 9815 is already occupied. - // The value is stable even though Go maps are iterated in random order each test run. - const wantTag uint64 = 12204 - tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) - require.NoError(t, err) - require.True(t, ok, "users table must be present in the result") - sch, err := tbl.GetSchema(ctx) - require.NoError(t, err) - col, ok := sch.GetAllCols().GetByName("name") - require.True(t, ok) - require.Equal(t, wantTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") - - idx, ok := sch.Indexes().GetByNameCaseInsensitive("idx_name") - require.True(t, ok, "idx_name must survive the retag") - require.Equal(t, []uint64{wantTag}, idx.IndexedColumnTags(), "idx_name must reference the retagged column, not the old tag") - require.True(t, idx.IsUnique(), "idx_name is unique and must remain so after retag") - require.True(t, idx.IsUserDefined(), "idx_name is user-defined and must remain so after retag") - }) - - t.Run("ApplyForeignKeyTagRemaps patches child side only when parent is committed", func(t *testing.T) { - // users.name and bar.code share tag 9815 so MoveUntrackedTables retagged users.name. - // The FK links users.name to bar.code so only the child side should change. - const wantTag uint64 = 12204 - - fks, err := doltdb.NewForeignKeyCollection(doltdb.ForeignKey{ - Name: "fk_bar", - TableName: doltdb.TableName{Name: "users"}, - TableColumns: []uint64{usersNameTag}, - ReferencedTableName: doltdb.TableName{Name: "bar"}, - ReferencedTableColumns: []uint64{barCodeTag}, - }) - require.NoError(t, err) - - require.NoError(t, ApplyForeignKeyTagRemaps(fks, allRemaps)) - - got, ok := fks.GetByNameCaseInsensitive("fk_bar", doltdb.TableName{Name: "users"}) - require.True(t, ok) - require.Equal(t, []uint64{wantTag}, got.TableColumns, - "child column must be updated to the retagged value") - require.Equal(t, []uint64{barCodeTag}, got.ReferencedTableColumns, - "committed parent column tag must not change") - }) -} diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index f5794aa18fb..c9d03332d43 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -444,8 +444,9 @@ var DoltScripts = []queries.ScriptTest{ }, Assertions: []queries.ScriptTestAssertion{ { - Query: "SELECT table_name, staged, status, ignored FROM dolt_status_ignored;", - Expected: []sql.Row{{"t", byte(0), "new table", false}}, + Query: "SELECT table_name, staged, status, ignored FROM dolt_status_ignored;", + // abc was staged on branch1 and carried to main by the checkout, preserving its staged status. + Expected: []sql.Row{{"abc", byte(1), "new table", false}, {"t", byte(0), "new table", false}}, }, { Query: "SELECT * FROM dolt_status_ignored AS OF 'tag1';", @@ -4046,7 +4047,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ "insert into parent values (1, 'alice'), (2, 'bob');", "call dolt_commit('-Am', 'add parent on feat');", "call dolt_checkout('main');", - // A local copy of parent is needed for the FK definition; the committed version wins after checkout. + // A local copy of parent is needed for the FK definition. The committed version wins after checkout. "create table parent (id int primary key, name varchar(64));", "create table child (id int primary key, parent_id int, constraint fk_parent foreign key (parent_id) references parent(id));", "insert into parent values (1, 'alice'), (2, 'bob');", @@ -4084,7 +4085,6 @@ var DoltCheckoutScripts = []queries.ScriptTest{ "create table conflict_tbl (id int primary key, val int);", "call dolt_commit('-Am', 'add conflict_tbl on feat');", "call dolt_checkout('--move', 'main');", - // conflict_tbl is now an untracked table on main (not committed to main) "create table conflict_tbl (id int primary key);", }, Assertions: []queries.ScriptTestAssertion{ @@ -4093,8 +4093,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", }, { - // active_branch confirms the checkout was aborted before switching branches. - Query: "select active_branch();", + Query: "select active_branch();", + // Still on main because the checkout aborted before switching branches. Expected: []sql.Row{{"main"}}, }, }, @@ -4107,7 +4107,6 @@ var DoltCheckoutScripts = []queries.ScriptTest{ "create table conflict_tbl (id int primary key, val int);", "call dolt_commit('-Am', 'add conflict_tbl on feat');", "call dolt_checkout('--move', 'main');", - // conflict_tbl is staged on main but not yet committed "create table conflict_tbl (id int primary key);", "call dolt_add('conflict_tbl');", }, @@ -4117,8 +4116,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", }, { - // active_branch confirms the checkout was aborted before switching branches. - Query: "select active_branch();", + Query: "select active_branch();", + // Still on main because the checkout aborted before switching branches. Expected: []sql.Row{{"main"}}, }, }, @@ -4131,7 +4130,6 @@ var DoltCheckoutScripts = []queries.ScriptTest{ "create table conflict_tbl (id int primary key, val int);", "call dolt_commit('-Am', 'add conflict_tbl on feat');", "call dolt_checkout('main');", - // conflict_tbl is now an untracked table on main (not committed to main) "create table conflict_tbl (id int primary key);", }, Assertions: []queries.ScriptTestAssertion{ @@ -4153,7 +4151,6 @@ var DoltCheckoutScripts = []queries.ScriptTest{ "create table conflict_tbl (id int primary key, val int);", "call dolt_commit('-Am', 'add conflict_tbl on feat');", "call dolt_checkout('main');", - // conflict_tbl is staged on main but not committed "create table conflict_tbl (id int primary key);", "call dolt_add('conflict_tbl');", }, @@ -4168,6 +4165,108 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, }, }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout('--move') with untracked FK table does not duplicate FK", + SetUpScript: []string{ + "create table parent (id int primary key);", + "call dolt_commit('-Am', 'add parent');", + "call dolt_branch('other');", + "create table child (id int primary key, pid int, constraint fk_child foreign key (pid) references parent(id));", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('--move', 'other');", + Expected: []sql.Row{{0, "Switched to branch 'other'"}}, + }, + { + Query: "select table_name from information_schema.tables where table_schema = database() and table_name = 'child';", + Expected: []sql.Row{{"child"}}, + }, + { + Query: "select constraint_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Expected: []sql.Row{{"fk_child"}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout rewrites carried FK referenced tags when parent column has a different tag on target", + SetUpScript: []string{ + "create table parent (id int primary key, ref_col int unique);", + "call dolt_commit('-Am', 'add parent');", + "call dolt_branch('other');", + "call dolt_update_column_tag('parent', 'ref_col', 9999);", + "call dolt_commit('-am', 'set ref_col tag on main');", + "call dolt_checkout('other');", + "call dolt_update_column_tag('parent', 'ref_col', 7777);", + "call dolt_commit('-am', 'set ref_col tag on other');", + "call dolt_checkout('main');", + "insert into parent values (1, 100);", + "call dolt_commit('-am', 'insert into parent');", + "create table child (id int primary key, pid int, constraint fk_child foreign key (pid) references parent(ref_col));", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('other');", + Expected: []sql.Row{{0, "Switched to branch 'other'"}}, + }, + { + Query: "call dolt_add('-A');", + Expected: []sql.Row{{0}}, + }, + { + Query: "call dolt_commit('-m', 'carry child to other');", + // Commit runs ValidateForeignKeysOnSchemas which looks up each FK column by tag in + // the referenced schema. The commit succeeds because the carry rewrote the referenced + // tag to target's value. + Expected: []sql.Row{{doltCommit}}, + }, + { + Query: "insert into parent values (1, 100);", + Expected: []sql.Row{{types.OkResult{RowsAffected: 1}}}, + }, + { + Query: "insert into child values (10, 100);", + Expected: []sql.Row{{types.OkResult{RowsAffected: 1}}}, + }, + { + Query: "insert into child values (99, 999);", + ExpectedErr: sql.ErrForeignKeyChildViolation, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout carries an untracked FK whose parent is missing on target and the next commit surfaces the broken state", + SetUpScript: []string{ + "create table parent (id int primary key);", + "call dolt_commit('-Am', 'add parent');", + "call dolt_branch('no_parent');", + "call dolt_checkout('no_parent');", + "drop table parent;", + "call dolt_commit('-Am', 'drop parent on no_parent');", + "call dolt_checkout('main');", + "create table child (id int primary key, pid int, constraint fk_orphan foreign key (pid) references parent(id));", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "call dolt_checkout('no_parent');", + // The carry succeeds even when the referenced parent does not exist on the target. + Expected: []sql.Row{{0, "Switched to branch 'no_parent'"}}, + }, + { + Query: "call dolt_add('-A');", + Expected: []sql.Row{{0}}, + }, + { + Query: "call dolt_commit('-m', 'carry orphan child');", + // Commit-time foreign key validation surfaces a clear error because the referenced + // parent table is missing on this branch. + ExpectedErrStr: "foreign key `fk_orphan` requires the referenced table `parent`", + }, + }, + }, } var DoltCheckoutReadOnlyScripts = []queries.ScriptTest{ @@ -5002,7 +5101,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ "insert into parent values (1, 'alice'), (2, 'bob');", "call dolt_commit('-Am', 'add parent on feat');", "call dolt_checkout('main');", - // A local copy of parent is needed for the FK definition; the committed version wins after reset. + // A local copy of parent is needed for the FK definition. The committed version wins after reset. "create table parent (id int primary key, name varchar(64));", "create table child (id int primary key, parent_id int, constraint fk_parent foreign key (parent_id) references parent(id));", "insert into parent values (1, 'alice'), (2, 'bob');", diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries_dtables.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries_dtables.go index 385e9eadb8c..f91c14b6ed9 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries_dtables.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries_dtables.go @@ -59,12 +59,13 @@ var DoltStatusTableScripts = []queries.ScriptTest{ }, Assertions: []queries.ScriptTestAssertion{ { - Query: "SELECT * FROM dolt_status;", - Expected: []sql.Row{{"t", byte(0), "new table"}}, + Query: "SELECT * FROM dolt_status;", + // abc was staged on branch1 and carried to main by the checkout, preserving its staged status. + Expected: []sql.Row{{"abc", byte(1), "new table"}, {"t", byte(0), "new table"}}, }, { Query: "SELECT * FROM `mydb/main`.dolt_status;", - Expected: []sql.Row{{"t", byte(0), "new table"}}, + Expected: []sql.Row{{"abc", byte(1), "new table"}, {"t", byte(0), "new table"}}, }, { Query: "SELECT * FROM dolt_status AS OF 'tag1';", @@ -75,16 +76,17 @@ var DoltStatusTableScripts = []queries.ScriptTest{ Expected: []sql.Row{}, }, { - // HEAD is a special revision spec + // HEAD is a special revision spec that shows the current working and staged status. Query: "SELECT * FROM dolt_status AS OF 'head';", - Expected: []sql.Row{{"t", byte(0), "new table"}}, + Expected: []sql.Row{{"abc", byte(1), "new table"}, {"t", byte(0), "new table"}}, }, { Query: "SELECT * FROM dolt_status AS OF 'HEAD~1';", Expected: []sql.Row{}, }, { - Query: "SELECT * FROM dolt_status AS OF 'branch1';", + Query: "SELECT * FROM dolt_status AS OF 'branch1';", + // branch1 is unaffected by the carry, so abc remains staged there. Expected: []sql.Row{{"abc", byte(1), "new table"}}, }, { diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries_nonlocal.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries_nonlocal.go index eae0562253e..faa2fb0bb0d 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries_nonlocal.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries_nonlocal.go @@ -164,8 +164,10 @@ var NonlocalScripts = []queries.ScriptTest{ }, Assertions: []queries.ScriptTestAssertion{ { - Query: "show tables", - Expected: []sql.Row{{"table_alias_1"}, {"table_alias_2"}, {"table_alias_wild_3"}}, + Query: "show tables", + // aliased_table was untracked on main and is carried to other by the checkout. + // table_alias_1, table_alias_2, and table_alias_wild_3 come from the nonlocal entries. + Expected: []sql.Row{{"aliased_table"}, {"table_alias_1"}, {"table_alias_2"}, {"table_alias_wild_3"}}, }, }, }, diff --git a/integration-tests/bats/checkout.bats b/integration-tests/bats/checkout.bats index d061310bf21..7364fe8487e 100755 --- a/integration-tests/bats/checkout.bats +++ b/integration-tests/bats/checkout.bats @@ -1494,3 +1494,41 @@ SQL [ "$status" -eq 0 ] [[ "$output" =~ "1,42" ]] || false } + +@test "checkout: untracked table with foreign key is carried to target branch without error" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt sql -q "CREATE TABLE parent (id INT PRIMARY KEY)" + dolt commit -Am "add parent" + dolt branch other + + dolt sql -q "CREATE TABLE child (id INT PRIMARY KEY, pid INT, CONSTRAINT fk_child FOREIGN KEY (pid) REFERENCES parent(id))" + + run dolt checkout other + [ "$status" -eq 0 ] + + run dolt sql -q "SELECT table_name FROM information_schema.tables WHERE table_schema = database() AND table_name = 'child'" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "child" ]] || false + + run dolt sql -q "SELECT constraint_name FROM information_schema.table_constraints WHERE table_schema = database() AND constraint_type = 'FOREIGN KEY' AND table_name = 'child'" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "fk_child" ]] || false +} + +@test "checkout: aborts when untracked table conflicts with committed table on target branch" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt checkout -b feat + dolt sql -q "CREATE TABLE conflict_tbl (id INT PRIMARY KEY, val INT)" + dolt commit -Am "add conflict_tbl on feat" + dolt checkout main + + dolt sql -q "CREATE TABLE conflict_tbl (id INT PRIMARY KEY)" + + run dolt checkout feat + [ "$status" -ne 0 ] + [[ "$output" =~ "conflict_tbl" ]] || false + + run dolt branch --show-current + [ "$status" -eq 0 ] + [[ "$output" =~ "main" ]] || false +} diff --git a/integration-tests/bats/reset.bats b/integration-tests/bats/reset.bats index 07ce86a7189..2ef8dd90fab 100644 --- a/integration-tests/bats/reset.bats +++ b/integration-tests/bats/reset.bats @@ -435,3 +435,25 @@ SQL [[ "$output" =~ "3,30" ]] || false [[ "$output" =~ "4,40" ]] || false } + +@test "reset: dolt reset --hard to a branch preserves untracked tables" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt checkout -b feat + dolt sql -q "CREATE TABLE committed_on_feat (id INT PRIMARY KEY)" + dolt commit -Am "add committed_on_feat" + dolt checkout main + + dolt sql -q "CREATE TABLE untracked_tbl (pk INT PRIMARY KEY, val INT)" + dolt sql -q "INSERT INTO untracked_tbl VALUES (1, 42)" + + run dolt reset --hard feat + [ "$status" -eq 0 ] + + run dolt sql -q "SELECT pk, val FROM untracked_tbl" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "1,42" ]] || false + + run dolt status + [ "$status" -eq 0 ] + [[ "$output" =~ "untracked_tbl" ]] || false +} From 30919465dea3234b5eb7660d5100017198d0670b Mon Sep 17 00:00:00 2001 From: elianddb Date: Wed, 13 May 2026 21:14:21 +0000 Subject: [PATCH 08/23] schema: add WithRemappedColumnTags, WithUpdatedColumnTag, and RemapTags helpers --- go/libraries/doltcore/schema/schema_impl.go | 35 +++++++++------------ go/libraries/doltcore/schema/tag.go | 17 ++++++++++ 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/go/libraries/doltcore/schema/schema_impl.go b/go/libraries/doltcore/schema/schema_impl.go index 7e262a76087..ede4f3d9a2e 100644 --- a/go/libraries/doltcore/schema/schema_impl.go +++ b/go/libraries/doltcore/schema/schema_impl.go @@ -17,6 +17,7 @@ package schema import ( "errors" "fmt" + "slices" "strconv" "strings" @@ -131,24 +132,26 @@ func SchemaFromCols(allCols *ColCollection) (Schema, error) { return sch, nil } -// WithUpdatedColumnTags returns a copy of |sch| with column and index tags remapped -// according to |tagRemap| (old tag -> new tag). All other schema-level metadata -// (comment, collation, primary key order, target row size) is preserved. Check -// constraints are preserved unchanged because they reference column names, not tags. -func WithUpdatedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, error) { +// WithRemappedColumnTags returns a copy of |sch| with column and index tags remapped +// according to |tagRemap| (old tag to new tag). All other schema-level metadata +// (comment, collation, primary key order, target row size) is preserved. Indexes +// referencing tags not in |tagRemap| are copied unchanged. Check constraints are +// preserved unchanged because they reference column names, not tags. Returns |sch| +// unchanged when |tagRemap| is empty. +func WithRemappedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, error) { if len(tagRemap) == 0 { return sch, nil } allCols := sch.GetAllCols() - cols := allCols.GetColumns() + cols := slices.Clone(allCols.GetColumns()) for oldTag, newTag := range tagRemap { if idx, ok := allCols.TagToIdx[oldTag]; ok { cols[idx].Tag = newTag } } - pkOrdinals := append([]int{}, sch.GetPkOrdinals()...) + pkOrdinals := slices.Clone(sch.GetPkOrdinals()) newSch, err := NewSchema(NewColCollection(cols...), pkOrdinals, sch.GetCollation(), nil, nil) if err != nil { return nil, err @@ -166,15 +169,7 @@ func WithUpdatedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, erro err = sch.Indexes().Iter(func(idx Index) (stop bool, err error) { tags := idx.IndexedColumnTags() if _, ok := affected[idx.Name()]; ok { - remapped := make([]uint64, len(tags)) - for i, t := range tags { - if newTag, ok := tagRemap[t]; ok { - remapped[i] = newTag - } else { - remapped[i] = t - } - } - tags = remapped + tags = RemapTags(tags, tagRemap) } props := IndexProperties{ IsUnique: idx.IsUnique(), @@ -202,13 +197,13 @@ func WithUpdatedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, erro return newSch, nil } -// UpdateColumnTag returns a copy of |sch| with the tag of the column named |name| set to |tag|. -func UpdateColumnTag(sch Schema, name string, tag uint64) (Schema, error) { +// WithUpdatedColumnTag returns a copy of |sch| with the tag of the column named |name| set to |tag|. +func WithUpdatedColumnTag(sch Schema, name string, tag uint64) (Schema, error) { col, ok := sch.GetAllCols().GetByName(name) if !ok { - return nil, fmt.Errorf("column %s does not exist", name) + return nil, fmt.Errorf("column %q does not exist", name) } - return WithUpdatedColumnTags(sch, map[uint64]uint64{col.Tag: tag}) + return WithRemappedColumnTags(sch, map[uint64]uint64{col.Tag: tag}) } // SchemaFromColCollections creates a schema from the three collections. diff --git a/go/libraries/doltcore/schema/tag.go b/go/libraries/doltcore/schema/tag.go index 4f6d1d7e0a8..53007b059a5 100644 --- a/go/libraries/doltcore/schema/tag.go +++ b/go/libraries/doltcore/schema/tag.go @@ -76,6 +76,23 @@ func (tm TagMapping) Size() int { return len(tm) } +// RemapTags returns |tags| with each entry replaced by |remap[t]| if present. Returns |tags| +// unchanged when |remap| is empty. +func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { + if len(remap) == 0 { + return tags + } + out := make([]uint64, len(tags)) + for i, t := range tags { + if newTag, ok := remap[t]; ok { + out[i] = newTag + } else { + out[i] = t + } + } + return out +} + // AutoGenerateTag generates a random tag that doesn't exist in the provided SuperSchema. // It uses a deterministic random number generator that is seeded with the NomsKinds of any existing columns in the // schema and the NomsKind of the column being added to the schema. Deterministic tag generation means that branches From ffaba864d353deffa3f01277a751ea025ab929cc Mon Sep 17 00:00:00 2001 From: elianddb Date: Wed, 13 May 2026 21:14:33 +0000 Subject: [PATCH 09/23] schcmds, dprocedures: dedup column-tag update via schema.WithUpdatedColumnTag --- go/cmd/dolt/commands/schcmds/copy-tags.go | 2 +- go/cmd/dolt/commands/schcmds/update-tag.go | 2 +- .../doltcore/sqle/dprocedures/dolt_update_column_tag.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go/cmd/dolt/commands/schcmds/copy-tags.go b/go/cmd/dolt/commands/schcmds/copy-tags.go index 45db102b137..4d9a9c29098 100644 --- a/go/cmd/dolt/commands/schcmds/copy-tags.go +++ b/go/cmd/dolt/commands/schcmds/copy-tags.go @@ -334,7 +334,7 @@ func updateRootWithNewColumnTag(ctx context.Context, root doltdb.RootValue, tabl } // Update the column tag in the schema - updatedSchema, err := schema.UpdateColumnTag(sch, columnName, tag) + updatedSchema, err := schema.WithUpdatedColumnTag(sch, columnName, tag) if err != nil { return nil, err } diff --git a/go/cmd/dolt/commands/schcmds/update-tag.go b/go/cmd/dolt/commands/schcmds/update-tag.go index 1cad8978ff7..1243627f1e4 100644 --- a/go/cmd/dolt/commands/schcmds/update-tag.go +++ b/go/cmd/dolt/commands/schcmds/update-tag.go @@ -98,7 +98,7 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get schema").Build(), usage) } - newSch, err := schema.UpdateColumnTag(sch, columnName, tag) + newSch, err := schema.WithUpdatedColumnTag(sch, columnName, tag) if err != nil { return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to update column tag").AddCause(err).Build(), usage) } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go index ec78f7e5ea9..af3cb82488e 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go @@ -57,7 +57,7 @@ func doltUpdateColumnTag(ctx *sql.Context, args ...string) (sql.RowIter, error) return nil, err } - newSch, err := schema.UpdateColumnTag(sch, columnName, tag) + newSch, err := schema.WithUpdatedColumnTag(sch, columnName, tag) if err != nil { return nil, fmt.Errorf("failed to update column tag: %w", err) } From 7b857b1bd0f9baffac45c9ca0a2071717f146a5a Mon Sep 17 00:00:00 2001 From: elianddb Date: Wed, 13 May 2026 21:14:40 +0000 Subject: [PATCH 10/23] actions: carry uncommitted tables across checkout and reset --- .../doltcore/env/actions/carry_tables.go | 195 ++++++++++++++++++ go/libraries/doltcore/env/actions/checkout.go | 140 +++++-------- go/libraries/doltcore/env/actions/reset.go | 103 +-------- .../sqle/dprocedures/dolt_checkout.go | 52 ++--- .../sqle/dprocedures/dolt_checkout_helpers.go | 2 +- .../doltcore/sqle/dprocedures/dolt_rebase.go | 13 +- 6 files changed, 273 insertions(+), 232 deletions(-) create mode 100644 go/libraries/doltcore/env/actions/carry_tables.go diff --git a/go/libraries/doltcore/env/actions/carry_tables.go b/go/libraries/doltcore/env/actions/carry_tables.go new file mode 100644 index 00000000000..4d9bbe68411 --- /dev/null +++ b/go/libraries/doltcore/env/actions/carry_tables.go @@ -0,0 +1,195 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed 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 actions + +import ( + "context" + "fmt" + + "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/dolt/go/libraries/doltcore/schema" + "github.com/dolthub/dolt/go/store/types" +) + +// CarryUncommittedTables copies tables that are in |src| but not in |baseline| into |dest|, +// resolving column tag collisions, and carries forward the foreign key entries for those tables +// from |src| into |dest|'s FK collection. Tables already present in |dest| are kept unchanged. +// Callers choose |baseline| to control what counts as "untracked": pass the staged root for +// true untracked semantics or the head root to also include staged-but-uncommitted tables. +func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.RootValue) (doltdb.RootValue, error) { + untracked, err := uncommittedSchemas(ctx, src, baseline) + if err != nil { + return nil, err + } + + dest, carried, tagRemaps, err := carryTables(ctx, src, dest, untracked) + if err != nil { + return nil, err + } + + if len(carried) == 0 { + return dest, nil + } + + return carryForeignKeys(ctx, src, dest, carried, tagRemaps) +} + +// uncommittedSchemas returns the schemas of tables that are in |src| but not in |baseline|. +func uncommittedSchemas(ctx context.Context, src, baseline doltdb.RootValue) (map[doltdb.TableName]schema.Schema, error) { + untracked, err := doltdb.GetAllSchemas(ctx, src) + if err != nil { + return nil, err + } + baselineNames, err := baseline.GetAllTableNames(ctx, false) + if err != nil { + return nil, err + } + for _, name := range baselineNames { + delete(untracked, name) + } + return untracked, nil +} + +// carryTables copies each entry of |toCarry| from |src| into |dest|, retagging columns whose +// tag collides with one already in |dest| or with one assigned to an earlier carried table. +// Tables whose name already exists in |dest| are skipped so the dest version wins. Returns +// the updated |dest|, the names that were carried, and the tag remaps applied per table for +// downstream FK fixup. +func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[doltdb.TableName]schema.Schema) (doltdb.RootValue, []doltdb.TableName, map[doltdb.TableName]map[uint64]uint64, error) { + destSchemas, err := doltdb.GetAllSchemas(ctx, dest) + if err != nil { + return nil, nil, nil, err + } + + heldTags := make(schema.TagMapping) + for tblName, tblSch := range destSchemas { + for _, t := range tblSch.GetAllCols().Tags { + heldTags.Add(t, tblName.Name) + } + } + + allTagRemaps := make(map[doltdb.TableName]map[uint64]uint64) + var carried []doltdb.TableName + for name, sch := range toCarry { + if _, exists := destSchemas[name]; exists { + continue + } + tbl, exists, err := src.GetTable(ctx, name) + if err != nil { + return nil, nil, nil, err + } + if !exists { + return nil, nil, nil, fmt.Errorf("table %s does not exist in src root", name) + } + columns := sch.GetAllCols().GetColumns() + tagRemap := make(map[uint64]uint64) + priorKinds := make([]types.NomsKind, 0, len(columns)) + for _, column := range columns { + if heldTags.Contains(column.Tag) { + newTag := schema.AutoGenerateTag(heldTags, name.Name, priorKinds, column.Name, column.Kind) + tagRemap[column.Tag] = newTag + column.Tag = newTag + } + priorKinds = append(priorKinds, column.Kind) + heldTags.Add(column.Tag, name.Name) + } + if len(tagRemap) > 0 { + allTagRemaps[name] = tagRemap + newSch, err := schema.WithRemappedColumnTags(sch, tagRemap) + if err != nil { + return nil, nil, nil, err + } + tbl, err = tbl.UpdateSchema(ctx, newSch) + if err != nil { + return nil, nil, nil, err + } + } + dest, err = dest.PutTable(ctx, name, tbl) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to write table back to database: %w", err) + } + carried = append(carried, name) + } + return dest, carried, allTagRemaps, nil +} + +// carryForeignKeys copies foreign keys from |src| whose child table is in |carried| into +// |dest|'s FK collection, applies |tagRemaps| so retagged column references are updated, and +// returns the updated |dest|. Foreign keys already present in |dest| (e.g. pre-merged by an +// upstream step) are skipped to avoid ErrForeignKeyDuplicateName. When a carried foreign key +// references a parent table that already exists on |dest|, the referenced column tags are +// re-resolved against |dest|'s parent schema by column name so the foreign key remains valid +// even when the same parent column has a different internal tag on |src| and |dest|. +func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried []doltdb.TableName, tagRemaps map[doltdb.TableName]map[uint64]uint64) (doltdb.RootValue, error) { + destFks, err := dest.GetForeignKeyCollection(ctx) + if err != nil { + return nil, err + } + srcFks, err := src.GetForeignKeyCollection(ctx) + if err != nil { + return nil, err + } + srcSchs, err := doltdb.GetAllSchemas(ctx, src) + if err != nil { + return nil, err + } + destSchs, err := doltdb.GetAllSchemas(ctx, dest) + if err != nil { + return nil, err + } + carriedSet := doltdb.NewTableNameSet(carried) + err = srcFks.Iter(func(fk doltdb.ForeignKey) (stop bool, err error) { + if !carriedSet.Contains(fk.TableName) { + return false, nil + } + if _, exists := destFks.GetByNameCaseInsensitive(fk.Name, fk.TableName); exists { + return false, nil + } + fk.TableColumns = schema.RemapTags(fk.TableColumns, tagRemaps[fk.TableName]) + if carriedSet.Contains(fk.ReferencedTableName) { + fk.ReferencedTableColumns = schema.RemapTags(fk.ReferencedTableColumns, tagRemaps[fk.ReferencedTableName]) + } else { + fk.ReferencedTableColumns = remapTagsByColumnName(fk.ReferencedTableColumns, srcSchs[fk.ReferencedTableName], destSchs[fk.ReferencedTableName]) + } + return false, destFks.AddKeys(fk) + }) + if err != nil { + return nil, err + } + return dest.PutForeignKeyCollection(ctx, destFks) +} + +// remapTagsByColumnName returns |tags| rewritten so each tag points at the same-named column +// on |destSch| instead of |srcSch|. When either schema is nil or any tag cannot be +// resolved on both sides, the original |tags| are returned so the caller can proceed and let +// downstream validation surface any genuine breakage. +func remapTagsByColumnName(tags []uint64, srcSch, destSch schema.Schema) []uint64 { + if srcSch == nil || destSch == nil { + return tags + } + out := make([]uint64, len(tags)) + for i, srcTag := range tags { + srcCol, ok := srcSch.GetAllCols().GetByTag(srcTag) + if !ok { + return tags + } + destCol, ok := destSch.GetAllCols().GetByName(srcCol.Name) + if !ok { + return tags + } + out[i] = destCol.Tag + } + return out +} diff --git a/go/libraries/doltcore/env/actions/checkout.go b/go/libraries/doltcore/env/actions/checkout.go index ccda70861e4..63b46a26af1 100644 --- a/go/libraries/doltcore/env/actions/checkout.go +++ b/go/libraries/doltcore/env/actions/checkout.go @@ -16,7 +16,7 @@ package actions import ( "context" - "sort" + "slices" "strings" "time" @@ -120,35 +120,45 @@ func FindTableInRoots(ctx *sql.Context, roots doltdb.Roots, name string) (doltdb return doltdb.TableName{}, nil, false, nil } -// CheckUntrackedConflicts returns ErrCheckoutWouldOverwrite if any table that is untracked -// in |roots| (present in Working but absent from Head) would be overwritten by a committed -// table of the same name on |targetRoot|. -func CheckUntrackedConflicts(ctx context.Context, roots doltdb.Roots, targetRoot doltdb.RootValue) error { - workingSchemas, err := doltdb.GetAllSchemas(ctx, roots.Working) +// CheckUncommittedConflicts returns ErrCheckoutWouldOverwrite if any table that is uncommitted +// in |roots| (present in Working but absent from Head, covering both untracked and staged-but- +// uncommitted tables) would be overwritten by a different committed version of the same table +// on |targetRoot|. Tables whose working content matches the committed version on |targetRoot| +// are not considered conflicts. +func CheckUncommittedConflicts(ctx context.Context, roots doltdb.Roots, targetRoot doltdb.RootValue) error { + workingNames, err := roots.Working.GetAllTableNames(ctx, false) if err != nil { return err } - headSchemas, err := doltdb.GetAllSchemas(ctx, roots.Head) + headNames, err := roots.Head.GetAllTableNames(ctx, false) if err != nil { return err } + headNamesSet := doltdb.NewTableNameSet(headNames) var conflicts []string - for name := range workingSchemas { - if _, isTracked := headSchemas[name]; isTracked { + for _, name := range workingNames { + if headNamesSet.Contains(name) { continue } - h, _, err := targetRoot.GetTableHash(ctx, name) + targetHash, _, err := targetRoot.GetTableHash(ctx, name) if err != nil { return err } - if !h.IsEmpty() { + if targetHash.IsEmpty() { + continue + } + workingHash, _, err := roots.Working.GetTableHash(ctx, name) + if err != nil { + return err + } + if workingHash != targetHash { conflicts = append(conflicts, name.Name) } } if len(conflicts) > 0 { - sort.Strings(conflicts) + slices.Sort(conflicts) return ErrCheckoutWouldOverwrite{conflicts} } return nil @@ -157,8 +167,8 @@ func CheckUntrackedConflicts(ctx context.Context, roots doltdb.Roots, targetRoot // RootsForBranch returns the roots needed for a branch checkout. |roots.Head| should be the // pre-checkout head. The returned roots struct has |Head| set to |branchRoot|. // -// Untracked tables (present in working or staged but absent from the old Head) are moved into -// the new root via [MoveUntrackedTables], which resolves any column tag collisions. +// Uncommitted tables (present in working or staged but absent from the old Head) are moved into +// the new root via [CarryUncommittedTables], which resolves any column tag collisions. func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.RootValue, force bool) (doltdb.Roots, error) { conflicts := doltdb.NewTableNameSet(nil) if roots.Head == nil { @@ -168,17 +178,23 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return roots, nil } - // Snapshot roots before the tracked-table merge so MoveUntrackedTables below has the original sources. + if !force { + if err := CheckUncommittedConflicts(ctx, roots, branchRoot); err != nil { + return doltdb.Roots{}, err + } + } + + // Snapshot roots before threeWayMergeTableHashes updates them so CarryUncommittedTables uses the pre-checkout values. preCheckoutWorking := roots.Working preCheckoutStaged := roots.Staged preCheckoutHead := roots.Head - wrkTblHashes, err := moveModifiedTables(ctx, roots.Head, branchRoot, roots.Working, conflicts, force) + wrkTblHashes, err := threeWayMergeTableHashes(ctx, roots.Head, branchRoot, roots.Working, conflicts, force) if err != nil { return doltdb.Roots{}, err } - stgTblHashes, err := moveModifiedTables(ctx, roots.Head, branchRoot, roots.Staged, conflicts, force) + stgTblHashes, err := threeWayMergeTableHashes(ctx, roots.Head, branchRoot, roots.Staged, conflicts, force) if err != nil { return doltdb.Roots{}, err } @@ -187,12 +203,12 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, ErrCheckoutWouldOverwrite{conflicts.AsStringSlice()} } - workingForeignKeys, err := MoveForeignKeys(ctx, roots.Head, branchRoot, roots.Working, force) + workingForeignKeys, err := threeWayMergeForeignKeys(ctx, roots.Head, branchRoot, roots.Working, force) if err != nil { return doltdb.Roots{}, err } - stagedForeignKeys, err := MoveForeignKeys(ctx, roots.Head, branchRoot, roots.Staged, force) + stagedForeignKeys, err := threeWayMergeForeignKeys(ctx, roots.Head, branchRoot, roots.Staged, force) if err != nil { return doltdb.Roots{}, err } @@ -207,29 +223,23 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } - // MoveUntrackedTables must run before PutForeignKeyCollection so remaps are available. - var workingRemaps, stagedRemaps map[doltdb.TableName]map[uint64]uint64 - roots.Working, workingRemaps, err = MoveUntrackedTables(ctx, preCheckoutWorking, preCheckoutHead, roots.Working) + // Put the merged foreign key collections so CarryUncommittedTables adds untracked keys on top. + roots.Working, err = roots.Working.PutForeignKeyCollection(ctx, workingForeignKeys) if err != nil { return doltdb.Roots{}, err } - roots.Staged, stagedRemaps, err = MoveUntrackedTables(ctx, preCheckoutStaged, preCheckoutHead, roots.Staged) + + roots.Staged, err = roots.Staged.PutForeignKeyCollection(ctx, stagedForeignKeys) if err != nil { return doltdb.Roots{}, err } - if err = ApplyForeignKeyTagRemaps(workingForeignKeys, workingRemaps); err != nil { - return doltdb.Roots{}, err - } - roots.Working, err = roots.Working.PutForeignKeyCollection(ctx, workingForeignKeys) + roots.Working, err = CarryUncommittedTables(ctx, preCheckoutWorking, preCheckoutHead, roots.Working) if err != nil { return doltdb.Roots{}, err } - if err = ApplyForeignKeyTagRemaps(stagedForeignKeys, stagedRemaps); err != nil { - return doltdb.Roots{}, err - } - roots.Staged, err = roots.Staged.PutForeignKeyCollection(ctx, stagedForeignKeys) + roots.Staged, err = CarryUncommittedTables(ctx, preCheckoutStaged, preCheckoutHead, roots.Staged) if err != nil { return doltdb.Roots{}, err } @@ -238,7 +248,7 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return roots, nil } -// CleanOldWorkingSet resets the source branch's working set to the branch head, leaving the source branch unchanged +// CleanOldWorkingSet resets the source branch's working set to the branch head, leaving the source branch unchanged. func CleanOldWorkingSet( ctx *sql.Context, dbData env.DbData[*sql.Context], @@ -331,11 +341,13 @@ func BranchHeadRoot(ctx context.Context, db *doltdb.DoltDB, brName string) (dolt return branchRoot, nil } -// moveModifiedTables handles working set changes during a branch change. -// When moving between branches, changes in the working set should travel with you. -// Working set changes cannot be moved if the table differs between the old and new head, -// in this case, we throw a conflict and error (as per Git). -func moveModifiedTables(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, conflicts *doltdb.TableNameSet, force bool) (map[doltdb.TableName]hash.Hash, error) { +// threeWayMergeTableHashes performs a 3-way merge of per-table hashes from |oldRoot|, |newRoot|, +// and |changedRoot|. For each table the returned map picks a winning hash: |changedRoot|'s hash +// when |newRoot| matches |oldRoot|, |newRoot|'s hash when |changedRoot| matches |oldRoot|, or +// |newRoot|'s hash when |force| is true. Tables modified on both sides without |force| are added +// to |conflicts| for the caller to surface as ErrCheckoutWouldOverwrite. Uncommitted tables are +// skipped here and carried by [CarryUncommittedTables] instead. +func threeWayMergeTableHashes(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, conflicts *doltdb.TableNameSet, force bool) (map[doltdb.TableName]hash.Hash, error) { resultMap := make(map[doltdb.TableName]hash.Hash) tblNames, err := doltdb.UnionTableNames(ctx, newRoot) if err != nil { @@ -386,7 +398,7 @@ func moveModifiedTables(ctx context.Context, oldRoot, newRoot, changedRoot doltd return nil, err } - // Untracked tables are carried forward by MoveUntrackedTables instead. + // Uncommitted tables are carried forward by CarryUncommittedTables instead. if oldHash == emptyHash { continue } else if force { @@ -449,8 +461,10 @@ func CheckOverwrittenIgnoredTables(ctx context.Context, roots doltdb.Roots, bran return nil } -// MoveForeignKeys returns the foreign key collection that should be used for the new working set. -func MoveForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, force bool) (*doltdb.ForeignKeyCollection, error) { +// threeWayMergeForeignKeys performs a 3-way merge of the foreign key collections from |oldRoot|, +// |newRoot|, and |changedRoot|. If one side did not change the collection it returns the other, +// and otherwise delegates to [mergeForeignKeyChanges] to merge changes from both sides. +func threeWayMergeForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, force bool) (*doltdb.ForeignKeyCollection, error) { oldFks, err := oldRoot.GetForeignKeyCollection(ctx) if err != nil { return nil, err @@ -492,52 +506,6 @@ func MoveForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.R } } -// ApplyForeignKeyTagRemaps updates column tags in |fks| for any foreign key whose child or parent -// table appears in |remaps|. It is called after MoveUntrackedTables to fix up tags that were -// reassigned to avoid collisions during the table copy. -func ApplyForeignKeyTagRemaps(fks *doltdb.ForeignKeyCollection, remaps map[doltdb.TableName]map[uint64]uint64) error { - if len(remaps) == 0 { - return nil - } - - remapSlice := func(tags []uint64, remap map[uint64]uint64) []uint64 { - if len(remap) == 0 { - return tags - } - out := make([]uint64, len(tags)) - for i, t := range tags { - if newTag, ok := remap[t]; ok { - out[i] = newTag - } else { - out[i] = t - } - } - return out - } - - var toUpdate []doltdb.ForeignKey - err := fks.Iter(func(fk doltdb.ForeignKey) (stop bool, err error) { - if len(remaps[fk.TableName]) == 0 && len(remaps[fk.ReferencedTableName]) == 0 { - return false, nil - } - toUpdate = append(toUpdate, fk) - return false, nil - }) - if err != nil { - return err - } - - for _, fk := range toUpdate { - fks.RemoveKeyByName(fk.Name, fk.TableName) - fk.TableColumns = remapSlice(fk.TableColumns, remaps[fk.TableName]) - fk.ReferencedTableColumns = remapSlice(fk.ReferencedTableColumns, remaps[fk.ReferencedTableName]) - if err = fks.AddKeys(fk); err != nil { - return err - } - } - return nil -} - // mergeForeignKeyChanges merges the foreign key changes from the old and changed roots into a new foreign key // collection, or returns an error if the changes are incompatible. Changes are incompatible if the changed root // and new root both altered foreign keys on the same table. diff --git a/go/libraries/doltcore/env/actions/reset.go b/go/libraries/doltcore/env/actions/reset.go index 98b14f6ae9a..cf59b995f5b 100644 --- a/go/libraries/doltcore/env/actions/reset.go +++ b/go/libraries/doltcore/env/actions/reset.go @@ -27,7 +27,6 @@ import ( "github.com/dolthub/dolt/go/libraries/doltcore/schema" "github.com/dolthub/dolt/go/libraries/doltcore/sqle/resolve" "github.com/dolthub/dolt/go/store/datas" - "github.com/dolthub/dolt/go/store/types" ) // resetHardTables resolves a new HEAD commit from a refSpec and updates working set roots by @@ -64,113 +63,13 @@ func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr str } } - fks, err := MoveForeignKeys(ctx, roots.Head, roots.Head, roots.Working, false) - if err != nil { - return nil, doltdb.Roots{}, err - } - newWorking, remaps, err := MoveUntrackedTables(ctx, roots.Working, roots.Staged, roots.Head) - if err != nil { - return nil, doltdb.Roots{}, err - } - if err = ApplyForeignKeyTagRemaps(fks, remaps); err != nil { - return nil, doltdb.Roots{}, err - } - newWorking, err = newWorking.PutForeignKeyCollection(ctx, fks) + newWorking, err := CarryUncommittedTables(ctx, roots.Working, roots.Staged, roots.Head) if err != nil { return nil, doltdb.Roots{}, err } return newHead, doltdb.Roots{Head: roots.Head, Working: newWorking, Staged: roots.Head}, nil } -// MoveUntrackedTables copies tables that are in |sourceWorking| but not in |sourceStaged| -// into |target| and returns the updated root. If a table shares a name with one already in -// |target|, the version in |target| is kept. If a table has column tags that conflict with -// those in |target|, the conflicting tags are replaced before the table is copied. Foreign -// key constraints declared on moved tables are also transferred to |target|, with column -// tags updated to match any retagging that was applied. -// -// This is the shared implementation used by both dolt reset --hard ([ResetHardTables]) and -// dolt checkout ([RootsForBranch]) to carry untracked tables across a root transition. -func MoveUntrackedTables(ctx context.Context, sourceWorking, sourceStaged, target doltdb.RootValue) (doltdb.RootValue, map[doltdb.TableName]map[uint64]uint64, error) { - untracked, err := doltdb.GetAllSchemas(ctx, sourceWorking) - if err != nil { - return nil, nil, err - } - - stagedNames, err := sourceStaged.GetAllTableNames(ctx, false) - if err != nil { - return nil, nil, err - } - for _, name := range stagedNames { - delete(untracked, name) - } - - targetSchemas, err := doltdb.GetAllSchemas(ctx, target) - if err != nil { - return nil, nil, err - } - - // heldTags starts with all column tags in |target| and grows as each untracked - // table is processed so no two moved tables end up sharing a tag. - heldTags := make(schema.TagMapping) - for tblName, tsch := range targetSchemas { - for _, t := range tsch.GetAllCols().Tags { - heldTags.Add(t, tblName.Name) - } - } - - allTagRemaps := make(map[doltdb.TableName]map[uint64]uint64) - for name, sch := range untracked { - if _, exists := targetSchemas[name]; exists { - continue - } - tbl, exists, err := sourceWorking.GetTable(ctx, name) - if err != nil { - return nil, nil, err - } - if !exists { - return nil, nil, fmt.Errorf("untracked table %s does not exist in working set", name) - } - tagRemap := collidingTagRemap(sch, name.Name, heldTags) - if len(tagRemap) > 0 { - allTagRemaps[name] = tagRemap - newSch, err := schema.WithUpdatedColumnTags(sch, tagRemap) - if err != nil { - return nil, nil, err - } - tbl, err = tbl.UpdateSchema(ctx, newSch) - if err != nil { - return nil, nil, err - } - } - target, err = target.PutTable(ctx, name, tbl) - if err != nil { - return nil, nil, fmt.Errorf("failed to write table back to database: %w", err) - } - } - - return target, allTagRemaps, nil -} - -// collidingTagRemap scans |sch| for column tags that collide with |heldTags| and returns -// a map of old tag to new tag for each colliding column. The map is empty when no columns -// collide. |heldTags| is updated in place with every column's final tag so that no two -// tables moved in sequence end up sharing a tag. |tableName| seeds the tag generator. -func collidingTagRemap(sch schema.Schema, tableName string, heldTags schema.TagMapping) map[uint64]uint64 { - columns := sch.GetAllCols().GetColumns() - tagRemap := make(map[uint64]uint64) - priorKinds := make([]types.NomsKind, 0, len(columns)) - for _, column := range columns { - if heldTags.Contains(column.Tag) { - tagRemap[column.Tag] = schema.AutoGenerateTag(heldTags, tableName, priorKinds, column.Name, column.Kind) - column.Tag = tagRemap[column.Tag] - } - priorKinds = append(priorKinds, column.Kind) - heldTags.Add(column.Tag, tableName) - } - return tagRemap -} - func GetAllTableNames(ctx context.Context, root doltdb.RootValue) []doltdb.TableName { tableNames := make([]doltdb.TableName, 0) _ = root.IterTables(ctx, func(name doltdb.TableName, table *doltdb.Table, sch schema.Schema) (stop bool, err error) { diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go index d3ec49103f8..aec90446f73 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go @@ -550,7 +550,9 @@ func checkoutExistingBranch( return err } - currentRoots, hasCurrentRoots := dSess.GetRoots(ctx, dbName) + var currentRoots doltdb.Roots + var hasCurrentRoots bool + currentRoots, hasCurrentRoots = dSess.GetRoots(ctx, dbName) if hasCurrentRoots { branchHead, err := actions.BranchHeadRoot(ctx, ddb, branchName) @@ -558,11 +560,14 @@ func checkoutExistingBranch( return err } - if err := actions.CheckOverwrittenIgnoredTables(ctx, currentRoots, branchHead, overwriteIgnore); err != nil { - return err + if !overwriteIgnore { + if err := actions.CheckOverwrittenIgnoredTables(ctx, currentRoots, branchHead, overwriteIgnore); err != nil { + return err + } } - if err := actions.CheckUntrackedConflicts(ctx, currentRoots, branchHead); err != nil { + // Not gated on overwriteIgnore because --overwrite-ignore covers only the dolt_ignore check above and not uncommitted table clobbering. + if err := actions.CheckUncommittedConflicts(ctx, currentRoots, branchHead); err != nil { return err } } @@ -573,42 +578,27 @@ func checkoutExistingBranch( } if hasCurrentRoots { - ws, err := dSess.WorkingSet(ctx, dbName) - if err != nil { - return err - } - workingFKs, err := actions.MoveForeignKeys(ctx, currentRoots.Head, ws.WorkingRoot(), currentRoots.Working, false) + // Use the base name because dbName may include a revision qualifier that WorkingSet does not accept after SwitchWorkingSet. + baseName, _ := doltdb.SplitRevisionDbName(dbName) + ws, err := dSess.WorkingSet(ctx, baseName) if err != nil { return err } - stagedFKs, err := actions.MoveForeignKeys(ctx, currentRoots.Head, ws.StagedRoot(), currentRoots.Staged, false) + // Carry tables not in the pre-checkout HEAD to the new branch. The staged root is carried + // separately so staged tables keep their staged status. + newWorking, err := actions.CarryUncommittedTables(ctx, currentRoots.Working, currentRoots.Head, ws.WorkingRoot()) if err != nil { return err } - newWorking, workingRemaps, err := actions.MoveUntrackedTables(ctx, currentRoots.Working, currentRoots.Head, ws.WorkingRoot()) + newStaged, err := actions.CarryUncommittedTables(ctx, currentRoots.Staged, currentRoots.Head, ws.StagedRoot()) if err != nil { return err } - newStaged, stagedRemaps, err := actions.MoveUntrackedTables(ctx, currentRoots.Staged, currentRoots.Head, ws.StagedRoot()) - if err != nil { - return err - } - if err = actions.ApplyForeignKeyTagRemaps(workingFKs, workingRemaps); err != nil { - return err - } - if err = actions.ApplyForeignKeyTagRemaps(stagedFKs, stagedRemaps); err != nil { - return err - } - newWorking, err = newWorking.PutForeignKeyCollection(ctx, workingFKs) - if err != nil { - return err - } - newStaged, err = newStaged.PutForeignKeyCollection(ctx, stagedFKs) - if err != nil { - return err - } - if err = dSess.SetWorkingSet(ctx, dbName, ws.WithWorkingRoot(newWorking).WithStagedRoot(newStaged)); err != nil { - return err + // CarryUncommittedTables returns its target argument unchanged when nothing is carried, so pointer equality detects a no-op. + if newWorking != ws.WorkingRoot() || newStaged != ws.StagedRoot() { + if err = dSess.SetWorkingSet(ctx, baseName, ws.WithWorkingRoot(newWorking).WithStagedRoot(newStaged)); err != nil { + return err + } } } } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go index 7eadd599af0..b570b9a2943 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go @@ -110,7 +110,7 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return err } - if err := actions.CheckUntrackedConflicts(ctx, initialRoots, branchHead); err != nil { + if err := actions.CheckUncommittedConflicts(ctx, initialRoots, branchHead); err != nil { return err } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go index 3e0283c16e1..47474cde24d 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go @@ -809,18 +809,7 @@ func continueRebase(ctx *sql.Context) rebaseResult { if err != nil { return newRebaseError(err) } - fks, err := actions.MoveForeignKeys(ctx, preRebaseStagedRoot, postCopyWS.WorkingRoot(), preRebaseWorkingRoot, false) - if err != nil { - return newRebaseError(err) - } - restoredWorking, remaps, err := actions.MoveUntrackedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot()) - if err != nil { - return newRebaseError(err) - } - if err = actions.ApplyForeignKeyTagRemaps(fks, remaps); err != nil { - return newRebaseError(err) - } - restoredWorking, err = restoredWorking.PutForeignKeyCollection(ctx, fks) + restoredWorking, err := actions.CarryUncommittedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot()) if err != nil { return newRebaseError(err) } From e01bac6d7040166b1a4610b6c0a372927f4a3c88 Mon Sep 17 00:00:00 2001 From: elianddb Date: Wed, 13 May 2026 22:44:30 +0000 Subject: [PATCH 11/23] actions: exclude system and ignored tables from uncommitted carry --- .../doltcore/env/actions/carry_tables.go | 24 +++++++++++++++++-- .../doltcore/sqle/statspro/worker_test.go | 7 ++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/go/libraries/doltcore/env/actions/carry_tables.go b/go/libraries/doltcore/env/actions/carry_tables.go index 4d9bbe68411..3a3468c5ed7 100644 --- a/go/libraries/doltcore/env/actions/carry_tables.go +++ b/go/libraries/doltcore/env/actions/carry_tables.go @@ -46,7 +46,10 @@ func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.Root return carryForeignKeys(ctx, src, dest, carried, tagRemaps) } -// uncommittedSchemas returns the schemas of tables that are in |src| but not in |baseline|. +// uncommittedSchemas returns the schemas of tables that are in |src| but not in |baseline|, +// excluding system tables and tables that match a dolt_ignore pattern on |src|. System tables +// hold per-branch configuration and ignored tables were never meant to be tracked, so neither +// should cross branches during a carry. func uncommittedSchemas(ctx context.Context, src, baseline doltdb.RootValue) (map[doltdb.TableName]schema.Schema, error) { untracked, err := doltdb.GetAllSchemas(ctx, src) if err != nil { @@ -59,7 +62,24 @@ func uncommittedSchemas(ctx context.Context, src, baseline doltdb.RootValue) (ma for _, name := range baselineNames { delete(untracked, name) } - return untracked, nil + for name := range untracked { + if doltdb.IsSystemTable(name) { + delete(untracked, name) + } + } + candidates := make([]doltdb.TableName, 0, len(untracked)) + for name := range untracked { + candidates = append(candidates, name) + } + kept, err := doltdb.ExcludeIgnoredTables(ctx, doltdb.Roots{Working: src, Staged: src, Head: src}, candidates) + if err != nil { + return nil, err + } + result := make(map[doltdb.TableName]schema.Schema, len(kept)) + for _, name := range kept { + result[name] = untracked[name] + } + return result, nil } // carryTables copies each entry of |toCarry| from |src| into |dest|, retagging columns whose diff --git a/go/libraries/doltcore/sqle/statspro/worker_test.go b/go/libraries/doltcore/sqle/statspro/worker_test.go index 9c33708f816..65a68ddb67b 100644 --- a/go/libraries/doltcore/sqle/statspro/worker_test.go +++ b/go/libraries/doltcore/sqle/statspro/worker_test.go @@ -998,13 +998,15 @@ func TestStatsBranchConcurrency(t *testing.T) { require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_checkout('"+branchName+"')")) require.NoError(t, executeQuery(ctx, sqlEng, "create table xy (x int primary key, y int)")) require.NoError(t, executeQuery(ctx, sqlEng, "insert into xy values (0,0),(1,1),(2,2),(3,3),(4,4),(5,5), (6,"+strconv.Itoa(i)+")")) + require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_commit('-Am', 'add xy on "+branchName+"')")) require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_stats_wait()")) } dropBranch := func(dropCtx *sql.Context, branchName string) { //log.Println("delete branch: ", branchName) require.NoError(t, executeQuery(ctx, sqlEng, "use mydb")) - del := "call dolt_branch('-d', '" + branchName + "')" + // Force delete because addData commits on each branch so the branch is no longer fully merged into main. + del := "call dolt_branch('-D', '" + branchName + "')" require.NoError(t, executeQuery(ctx, sqlEng, del)) } @@ -1078,7 +1080,8 @@ func TestStatsCacheGrowth(t *testing.T) { require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_checkout('"+branchName+"')")) require.NoError(t, executeQuery(ctx, sqlEng, "create table xy (x int primary key, y int)")) require.NoError(t, executeQuery(ctx, sqlEng, "insert into xy values (0,0),(1,1),(2,2),(3,3),(4,4),(5,5), (6,"+strconv.Itoa(i)+")")) - + // Commit so the table is tracked on this branch and does not carry to main on the next checkout. + require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_commit('-Am', 'add xy on "+branchName+"')")) } iters := 20 From 44503bf41223c434561a2db8cc25d8cbd612a2d5 Mon Sep 17 00:00:00 2001 From: elianddb Date: Mon, 18 May 2026 22:38:25 +0000 Subject: [PATCH 12/23] schema: trim helper docs and tighten consumer error wrapping --- go/cmd/dolt/commands/schcmds/update-tag.go | 4 +-- go/libraries/doltcore/schema/schema_impl.go | 31 ++++--------------- go/libraries/doltcore/schema/schema_test.go | 18 +++++++++++ go/libraries/doltcore/schema/tag.go | 26 ++++++++++++++-- .../dprocedures/dolt_update_column_tag.go | 3 +- 5 files changed, 52 insertions(+), 30 deletions(-) diff --git a/go/cmd/dolt/commands/schcmds/update-tag.go b/go/cmd/dolt/commands/schcmds/update-tag.go index 1243627f1e4..e2ec7e62e01 100644 --- a/go/cmd/dolt/commands/schcmds/update-tag.go +++ b/go/cmd/dolt/commands/schcmds/update-tag.go @@ -87,7 +87,7 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri tbl, tName, ok, err := doltdb.GetTableInsensitive(ctx, root, doltdb.TableName{Name: tableName}) if err != nil { - return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get table").Build(), usage) + return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get table").AddCause(err).Build(), usage) } if !ok { return commands.HandleVErrAndExitCode(errhand.BuildDError("table %s does not exist", tableName).Build(), usage) @@ -95,7 +95,7 @@ func (cmd UpdateTagCmd) Exec(ctx context.Context, commandStr string, args []stri sch, err := tbl.GetSchema(ctx) if err != nil { - return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get schema").Build(), usage) + return commands.HandleVErrAndExitCode(errhand.BuildDError("failed to get schema").AddCause(err).Build(), usage) } newSch, err := schema.WithUpdatedColumnTag(sch, columnName, tag) diff --git a/go/libraries/doltcore/schema/schema_impl.go b/go/libraries/doltcore/schema/schema_impl.go index ede4f3d9a2e..e43b993166f 100644 --- a/go/libraries/doltcore/schema/schema_impl.go +++ b/go/libraries/doltcore/schema/schema_impl.go @@ -132,19 +132,16 @@ func SchemaFromCols(allCols *ColCollection) (Schema, error) { return sch, nil } -// WithRemappedColumnTags returns a copy of |sch| with column and index tags remapped -// according to |tagRemap| (old tag to new tag). All other schema-level metadata -// (comment, collation, primary key order, target row size) is preserved. Indexes -// referencing tags not in |tagRemap| are copied unchanged. Check constraints are -// preserved unchanged because they reference column names, not tags. Returns |sch| -// unchanged when |tagRemap| is empty. +// WithRemappedColumnTags returns a copy of |sch| with column and index tags rewritten via +// |tagRemap|. Schema metadata and check constraints are preserved. Returns |sch| unchanged +// when |tagRemap| is empty. func WithRemappedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, error) { if len(tagRemap) == 0 { return sch, nil } allCols := sch.GetAllCols() - cols := slices.Clone(allCols.GetColumns()) + cols := allCols.GetColumns() for oldTag, newTag := range tagRemap { if idx, ok := allCols.TagToIdx[oldTag]; ok { cols[idx].Tag = newTag @@ -152,25 +149,15 @@ func WithRemappedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, err } pkOrdinals := slices.Clone(sch.GetPkOrdinals()) - newSch, err := NewSchema(NewColCollection(cols...), pkOrdinals, sch.GetCollation(), nil, nil) + newSch, err := NewSchema(NewColCollection(cols...), pkOrdinals, sch.GetCollation(), nil, sch.Checks().Copy()) if err != nil { return nil, err } newSch.SetTargetRowSize(sch.GetTargetRowSize()) newSch.SetComment(sch.GetComment()) - affected := make(map[string]struct{}) - for oldTag := range tagRemap { - for _, idx := range sch.Indexes().IndexesWithTag(oldTag) { - affected[idx.Name()] = struct{}{} - } - } - err = sch.Indexes().Iter(func(idx Index) (stop bool, err error) { - tags := idx.IndexedColumnTags() - if _, ok := affected[idx.Name()]; ok { - tags = RemapTags(tags, tagRemap) - } + tags := RemapTags(idx.IndexedColumnTags(), tagRemap) props := IndexProperties{ IsUnique: idx.IsUnique(), IsSpatial: idx.IsSpatial(), @@ -188,12 +175,6 @@ func WithRemappedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, err return nil, err } - for _, chk := range sch.Checks().AllChecks() { - if _, err = newSch.Checks().AddCheck(chk.Name(), chk.Expression(), chk.Enforced()); err != nil { - return nil, err - } - } - return newSch, nil } diff --git a/go/libraries/doltcore/schema/schema_test.go b/go/libraries/doltcore/schema/schema_test.go index 75b12774717..6f268360688 100644 --- a/go/libraries/doltcore/schema/schema_test.go +++ b/go/libraries/doltcore/schema/schema_test.go @@ -417,3 +417,21 @@ func validateCols(t *testing.T, cols []Column, colColl *ColCollection, msg strin t.Error() } } + +// TestWithRemappedColumnTagsPreservesMetadata verifies that WithRemappedColumnTags carries +// collation, comment, and target row size onto the returned schema. +func TestWithRemappedColumnTagsPreservesMetadata(t *testing.T) { + col := NewColumn("val", 100, types.StringKind, true, NotNullConstraint{}) + sch, err := SchemaFromCols(NewColCollection(col)) + require.NoError(t, err) + require.NoError(t, sch.SetPkOrdinals([]int{0})) + sch.SetCollation(Collation_utf8mb4_general_ci) + sch.SetComment("test comment") + sch.SetTargetRowSize(4096) + + remapped, err := WithRemappedColumnTags(sch, map[uint64]uint64{100: 200}) + require.NoError(t, err) + require.Equal(t, Collation_utf8mb4_general_ci, remapped.GetCollation(), "collation must survive WithRemappedColumnTags") + require.Equal(t, "test comment", remapped.GetComment(), "comment must survive WithRemappedColumnTags") + require.Equal(t, uint16(4096), remapped.GetTargetRowSize(), "target row size must survive WithRemappedColumnTags") +} diff --git a/go/libraries/doltcore/schema/tag.go b/go/libraries/doltcore/schema/tag.go index 53007b059a5..c8e90359a58 100644 --- a/go/libraries/doltcore/schema/tag.go +++ b/go/libraries/doltcore/schema/tag.go @@ -76,8 +76,8 @@ func (tm TagMapping) Size() int { return len(tm) } -// RemapTags returns |tags| with each entry replaced by |remap[t]| if present. Returns |tags| -// unchanged when |remap| is empty. +// RemapTags returns a new slice with each entry of |tags| replaced by its value in |remap|, +// or kept unchanged when absent. The input is returned aliased when |remap| is empty. func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { if len(remap) == 0 { return tags @@ -93,6 +93,28 @@ func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { return out } +// RemapTagsByColumnName returns |tags| rewritten so each tag points at the same-named column +// on |destSch| instead of |srcSch|. When either schema is nil or a tag cannot be resolved on +// both sides, the original |tags| are returned unchanged. +func RemapTagsByColumnName(tags []uint64, srcSch, destSch Schema) []uint64 { + if srcSch == nil || destSch == nil { + return tags + } + out := make([]uint64, len(tags)) + for i, srcTag := range tags { + srcCol, ok := srcSch.GetAllCols().GetByTag(srcTag) + if !ok { + return tags + } + destCol, ok := destSch.GetAllCols().GetByName(srcCol.Name) + if !ok { + return tags + } + out[i] = destCol.Tag + } + return out +} + // AutoGenerateTag generates a random tag that doesn't exist in the provided SuperSchema. // It uses a deterministic random number generator that is seeded with the NomsKinds of any existing columns in the // schema and the NomsKind of the column being added to the schema. Deterministic tag generation means that branches diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go index af3cb82488e..892414eb44c 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go @@ -48,7 +48,8 @@ func doltUpdateColumnTag(ctx *sql.Context, args ...string) (sql.RowIter, error) tbl, tName, ok, err := doltdb.GetTableInsensitive(ctx, root, doltdb.TableName{Name: tableName}) if err != nil { return nil, err - } else if !ok { + } + if !ok { return nil, fmt.Errorf("table %s does not exist", tableName) } From 2e569b91e3763b00a21c94e64c29d6c0d98aa544 Mon Sep 17 00:00:00 2001 From: elianddb Date: Mon, 18 May 2026 22:38:35 +0000 Subject: [PATCH 13/23] actions: add CarryPolicy and split overwrite error by table state --- .../doltcore/env/actions/carry_tables.go | 162 +++++++------- .../doltcore/env/actions/carry_tables_test.go | 119 +++++------ go/libraries/doltcore/env/actions/checkout.go | 199 ++++++++++++------ go/libraries/doltcore/env/actions/errors.go | 34 +-- go/libraries/doltcore/env/actions/reset.go | 2 +- .../sqle/dprocedures/dolt_checkout_helpers.go | 24 +-- .../doltcore/sqle/dprocedures/dolt_rebase.go | 2 +- 7 files changed, 290 insertions(+), 252 deletions(-) diff --git a/go/libraries/doltcore/env/actions/carry_tables.go b/go/libraries/doltcore/env/actions/carry_tables.go index 3a3468c5ed7..9f72acd197f 100644 --- a/go/libraries/doltcore/env/actions/carry_tables.go +++ b/go/libraries/doltcore/env/actions/carry_tables.go @@ -23,18 +23,35 @@ import ( "github.com/dolthub/dolt/go/store/types" ) -// CarryUncommittedTables copies tables that are in |src| but not in |baseline| into |dest|, -// resolving column tag collisions, and carries forward the foreign key entries for those tables -// from |src| into |dest|'s FK collection. Tables already present in |dest| are kept unchanged. -// Callers choose |baseline| to control what counts as "untracked": pass the staged root for -// true untracked semantics or the head root to also include staged-but-uncommitted tables. -func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.RootValue) (doltdb.RootValue, error) { - untracked, err := uncommittedSchemas(ctx, src, baseline) +// CarryPolicy selects which uncommitted tables CarryUncommittedTables copies. +type CarryPolicy int + +const ( + // CarryAll carries every uncommitted table including those matched by dolt_ignore. + CarryAll CarryPolicy = iota + // ExcludeIgnored skips tables matched by the source's dolt_ignore patterns. + ExcludeIgnored +) + +// CarryUncommittedTables copies tables present in |src| but not in |baseline| into |dest|, +// resolving column tag collisions and carrying their foreign keys. Tables already on |dest| +// are kept unchanged. |policy| filters which uncommitted tables are eligible; see CarryPolicy. +func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.RootValue, policy CarryPolicy) (doltdb.RootValue, error) { + srcSchemas, err := doltdb.GetAllSchemas(ctx, src) + if err != nil { + return nil, err + } + destSchemas, err := doltdb.GetAllSchemas(ctx, dest) + if err != nil { + return nil, err + } + + uncommitted, err := uncommittedTableSchemas(ctx, src, srcSchemas, baseline, policy) if err != nil { return nil, err } - dest, carried, tagRemaps, err := carryTables(ctx, src, dest, untracked) + dest, carried, tagRemaps, err := carryTables(ctx, src, dest, uncommitted, destSchemas) if err != nil { return nil, err } @@ -43,56 +60,57 @@ func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.Root return dest, nil } - return carryForeignKeys(ctx, src, dest, carried, tagRemaps) + return carryForeignKeys(ctx, src, dest, carried, tagRemaps, srcSchemas, destSchemas) } -// uncommittedSchemas returns the schemas of tables that are in |src| but not in |baseline|, -// excluding system tables and tables that match a dolt_ignore pattern on |src|. System tables -// hold per-branch configuration and ignored tables were never meant to be tracked, so neither -// should cross branches during a carry. -func uncommittedSchemas(ctx context.Context, src, baseline doltdb.RootValue) (map[doltdb.TableName]schema.Schema, error) { - untracked, err := doltdb.GetAllSchemas(ctx, src) - if err != nil { - return nil, err - } +// uncommittedTableSchemas returns the subset of |srcSchemas| whose names are absent from +// |baseline| and are not dolt system tables. When |policy| is ExcludeIgnored, names matched +// by |src|'s dolt_ignore patterns are also removed. Pattern conflicts on |src| fall through +// to the caller so CheckOverwrittenIgnoredTables can surface them. +func uncommittedTableSchemas(ctx context.Context, src doltdb.RootValue, srcSchemas map[doltdb.TableName]schema.Schema, baseline doltdb.RootValue, policy CarryPolicy) (map[doltdb.TableName]schema.Schema, error) { baselineNames, err := baseline.GetAllTableNames(ctx, false) if err != nil { return nil, err } - for _, name := range baselineNames { - delete(untracked, name) - } - for name := range untracked { - if doltdb.IsSystemTable(name) { - delete(untracked, name) + baselineSet := doltdb.NewTableNameSet(baselineNames) + + var patternsBySchema map[string]doltdb.IgnorePatterns + if policy == ExcludeIgnored && len(srcSchemas) > 0 { + names := make([]doltdb.TableName, 0, len(srcSchemas)) + for n := range srcSchemas { + names = append(names, n) + } + patternsBySchema, err = doltdb.GetIgnoredTablePatterns(ctx, doltdb.Roots{Working: src, Staged: src, Head: src}, doltdb.GetUniqueSchemaNamesFromTableNames(names)) + if err != nil { + return nil, err } } - candidates := make([]doltdb.TableName, 0, len(untracked)) - for name := range untracked { - candidates = append(candidates, name) - } - kept, err := doltdb.ExcludeIgnoredTables(ctx, doltdb.Roots{Working: src, Staged: src, Head: src}, candidates) - if err != nil { - return nil, err - } - result := make(map[doltdb.TableName]schema.Schema, len(kept)) - for _, name := range kept { - result[name] = untracked[name] + + uncommitted := make(map[doltdb.TableName]schema.Schema, len(srcSchemas)) + for name, sch := range srcSchemas { + if baselineSet.Contains(name) || doltdb.IsSystemTable(name) { + continue + } + if patternsBySchema != nil { + patterns := patternsBySchema[name.Schema] + result, ignoreErr := patterns.IsTableNameIgnored(name) + if doltdb.AsDoltIgnoreInConflict(ignoreErr) == nil && ignoreErr != nil { + return nil, ignoreErr + } + if result == doltdb.Ignore { + continue + } + } + uncommitted[name] = sch } - return result, nil + return uncommitted, nil } // carryTables copies each entry of |toCarry| from |src| into |dest|, retagging columns whose -// tag collides with one already in |dest| or with one assigned to an earlier carried table. -// Tables whose name already exists in |dest| are skipped so the dest version wins. Returns -// the updated |dest|, the names that were carried, and the tag remaps applied per table for -// downstream FK fixup. -func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[doltdb.TableName]schema.Schema) (doltdb.RootValue, []doltdb.TableName, map[doltdb.TableName]map[uint64]uint64, error) { - destSchemas, err := doltdb.GetAllSchemas(ctx, dest) - if err != nil { - return nil, nil, nil, err - } - +// tag collides with one held by |dest| or by an earlier carried table. Names already on |dest| +// are skipped so the dest version wins. Returns the updated |dest|, the names that were +// carried, and the per-table tag remaps so the caller can fix up foreign keys. +func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[doltdb.TableName]schema.Schema, destSchemas map[doltdb.TableName]schema.Schema) (doltdb.RootValue, []doltdb.TableName, map[doltdb.TableName]map[uint64]uint64, error) { heldTags := make(schema.TagMapping) for tblName, tblSch := range destSchemas { for _, t := range tblSch.GetAllCols().Tags { @@ -111,7 +129,7 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[do return nil, nil, nil, err } if !exists { - return nil, nil, nil, fmt.Errorf("table %s does not exist in src root", name) + return nil, nil, nil, fmt.Errorf("table %q does not exist in src root", name) } columns := sch.GetAllCols().GetColumns() tagRemap := make(map[uint64]uint64) @@ -138,7 +156,7 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[do } dest, err = dest.PutTable(ctx, name, tbl) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to write table back to database: %w", err) + return nil, nil, nil, err } carried = append(carried, name) } @@ -146,13 +164,12 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[do } // carryForeignKeys copies foreign keys from |src| whose child table is in |carried| into -// |dest|'s FK collection, applies |tagRemaps| so retagged column references are updated, and -// returns the updated |dest|. Foreign keys already present in |dest| (e.g. pre-merged by an -// upstream step) are skipped to avoid ErrForeignKeyDuplicateName. When a carried foreign key -// references a parent table that already exists on |dest|, the referenced column tags are -// re-resolved against |dest|'s parent schema by column name so the foreign key remains valid -// even when the same parent column has a different internal tag on |src| and |dest|. -func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried []doltdb.TableName, tagRemaps map[doltdb.TableName]map[uint64]uint64) (doltdb.RootValue, error) { +// |dest|'s FK collection and applies |tagRemaps| so retagged child columns stay consistent. +// Keys already present on |dest| are skipped so a pre-merged FK is not duplicated. When a +// carried key references a parent that already lives on |dest|, the referenced column tags +// are re-resolved by column name against |destSchemas| so the same column can carry a +// different internal tag on each branch and the foreign key stays valid. +func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried []doltdb.TableName, tagRemaps map[doltdb.TableName]map[uint64]uint64, srcSchemas, destSchemas map[doltdb.TableName]schema.Schema) (doltdb.RootValue, error) { destFks, err := dest.GetForeignKeyCollection(ctx) if err != nil { return nil, err @@ -161,27 +178,19 @@ func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried [ if err != nil { return nil, err } - srcSchs, err := doltdb.GetAllSchemas(ctx, src) - if err != nil { - return nil, err - } - destSchs, err := doltdb.GetAllSchemas(ctx, dest) - if err != nil { - return nil, err - } carriedSet := doltdb.NewTableNameSet(carried) err = srcFks.Iter(func(fk doltdb.ForeignKey) (stop bool, err error) { if !carriedSet.Contains(fk.TableName) { return false, nil } - if _, exists := destFks.GetByNameCaseInsensitive(fk.Name, fk.TableName); exists { + if destFks.Contains(fk.Name, fk.TableName) { return false, nil } fk.TableColumns = schema.RemapTags(fk.TableColumns, tagRemaps[fk.TableName]) if carriedSet.Contains(fk.ReferencedTableName) { fk.ReferencedTableColumns = schema.RemapTags(fk.ReferencedTableColumns, tagRemaps[fk.ReferencedTableName]) } else { - fk.ReferencedTableColumns = remapTagsByColumnName(fk.ReferencedTableColumns, srcSchs[fk.ReferencedTableName], destSchs[fk.ReferencedTableName]) + fk.ReferencedTableColumns = schema.RemapTagsByColumnName(fk.ReferencedTableColumns, srcSchemas[fk.ReferencedTableName], destSchemas[fk.ReferencedTableName]) } return false, destFks.AddKeys(fk) }) @@ -190,26 +199,3 @@ func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried [ } return dest.PutForeignKeyCollection(ctx, destFks) } - -// remapTagsByColumnName returns |tags| rewritten so each tag points at the same-named column -// on |destSch| instead of |srcSch|. When either schema is nil or any tag cannot be -// resolved on both sides, the original |tags| are returned so the caller can proceed and let -// downstream validation surface any genuine breakage. -func remapTagsByColumnName(tags []uint64, srcSch, destSch schema.Schema) []uint64 { - if srcSch == nil || destSch == nil { - return tags - } - out := make([]uint64, len(tags)) - for i, srcTag := range tags { - srcCol, ok := srcSch.GetAllCols().GetByTag(srcTag) - if !ok { - return tags - } - destCol, ok := destSch.GetAllCols().GetByName(srcCol.Name) - if !ok { - return tags - } - out[i] = destCol.Tag - } - return out -} diff --git a/go/libraries/doltcore/env/actions/carry_tables_test.go b/go/libraries/doltcore/env/actions/carry_tables_test.go index 62d4df80f0c..450736c6b87 100644 --- a/go/libraries/doltcore/env/actions/carry_tables_test.go +++ b/go/libraries/doltcore/env/actions/carry_tables_test.go @@ -25,14 +25,28 @@ import ( "github.com/dolthub/dolt/go/store/types" ) +// collidingTag is used for both bar.code and users.name to engineer a tag collision that +// CarryUncommittedTables must resolve via a retag. const ( - barCodeTag uint64 = 9815 - usersNameTag uint64 = 9815 // deliberate collision with barCodeTag + collidingTag uint64 = 9815 + barCodeTag = collidingTag + usersNameTag = collidingTag postsTitleTag uint64 = 13593 ) +// putFK installs a single-entry foreign key collection onto |root| and returns the updated root. +func putFK(t *testing.T, ctx context.Context, root doltdb.RootValue, fk doltdb.ForeignKey) doltdb.RootValue { + t.Helper() + fks, err := doltdb.NewForeignKeyCollection(fk) + require.NoError(t, err) + updated, err := root.PutForeignKeyCollection(ctx, fks) + require.NoError(t, err) + return updated +} + // newEmptyRoot returns a context and the empty working root of a freshly initialised repo. func newEmptyRoot(t *testing.T) (context.Context, doltdb.RootValue) { + t.Helper() dEnv, _ := createTestEnv() ctx := context.Background() require.NoError(t, dEnv.InitRepo(ctx, types.Format_Default, "test user", "test@test.com", "main")) @@ -41,8 +55,9 @@ func newEmptyRoot(t *testing.T) (context.Context, doltdb.RootValue) { return ctx, emptyRoot } -// singleColPkSchema builds a one-column primary key schema using |colName| and |tag|. -func singleColPkSchema(t *testing.T, colName string, tag uint64) schema.Schema { +// singleColPKSchema builds a one-column primary key schema using |colName| and |tag|. +func singleColPKSchema(t *testing.T, colName string, tag uint64) schema.Schema { + t.Helper() col := schema.NewColumn(colName, tag, types.StringKind, true, schema.NotNullConstraint{}) sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) require.NoError(t, err) @@ -50,9 +65,9 @@ func singleColPkSchema(t *testing.T, colName string, tag uint64) schema.Schema { return sch } -// TestCarryUncommittedTablesPreexistingFK verifies that CarryUncommittedTables does not return an error +// TestCarryUncommittedTablesDoesNotDuplicatePreexistingFK verifies that CarryUncommittedTables does not return an error // when the target foreign key collection already contains a key being carried. -func TestCarryUncommittedTablesPreexistingFK(t *testing.T) { +func TestCarryUncommittedTablesDoesNotDuplicatePreexistingFK(t *testing.T) { // See https://github.com/dolthub/dolt/issues/11007 ctx, emptyRoot := newEmptyRoot(t) @@ -60,29 +75,25 @@ func TestCarryUncommittedTablesPreexistingFK(t *testing.T) { const parentTag uint64 = 50001 const childTag uint64 = 50002 - target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPkSchema(t, "id", parentTag)) + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTag)) require.NoError(t, err) working := emptyRoot - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPkSchema(t, "pid", childTag)) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPKSchema(t, "pid", childTag)) require.NoError(t, err) - fks, err := doltdb.NewForeignKeyCollection(doltdb.ForeignKey{ + fk := doltdb.ForeignKey{ Name: "fk_pre", TableName: doltdb.TableName{Name: "child"}, TableColumns: []uint64{childTag}, ReferencedTableName: doltdb.TableName{Name: "parent"}, ReferencedTableColumns: []uint64{parentTag}, - }) - require.NoError(t, err) - working, err = working.PutForeignKeyCollection(ctx, fks) - require.NoError(t, err) - + } + working = putFK(t, ctx, working, fk) // Simulate the FK pre-merge that RootsForBranch performs before calling CarryUncommittedTables. - target, err = target.PutForeignKeyCollection(ctx, fks) - require.NoError(t, err) + target = putFK(t, ctx, target, fk) - result, err := CarryUncommittedTables(ctx, working, emptyRoot, target) + result, err := CarryUncommittedTables(ctx, working, emptyRoot, target, CarryAll) require.NoError(t, err) resultFks, err := result.GetForeignKeyCollection(ctx) @@ -103,28 +114,25 @@ func TestCarryUncommittedTablesParentTagMismatch(t *testing.T) { const parentTagOnTarget uint64 = 60002 const childTag uint64 = 60003 - target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPkSchema(t, "id", parentTagOnTarget)) + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTagOnTarget)) require.NoError(t, err) - working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPkSchema(t, "id", parentTagOnSource)) + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTagOnSource)) require.NoError(t, err) - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPkSchema(t, "pid", childTag)) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPKSchema(t, "pid", childTag)) require.NoError(t, err) - fks, err := doltdb.NewForeignKeyCollection(doltdb.ForeignKey{ + working = putFK(t, ctx, working, doltdb.ForeignKey{ Name: "fk_cross", TableName: doltdb.TableName{Name: "child"}, TableColumns: []uint64{childTag}, ReferencedTableName: doltdb.TableName{Name: "parent"}, ReferencedTableColumns: []uint64{parentTagOnSource}, }) - require.NoError(t, err) - working, err = working.PutForeignKeyCollection(ctx, fks) - require.NoError(t, err) - // sourceStaged is emptyRoot so child is untracked and parent (in working) is treated the same way. - // parent already exists in target, so carryTables skips it and only child is carried. - result, err := CarryUncommittedTables(ctx, working, target, target) + // |baseline| is target so only the child table is carried. The parent table already exists on + // target and is filtered out of the carry candidates. + result, err := CarryUncommittedTables(ctx, working, target, target, CarryAll) require.NoError(t, err) resultFks, err := result.GetForeignKeyCollection(ctx) @@ -149,30 +157,27 @@ func TestCarryUncommittedTables(t *testing.T) { return sch } - target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPkSchema(t, "code", barCodeTag)) + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", barCodeTag)) require.NoError(t, err) // Source has bar (so the foreign key can reference it), plus untracked users and posts. - working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPkSchema(t, "code", barCodeTag)) + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", barCodeTag)) require.NoError(t, err) - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, withUniqueIndex(singleColPkSchema(t, "name", usersNameTag), "idx_name", usersNameTag)) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, withUniqueIndex(singleColPKSchema(t, "name", usersNameTag), "idx_name", usersNameTag)) require.NoError(t, err) - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, singleColPkSchema(t, "title", postsTitleTag)) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, singleColPKSchema(t, "title", postsTitleTag)) require.NoError(t, err) - fks, err := doltdb.NewForeignKeyCollection(doltdb.ForeignKey{ + working = putFK(t, ctx, working, doltdb.ForeignKey{ Name: "fk_bar", TableName: doltdb.TableName{Name: "users"}, TableColumns: []uint64{usersNameTag}, ReferencedTableName: doltdb.TableName{Name: "bar"}, ReferencedTableColumns: []uint64{barCodeTag}, }) - require.NoError(t, err) - working, err = working.PutForeignKeyCollection(ctx, fks) - require.NoError(t, err) - // sourceStaged is target so bar is treated as already-tracked on source and only users and posts are carried. - result, err := CarryUncommittedTables(ctx, working, target, target) + // |baseline| is target so bar is treated as tracked on source and only users and posts are carried. + result, err := CarryUncommittedTables(ctx, working, target, target, CarryAll) require.NoError(t, err) t.Run("preserves tag on non-collision", func(t *testing.T) { @@ -186,26 +191,7 @@ func TestCarryUncommittedTables(t *testing.T) { require.Equal(t, postsTitleTag, col.Tag, "posts.title tag must not change when there is no collision") }) - t.Run("WithRemappedColumnTags preserves schema metadata", func(t *testing.T) { - col := schema.NewColumn("val", 100, types.StringKind, true, schema.NotNullConstraint{}) - sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) - require.NoError(t, err) - require.NoError(t, sch.SetPkOrdinals([]int{0})) - sch.SetCollation(schema.Collation_utf8mb4_general_ci) - sch.SetComment("test comment") - sch.SetTargetRowSize(4096) - - remapped, err := schema.WithRemappedColumnTags(sch, map[uint64]uint64{100: 200}) - require.NoError(t, err) - require.Equal(t, schema.Collation_utf8mb4_general_ci, remapped.GetCollation(), "collation must survive WithRemappedColumnTags") - require.Equal(t, "test comment", remapped.GetComment(), "comment must survive WithRemappedColumnTags") - require.Equal(t, uint16(4096), remapped.GetTargetRowSize(), "target row size must survive WithRemappedColumnTags") - }) - t.Run("retags colliding column", func(t *testing.T) { - // AutoGenerateTag deterministically picks this tag when 9815 is already occupied. - // The value is stable even though Go maps are iterated in random order each test run. - const wantTag uint64 = 12204 tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) require.NoError(t, err) require.True(t, ok, "users table must be present in the result") @@ -213,25 +199,32 @@ func TestCarryUncommittedTables(t *testing.T) { require.NoError(t, err) col, ok := sch.GetAllCols().GetByName("name") require.True(t, ok) - require.Equal(t, wantTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") + require.NotEqual(t, barCodeTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") idx, ok := sch.Indexes().GetByNameCaseInsensitive("idx_name") require.True(t, ok, "idx_name must survive the retag") - require.Equal(t, []uint64{wantTag}, idx.IndexedColumnTags(), "idx_name must reference the retagged column, not the old tag") + require.Equal(t, []uint64{col.Tag}, idx.IndexedColumnTags(), "idx_name must reference the retagged column, not the old tag") require.True(t, idx.IsUnique(), "idx_name is unique and must remain so after retag") require.True(t, idx.IsUserDefined(), "idx_name is user-defined and must remain so after retag") }) t.Run("FK child column retagged, parent column unchanged", func(t *testing.T) { - // users.name and bar.code share tag 9815 so CarryUncommittedTables retagged users.name. - // Only the child side of the FK changes because bar.code is committed and its tag is not affected by the carry. - const wantTag uint64 = 12204 + // users.name and bar.code share collidingTag so users.name is retagged. bar.code is committed + // on the target and keeps its tag, so only the child side of the FK changes. + usersTbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) + require.NoError(t, err) + require.True(t, ok) + usersSch, err := usersTbl.GetSchema(ctx) + require.NoError(t, err) + nameCol, ok := usersSch.GetAllCols().GetByName("name") + require.True(t, ok) + resultFks, err := result.GetForeignKeyCollection(ctx) require.NoError(t, err) got, ok := resultFks.GetByNameCaseInsensitive("fk_bar", doltdb.TableName{Name: "users"}) require.True(t, ok, "fk_bar must be present in result FK collection") - require.Equal(t, []uint64{wantTag}, got.TableColumns, - "child column must be updated to the retagged value") + require.Equal(t, []uint64{nameCol.Tag}, got.TableColumns, + "child column must follow the retagged users.name tag") require.Equal(t, []uint64{barCodeTag}, got.ReferencedTableColumns, "committed parent column tag must not change") }) diff --git a/go/libraries/doltcore/env/actions/checkout.go b/go/libraries/doltcore/env/actions/checkout.go index 63b46a26af1..528b981b934 100644 --- a/go/libraries/doltcore/env/actions/checkout.go +++ b/go/libraries/doltcore/env/actions/checkout.go @@ -16,6 +16,7 @@ package actions import ( "context" + "maps" "slices" "strings" "time" @@ -120,12 +121,11 @@ func FindTableInRoots(ctx *sql.Context, roots doltdb.Roots, name string) (doltdb return doltdb.TableName{}, nil, false, nil } -// CheckUncommittedConflicts returns ErrCheckoutWouldOverwrite if any table that is uncommitted -// in |roots| (present in Working but absent from Head, covering both untracked and staged-but- -// uncommitted tables) would be overwritten by a different committed version of the same table -// on |targetRoot|. Tables whose working content matches the committed version on |targetRoot| -// are not considered conflicts. -func CheckUncommittedConflicts(ctx context.Context, roots doltdb.Roots, targetRoot doltdb.RootValue) error { +// CheckoutWouldOverwriteUncommittedTables returns ErrCheckoutWouldOverwrite when any table +// in |roots.Working| but absent from |roots.Head| differs from the committed version on +// |targetRoot|. Tables also present in |roots.Staged| are reported as local changes, while +// tables only in |roots.Working| are reported as untracked. +func CheckoutWouldOverwriteUncommittedTables(ctx context.Context, roots doltdb.Roots, targetRoot doltdb.RootValue) error { workingNames, err := roots.Working.GetAllTableNames(ctx, false) if err != nil { return err @@ -134,11 +134,16 @@ func CheckUncommittedConflicts(ctx context.Context, roots doltdb.Roots, targetRo if err != nil { return err } + stagedNames, err := roots.Staged.GetAllTableNames(ctx, false) + if err != nil { + return err + } headNamesSet := doltdb.NewTableNameSet(headNames) + stagedNamesSet := doltdb.NewTableNameSet(stagedNames) - var conflicts []string + var localChange, untracked []string for _, name := range workingNames { - if headNamesSet.Contains(name) { + if headNamesSet.Contains(name) || doltdb.IsSystemTable(name) { continue } targetHash, _, err := targetRoot.GetTableHash(ctx, name) @@ -152,23 +157,28 @@ func CheckUncommittedConflicts(ctx context.Context, roots doltdb.Roots, targetRo if err != nil { return err } - if workingHash != targetHash { - conflicts = append(conflicts, name.Name) + if workingHash == targetHash { + continue + } + if stagedNamesSet.Contains(name) { + localChange = append(localChange, name.Name) + } else { + untracked = append(untracked, name.Name) } } - if len(conflicts) > 0 { - slices.Sort(conflicts) - return ErrCheckoutWouldOverwrite{conflicts} + if len(localChange) > 0 || len(untracked) > 0 { + slices.Sort(localChange) + slices.Sort(untracked) + return ErrCheckoutWouldOverwrite{LocalChangeTables: localChange, UntrackedTables: untracked} } return nil } -// RootsForBranch returns the roots needed for a branch checkout. |roots.Head| should be the -// pre-checkout head. The returned roots struct has |Head| set to |branchRoot|. -// -// Uncommitted tables (present in working or staged but absent from the old Head) are moved into -// the new root via [CarryUncommittedTables], which resolves any column tag collisions. +// RootsForBranch returns the roots for checking out a branch whose head is |branchRoot|. +// |roots.Head| must be the pre-checkout head. Uncommitted tables, those present in working +// or staged but absent from the old head, are moved into the new root via +// [CarryUncommittedTables] so any column tag collisions are resolved. func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.RootValue, force bool) (doltdb.Roots, error) { conflicts := doltdb.NewTableNameSet(nil) if roots.Head == nil { @@ -179,15 +189,16 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R } if !force { - if err := CheckUncommittedConflicts(ctx, roots, branchRoot); err != nil { + if err := CheckoutWouldOverwriteUncommittedTables(ctx, roots, branchRoot); err != nil { return doltdb.Roots{}, err } } + // |force| skips the conflict check. The carry step below drops source tables whose name + // already exists on the destination so the destination version wins. - // Snapshot roots before threeWayMergeTableHashes updates them so CarryUncommittedTables uses the pre-checkout values. - preCheckoutWorking := roots.Working - preCheckoutStaged := roots.Staged - preCheckoutHead := roots.Head + // Snapshot the pre-checkout roots before the three-way merge below reassigns roots.Working + // and roots.Staged so the carry step still sees the original values. + preCheckout := roots wrkTblHashes, err := threeWayMergeTableHashes(ctx, roots.Head, branchRoot, roots.Working, conflicts, force) if err != nil { @@ -200,7 +211,7 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R } if conflicts.Size() > 0 { - return doltdb.Roots{}, ErrCheckoutWouldOverwrite{conflicts.AsStringSlice()} + return doltdb.Roots{}, ErrCheckoutWouldOverwrite{LocalChangeTables: conflicts.AsStringSlice()} } workingForeignKeys, err := threeWayMergeForeignKeys(ctx, roots.Head, branchRoot, roots.Working, force) @@ -223,7 +234,7 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } - // Put the merged foreign key collections so CarryUncommittedTables adds untracked keys on top. + // Put the merged collections first so CarryUncommittedTables layers untracked keys on top. roots.Working, err = roots.Working.PutForeignKeyCollection(ctx, workingForeignKeys) if err != nil { return doltdb.Roots{}, err @@ -234,12 +245,13 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } - roots.Working, err = CarryUncommittedTables(ctx, preCheckoutWorking, preCheckoutHead, roots.Working) + // Ignored tables stay on the source branch because dolt_ignore is branch-scoped. + roots.Working, err = CarryUncommittedTables(ctx, preCheckout.Working, preCheckout.Head, roots.Working, ExcludeIgnored) if err != nil { return doltdb.Roots{}, err } - roots.Staged, err = CarryUncommittedTables(ctx, preCheckoutStaged, preCheckoutHead, roots.Staged) + roots.Staged, err = CarryUncommittedTables(ctx, preCheckout.Staged, preCheckout.Head, roots.Staged, ExcludeIgnored) if err != nil { return doltdb.Roots{}, err } @@ -248,7 +260,8 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return roots, nil } -// CleanOldWorkingSet resets the source branch's working set to the branch head, leaving the source branch unchanged. +// CleanOldWorkingSet resets the source branch's working set to its head so uncommitted +// changes do not remain after a checkout has moved them onto the destination branch. func CleanOldWorkingSet( ctx *sql.Context, dbData env.DbData[*sql.Context], @@ -286,8 +299,9 @@ func CleanOldWorkingSet( Staged: workingSet.StagedRoot(), } - // we also have to do a clean, because we the ResetHard won't touch any new tables (tables only in the working set) - newRoots, err := CleanUntracked(ctx, resetRoots, []string{}, false, true, false) + // we also have to do a clean, because we the ResetHard won't touch any new tables (tables only in the working set). + // Respect ignore rules so dolt_ignore-matched tables stay on the source branch per the documented policy. + newRoots, err := CleanUntracked(ctx, resetRoots, []string{}, false, true, true) if err != nil { return err } @@ -341,12 +355,11 @@ func BranchHeadRoot(ctx context.Context, db *doltdb.DoltDB, brName string) (dolt return branchRoot, nil } -// threeWayMergeTableHashes performs a 3-way merge of per-table hashes from |oldRoot|, |newRoot|, -// and |changedRoot|. For each table the returned map picks a winning hash: |changedRoot|'s hash -// when |newRoot| matches |oldRoot|, |newRoot|'s hash when |changedRoot| matches |oldRoot|, or -// |newRoot|'s hash when |force| is true. Tables modified on both sides without |force| are added -// to |conflicts| for the caller to surface as ErrCheckoutWouldOverwrite. Uncommitted tables are -// skipped here and carried by [CarryUncommittedTables] instead. +// threeWayMergeTableHashes performs a 3-way merge of per-table hashes across |oldRoot|, +// |newRoot|, and |changedRoot|. Each table picks the side that changed against |oldRoot|, or +// |newRoot| when |force| is true. Tables changed on both sides go into |conflicts| so the +// caller can surface ErrCheckoutWouldOverwrite. Uncommitted tables are skipped here and +// handled by [CarryUncommittedTables]. func threeWayMergeTableHashes(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, conflicts *doltdb.TableNameSet, force bool) (map[doltdb.TableName]hash.Hash, error) { resultMap := make(map[doltdb.TableName]hash.Hash) tblNames, err := doltdb.UnionTableNames(ctx, newRoot) @@ -398,7 +411,10 @@ func threeWayMergeTableHashes(ctx context.Context, oldRoot, newRoot, changedRoot return nil, err } - // Uncommitted tables are carried forward by CarryUncommittedTables instead. + // Skip uncommitted tables here so CarryUncommittedTables can pick them up after + // the merged tracked state lands on the destination. Carry needs that final state + // to detect column tag collisions and to rewrite foreign key references against + // the destination's parent schemas. if oldHash == emptyHash { continue } else if force { @@ -586,7 +602,7 @@ func mergeForeignKeyChanges( } if conflicts.Size() > 0 { - return nil, ErrCheckoutWouldOverwrite{conflicts.AsStringSlice()} + return nil, ErrCheckoutWouldOverwrite{LocalChangeTables: conflicts.AsStringSlice()} } fks := make([]doltdb.ForeignKey, 0) @@ -631,47 +647,96 @@ func writeTableHashes(ctx context.Context, head doltdb.RootValue, tblHashes map[ return head, nil } -// CheckoutWouldStompWorkingSetChanges checks that the current working set is "compatible" with the dest working set. -// This means that if both working sets are present (ie there are changes on both source and dest branches), -// we check if the changes are identical before allowing a clobbering checkout. -// Working set errors are ignored by this function, because they are properly handled elsewhere. -func CheckoutWouldStompWorkingSetChanges(ctx context.Context, sourceRoots, destRoots doltdb.Roots) (bool, error) { - - wouldStomp := doRootsHaveIncompatibleChanges(sourceRoots, destRoots) - - if !wouldStomp { - return false, nil +// CheckoutWouldOverwriteWorkingSetChanges reports whether a checkout would overwrite +// uncommitted changes on the destination. System and dolt_ignore-matched tables are excluded. +func CheckoutWouldOverwriteWorkingSetChanges(ctx context.Context, sourceRoots, destRoots doltdb.Roots) (bool, error) { + srcTracked, err := buildTrackedPredicate(ctx, sourceRoots.Working) + if err != nil { + return false, err } - - // In some cases, a working set differs from its head only by the feature version. - // If this is the case, moving the working set is safe. - modifiedSourceRoots, err := ClearFeatureVersion(ctx, sourceRoots) + sourceHas, err := rootsHaveTrackedChanges(ctx, sourceRoots, srcTracked) + if err != nil || !sourceHas { + return false, err + } + destTracked, err := buildTrackedPredicate(ctx, destRoots.Working) if err != nil { - return true, err + return false, err } + return rootsHaveTrackedChanges(ctx, destRoots, destTracked) +} - modifiedDestRoots, err := ClearFeatureVersion(ctx, destRoots) +// buildTrackedPredicate returns a closure that reports whether a table is tracked for the +// purpose of detecting uncommitted-change conflicts. The closure returns false for system +// tables and for tables matched by a dolt_ignore pattern on |root|. Patterns are read once so +// callers can reuse the closure across multiple roots. +func buildTrackedPredicate(ctx context.Context, root doltdb.RootValue) (func(doltdb.TableName) (bool, error), error) { + names, err := root.GetAllTableNames(ctx, false) if err != nil { - return true, err + return nil, err } - - return doRootsHaveIncompatibleChanges(modifiedSourceRoots, modifiedDestRoots), nil + schemas := doltdb.GetUniqueSchemaNamesFromTableNames(names) + patternsBySchema, err := doltdb.GetIgnoredTablePatterns(ctx, doltdb.Roots{Working: root, Staged: root, Head: root}, schemas) + if err != nil { + return nil, err + } + return func(n doltdb.TableName) (bool, error) { + if doltdb.IsSystemTable(n) { + return false, nil + } + patterns := patternsBySchema[n.Schema] + result, ignoreErr := patterns.IsTableNameIgnored(n) + if doltdb.AsDoltIgnoreInConflict(ignoreErr) != nil { + return true, nil + } + if ignoreErr != nil { + return false, ignoreErr + } + return result != doltdb.Ignore, nil + }, nil } -func doRootsHaveIncompatibleChanges(sourceRoots, destRoots doltdb.Roots) bool { - sourceHasChanges, sourceWorkingHash, sourceStagedHash, err := RootHasUncommittedChanges(sourceRoots) +// rootsHaveTrackedChanges reports whether |roots| have uncommitted changes on any table that +// |isTracked| accepts. Working differing from head, or staged differing from head, on at least +// one tracked table returns true. +func rootsHaveTrackedChanges(ctx context.Context, roots doltdb.Roots, isTracked func(doltdb.TableName) (bool, error)) (bool, error) { + hashes := func(root doltdb.RootValue) (map[doltdb.TableName]hash.Hash, error) { + names, err := root.GetAllTableNames(ctx, false) + if err != nil { + return nil, err + } + out := make(map[doltdb.TableName]hash.Hash, len(names)) + for _, n := range names { + tracked, err := isTracked(n) + if err != nil { + return nil, err + } + if !tracked { + continue + } + h, _, err := root.GetTableHash(ctx, n) + if err != nil { + return nil, err + } + out[n] = h + } + return out, nil + } + working, err := hashes(roots.Working) if err != nil { - return false + return false, err } - - destHasChanges, destWorkingHash, destStagedHash, err := RootHasUncommittedChanges(destRoots) + head, err := hashes(roots.Head) if err != nil { - return false + return false, err } - - // This is a stomping checkout operation if both the source and dest have uncommitted changes, and they're not the - // same uncommitted changes - return sourceHasChanges && destHasChanges && (sourceWorkingHash != destWorkingHash || sourceStagedHash != destStagedHash) + if !maps.Equal(working, head) { + return true, nil + } + staged, err := hashes(roots.Staged) + if err != nil { + return false, err + } + return !maps.Equal(staged, head), nil } // ClearFeatureVersion creates a new version of the provided roots where all three roots have the same diff --git a/go/libraries/doltcore/env/actions/errors.go b/go/libraries/doltcore/env/actions/errors.go index 2a5b1dd9643..c2b122208cb 100644 --- a/go/libraries/doltcore/env/actions/errors.go +++ b/go/libraries/doltcore/env/actions/errors.go @@ -115,18 +115,30 @@ func GetTablesForError(err error) []doltdb.TableName { return te.tables } +// ErrCheckoutWouldOverwrite reports the conflicting tables a checkout would overwrite. +// |LocalChangeTables| lists tables with tracked or staged changes the user can commit or stash. +// |UntrackedTables| lists tables present only in the working set the user must move or remove. type ErrCheckoutWouldOverwrite struct { - tables []string + LocalChangeTables []string + UntrackedTables []string } func (cwo ErrCheckoutWouldOverwrite) Error() string { var buffer bytes.Buffer - buffer.WriteString("Your local changes to the following tables would be overwritten by checkout:\n") - for _, tbl := range cwo.tables { - buffer.WriteString("\t" + tbl + "\n") + if len(cwo.LocalChangeTables) > 0 { + buffer.WriteString("Your local changes to the following tables would be overwritten by checkout:\n") + for _, tbl := range cwo.LocalChangeTables { + buffer.WriteString("\t" + tbl + "\n") + } + buffer.WriteString("Please commit your changes or stash them before you switch branches.\n") + } + if len(cwo.UntrackedTables) > 0 { + buffer.WriteString("The following untracked tables would be overwritten by checkout:\n") + for _, tbl := range cwo.UntrackedTables { + buffer.WriteString("\t" + tbl + "\n") + } + buffer.WriteString("Please move or remove them before you switch branches.\n") } - - buffer.WriteString("Please commit your changes or stash them before you switch branches.\n") buffer.WriteString("Aborting") return buffer.String() } @@ -136,16 +148,6 @@ func IsCheckoutWouldOverwrite(err error) bool { return ok } -func CheckoutWouldOverwriteTables(err error) []string { - cwo, ok := err.(ErrCheckoutWouldOverwrite) - - if !ok { - panic("Must validate with IsCheckoutWouldOverwrite before calling CheckoutWouldOverwriteTables") - } - - return cwo.tables -} - var ErrCheckoutWouldOverwriteIgnoredTables = goerrors.NewKind( "The following ignored tables would be overwritten by checkout:\n\t%s\n" + "Please move or remove them before you switch branches.\n" + diff --git a/go/libraries/doltcore/env/actions/reset.go b/go/libraries/doltcore/env/actions/reset.go index cf59b995f5b..d8c129e3271 100644 --- a/go/libraries/doltcore/env/actions/reset.go +++ b/go/libraries/doltcore/env/actions/reset.go @@ -63,7 +63,7 @@ func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr str } } - newWorking, err := CarryUncommittedTables(ctx, roots.Working, roots.Staged, roots.Head) + newWorking, err := CarryUncommittedTables(ctx, roots.Working, roots.Staged, roots.Head, CarryAll) if err != nil { return nil, doltdb.Roots{}, err } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go index b570b9a2943..efb6a8fcafa 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go @@ -68,31 +68,27 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return err } + initialRoots, hasRoots := dSess.GetRoots(ctx, dbName) + if !hasRoots { + return fmt.Errorf("unable to resolve roots for %s", dbName) + } + if !force { - currentRoots, hasRoots := dSess.GetRoots(ctx, dbName) - if !hasRoots { - return fmt.Errorf("unable to resolve roots for %s", dbName) - } newBranchRoots, err := db.ResolveBranchRoots(ctx, branchRef) if err != nil { return err } if !isNewBranch { - wouldStomp, err := actions.CheckoutWouldStompWorkingSetChanges(ctx, currentRoots, newBranchRoots) + wouldOverwrite, err := actions.CheckoutWouldOverwriteWorkingSetChanges(ctx, initialRoots, newBranchRoots) if err != nil { return err } - if wouldStomp { + if wouldOverwrite { return actions.ErrWorkingSetsOnBothBranches } } } - initialRoots, hasRoots := dSess.GetRoots(ctx, dbName) - if !hasRoots { - return fmt.Errorf("unable to get roots") - } - hasChanges := false if workingSetExists { hasChanges, _, _, err = actions.RootHasUncommittedChanges(initialRoots) @@ -110,13 +106,9 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return err } - if err := actions.CheckUncommittedConflicts(ctx, initialRoots, branchHead); err != nil { - return err - } - // Only if the current working set has uncommitted changes do we carry them forward to the branch being checked out. // If this is the case, then the destination branch must *not* have any uncommitted changes, as checked by - // checkoutWouldStompWorkingSetChanges + // CheckoutWouldOverwriteWorkingSetChanges. if hasChanges { err = transferWorkingChanges(ctx, dbName, initialRoots, branchHead, branchRef, force) if err != nil { diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go index 47474cde24d..55872e7f23f 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go @@ -809,7 +809,7 @@ func continueRebase(ctx *sql.Context) rebaseResult { if err != nil { return newRebaseError(err) } - restoredWorking, err := actions.CarryUncommittedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot()) + restoredWorking, err := actions.CarryUncommittedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot(), actions.CarryAll) if err != nil { return newRebaseError(err) } From c0ac0d7c459875759ec4345d27eedf64d7857677 Mon Sep 17 00:00:00 2001 From: elianddb Date: Mon, 18 May 2026 22:38:49 +0000 Subject: [PATCH 14/23] dprocedures: keep non-move sql checkout session-local --- .../sqle/dprocedures/dolt_checkout.go | 47 +-- .../doltcore/sqle/enginetest/dolt_queries.go | 267 +++--------------- .../sqle/enginetest/dolt_queries_dtables.go | 14 +- .../sqle/enginetest/dolt_queries_nonlocal.go | 6 +- .../enginetest/dolt_transaction_queries.go | 202 +++++++++++++ .../doltcore/sqle/statspro/worker_test.go | 7 +- 6 files changed, 260 insertions(+), 283 deletions(-) diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go index aec90446f73..a4c3947df3e 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout.go @@ -550,57 +550,22 @@ func checkoutExistingBranch( return err } - var currentRoots doltdb.Roots - var hasCurrentRoots bool - currentRoots, hasCurrentRoots = dSess.GetRoots(ctx, dbName) - - if hasCurrentRoots { - branchHead, err := actions.BranchHeadRoot(ctx, ddb, branchName) - if err != nil { - return err - } - - if !overwriteIgnore { + if !overwriteIgnore { + if currentRoots, hasRoots := dSess.GetRoots(ctx, dbName); hasRoots { + branchHead, err := actions.BranchHeadRoot(ctx, ddb, branchName) + if err != nil { + return err + } if err := actions.CheckOverwrittenIgnoredTables(ctx, currentRoots, branchHead, overwriteIgnore); err != nil { return err } } - - // Not gated on overwriteIgnore because --overwrite-ignore covers only the dolt_ignore check above and not uncommitted table clobbering. - if err := actions.CheckUncommittedConflicts(ctx, currentRoots, branchHead); err != nil { - return err - } } err = dSess.SwitchWorkingSet(ctx, dbName, wsRef) if err != nil { return err } - - if hasCurrentRoots { - // Use the base name because dbName may include a revision qualifier that WorkingSet does not accept after SwitchWorkingSet. - baseName, _ := doltdb.SplitRevisionDbName(dbName) - ws, err := dSess.WorkingSet(ctx, baseName) - if err != nil { - return err - } - // Carry tables not in the pre-checkout HEAD to the new branch. The staged root is carried - // separately so staged tables keep their staged status. - newWorking, err := actions.CarryUncommittedTables(ctx, currentRoots.Working, currentRoots.Head, ws.WorkingRoot()) - if err != nil { - return err - } - newStaged, err := actions.CarryUncommittedTables(ctx, currentRoots.Staged, currentRoots.Head, ws.StagedRoot()) - if err != nil { - return err - } - // CarryUncommittedTables returns its target argument unchanged when nothing is carried, so pointer equality detects a no-op. - if newWorking != ws.WorkingRoot() || newStaged != ws.StagedRoot() { - if err = dSess.SetWorkingSet(ctx, baseName, ws.WithWorkingRoot(newWorking).WithStagedRoot(newStaged)); err != nil { - return err - } - } - } } return nil diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index c9d03332d43..46111d94efa 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -444,9 +444,8 @@ var DoltScripts = []queries.ScriptTest{ }, Assertions: []queries.ScriptTestAssertion{ { - Query: "SELECT table_name, staged, status, ignored FROM dolt_status_ignored;", - // abc was staged on branch1 and carried to main by the checkout, preserving its staged status. - Expected: []sql.Row{{"abc", byte(1), "new table", false}, {"t", byte(0), "new table", false}}, + Query: "SELECT table_name, staged, status, ignored FROM dolt_status_ignored;", + Expected: []sql.Row{{"t", byte(0), "new table", false}}, }, { Query: "SELECT * FROM dolt_status_ignored AS OF 'tag1';", @@ -3995,7 +3994,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout preserves untracked tables with secondary indexes and foreign keys", + Name: "dolt_checkout via SQL leaves untracked tables with secondary indexes and foreign keys on the source branch", SetUpScript: []string{ "call dolt_branch('empty');", "create table parent (id int primary key, name varchar(64));", @@ -4010,6 +4009,16 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Query: "call dolt_checkout('empty');", Expected: []sql.Row{{0, "Switched to branch 'empty'"}}, }, + { + Query: "select count(*) from information_schema.tables where table_schema = database() and table_name in ('parent', 'child');", + // Bare SQL checkout is a session pointer flip and does not carry uncommitted + // tables, so empty stays empty. + Expected: []sql.Row{{0}}, + }, + { + Query: "call dolt_checkout('main');", + Expected: []sql.Row{{0, "Switched to branch 'main'"}}, + }, { Query: "select id, name from parent;", Expected: []sql.Row{{1, "alice"}}, @@ -4020,7 +4029,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", - // non_unique=0 confirms the index remained unique after the checkout transition. + // The unique index survived the round trip because main's working set was + // never touched. Expected: []sql.Row{{"idx_val", 0, 1, "val"}}, }, { @@ -4039,46 +4049,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout preserves FK on untracked child when parent is committed on target", - SetUpScript: []string{ - "call dolt_branch('feat');", - "call dolt_checkout('feat');", - "create table parent (id int primary key, name varchar(64));", - "insert into parent values (1, 'alice'), (2, 'bob');", - "call dolt_commit('-Am', 'add parent on feat');", - "call dolt_checkout('main');", - // A local copy of parent is needed for the FK definition. The committed version wins after checkout. - "create table parent (id int primary key, name varchar(64));", - "create table child (id int primary key, parent_id int, constraint fk_parent foreign key (parent_id) references parent(id));", - "insert into parent values (1, 'alice'), (2, 'bob');", - "insert into child values (10, 1), (20, 2);", - }, - Assertions: []queries.ScriptTestAssertion{ - { - Query: "call dolt_checkout('feat');", - Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, - }, - { - Query: "select id, name from parent order by id;", - // The committed parent from feat replaces the untracked local copy. - Expected: []sql.Row{{1, "alice"}, {2, "bob"}}, - }, - { - Query: "select id, parent_id from child order by id;", - Expected: []sql.Row{{10, 1}, {20, 2}}, - }, - { - Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", - Expected: []sql.Row{{"fk_parent", "child"}}, - }, - { - Query: "insert into child values (99, 999);", - ExpectedErr: sql.ErrForeignKeyChildViolation, - }, - }, - }, - { - Name: "dolt_checkout('--move') aborts when untracked table conflicts with committed table on target branch", + Name: "dolt_checkout via SQL does not abort when the source has an untracked table that conflicts with the target", SetUpScript: []string{ "call dolt_branch('feat');", "call dolt_checkout('--move', 'feat');", @@ -4089,156 +4060,31 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, Assertions: []queries.ScriptTestAssertion{ { - Query: "call dolt_checkout('--move', 'feat');", - ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", - }, - { - Query: "select active_branch();", - // Still on main because the checkout aborted before switching branches. - Expected: []sql.Row{{"main"}}, - }, - }, - }, - { - Name: "dolt_checkout('--move') aborts when staged table conflicts with committed table on target branch", - SetUpScript: []string{ - "call dolt_branch('feat');", - "call dolt_checkout('--move', 'feat');", - "create table conflict_tbl (id int primary key, val int);", - "call dolt_commit('-Am', 'add conflict_tbl on feat');", - "call dolt_checkout('--move', 'main');", - "create table conflict_tbl (id int primary key);", - "call dolt_add('conflict_tbl');", - }, - Assertions: []queries.ScriptTestAssertion{ - { - Query: "call dolt_checkout('--move', 'feat');", - ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", - }, - { - Query: "select active_branch();", - // Still on main because the checkout aborted before switching branches. - Expected: []sql.Row{{"main"}}, - }, - }, - }, - { - Name: "dolt_checkout aborts when untracked table conflicts with committed table on target branch", - SetUpScript: []string{ - "call dolt_branch('feat');", - "call dolt_checkout('feat');", - "create table conflict_tbl (id int primary key, val int);", - "call dolt_commit('-Am', 'add conflict_tbl on feat');", - "call dolt_checkout('main');", - "create table conflict_tbl (id int primary key);", - }, - Assertions: []queries.ScriptTestAssertion{ - { - Query: "call dolt_checkout('feat');", - ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", - }, - { - Query: "select active_branch();", - Expected: []sql.Row{{"main"}}, - }, - }, - }, - { - Name: "dolt_checkout aborts when staged table conflicts with committed table on target branch", - SetUpScript: []string{ - "call dolt_branch('feat');", - "call dolt_checkout('feat');", - "create table conflict_tbl (id int primary key, val int);", - "call dolt_commit('-Am', 'add conflict_tbl on feat');", - "call dolt_checkout('main');", - "create table conflict_tbl (id int primary key);", - "call dolt_add('conflict_tbl');", - }, - Assertions: []queries.ScriptTestAssertion{ - { - Query: "call dolt_checkout('feat');", - ExpectedErrStr: "Your local changes to the following tables would be overwritten by checkout:\n\tconflict_tbl\nPlease commit your changes or stash them before you switch branches.\nAborting", - }, - { - Query: "select active_branch();", - Expected: []sql.Row{{"main"}}, - }, - }, - }, - { - // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout('--move') with untracked FK table does not duplicate FK", - SetUpScript: []string{ - "create table parent (id int primary key);", - "call dolt_commit('-Am', 'add parent');", - "call dolt_branch('other');", - "create table child (id int primary key, pid int, constraint fk_child foreign key (pid) references parent(id));", - }, - Assertions: []queries.ScriptTestAssertion{ - { - Query: "call dolt_checkout('--move', 'other');", - Expected: []sql.Row{{0, "Switched to branch 'other'"}}, - }, - { - Query: "select table_name from information_schema.tables where table_schema = database() and table_name = 'child';", - Expected: []sql.Row{{"child"}}, - }, - { - Query: "select constraint_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", - Expected: []sql.Row{{"fk_child"}}, - }, - }, - }, - { - // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout rewrites carried FK referenced tags when parent column has a different tag on target", - SetUpScript: []string{ - "create table parent (id int primary key, ref_col int unique);", - "call dolt_commit('-Am', 'add parent');", - "call dolt_branch('other');", - "call dolt_update_column_tag('parent', 'ref_col', 9999);", - "call dolt_commit('-am', 'set ref_col tag on main');", - "call dolt_checkout('other');", - "call dolt_update_column_tag('parent', 'ref_col', 7777);", - "call dolt_commit('-am', 'set ref_col tag on other');", - "call dolt_checkout('main');", - "insert into parent values (1, 100);", - "call dolt_commit('-am', 'insert into parent');", - "create table child (id int primary key, pid int, constraint fk_child foreign key (pid) references parent(ref_col));", - }, - Assertions: []queries.ScriptTestAssertion{ - { - Query: "call dolt_checkout('other');", - Expected: []sql.Row{{0, "Switched to branch 'other'"}}, - }, - { - Query: "call dolt_add('-A');", - Expected: []sql.Row{{0}}, - }, - { - Query: "call dolt_commit('-m', 'carry child to other');", - // Commit runs ValidateForeignKeysOnSchemas which looks up each FK column by tag in - // the referenced schema. The commit succeeds because the carry rewrote the referenced - // tag to target's value. - Expected: []sql.Row{{doltCommit}}, + Query: "call dolt_checkout('feat');", + // Bare SQL flips the session to feat without trying to carry main's local + // conflict_tbl, so the overwrite check never fires. + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, }, { - Query: "insert into parent values (1, 100);", - Expected: []sql.Row{{types.OkResult{RowsAffected: 1}}}, + Query: "select column_name from information_schema.columns where table_schema = database() and table_name = 'conflict_tbl' order by ordinal_position;", + // feat sees its own committed two-column schema. + Expected: []sql.Row{{"id"}, {"val"}}, }, { - Query: "insert into child values (10, 100);", - Expected: []sql.Row{{types.OkResult{RowsAffected: 1}}}, + Query: "call dolt_checkout('main');", + Expected: []sql.Row{{0, "Switched to branch 'main'"}}, }, { - Query: "insert into child values (99, 999);", - ExpectedErr: sql.ErrForeignKeyChildViolation, + Query: "select column_name from information_schema.columns where table_schema = database() and table_name = 'conflict_tbl' order by ordinal_position;", + // main's untracked one-column copy is still there because bare SQL did not + // touch its working set. + Expected: []sql.Row{{"id"}}, }, }, }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout carries an untracked FK whose parent is missing on target and the next commit surfaces the broken state", + Name: "dolt_checkout via SQL keeps an untracked foreign key on the source even when the parent is missing on the target", SetUpScript: []string{ "create table parent (id int primary key);", "call dolt_commit('-Am', 'add parent');", @@ -4251,19 +4097,23 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, Assertions: []queries.ScriptTestAssertion{ { - Query: "call dolt_checkout('no_parent');", - // The carry succeeds even when the referenced parent does not exist on the target. + Query: "call dolt_checkout('no_parent');", Expected: []sql.Row{{0, "Switched to branch 'no_parent'"}}, }, { - Query: "call dolt_add('-A');", + Query: "select count(*) from information_schema.tables where table_schema = database() and table_name = 'child';", + // child stayed on main, so no orphan foreign key exists on no_parent and + // the next commit on this branch has nothing to surface. Expected: []sql.Row{{0}}, }, { - Query: "call dolt_commit('-m', 'carry orphan child');", - // Commit-time foreign key validation surfaces a clear error because the referenced - // parent table is missing on this branch. - ExpectedErrStr: "foreign key `fk_orphan` requires the referenced table `parent`", + Query: "call dolt_checkout('main');", + Expected: []sql.Row{{0, "Switched to branch 'main'"}}, + }, + { + Query: "select constraint_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + // child still references parent on main, where parent exists. + Expected: []sql.Row{{"fk_orphan"}}, }, }, }, @@ -5059,39 +4909,6 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, }, }, - { - // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_reset('--hard') preserves foreign key constraints on untracked tables", - SetUpScript: []string{ - "call dolt_branch('empty');", - "create table parent (id int primary key, name varchar(64));", - "create table child (id int primary key, parent_id int, constraint fk_parent foreign key (parent_id) references parent(id));", - "insert into parent values (1, 'alice');", - "insert into child values (10, 1);", - }, - Assertions: []queries.ScriptTestAssertion{ - { - Query: "call dolt_reset('--hard', 'empty');", - Expected: []sql.Row{{0}}, - }, - { - Query: "select id, name from parent;", - Expected: []sql.Row{{1, "alice"}}, - }, - { - Query: "select id, parent_id from child;", - Expected: []sql.Row{{10, 1}}, - }, - { - Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", - Expected: []sql.Row{{"fk_parent", "child"}}, - }, - { - Query: "insert into child values (99, 999);", - ExpectedErr: sql.ErrForeignKeyChildViolation, - }, - }, - }, { // See https://github.com/dolthub/dolt/issues/11007 Name: "dolt_reset('--hard') preserves FK on untracked child when parent is committed on target", @@ -5151,7 +4968,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { Query: "insert into ai (val) values ('row3');", - // InsertID=3 confirms the auto-increment counter was not reset to 1 after the move. + // InsertID=3 confirms the auto-increment counter was not reset to 1 after the reset. Expected: []sql.Row{{types.OkResult{RowsAffected: 1, InsertID: 3}}}, }, { diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries_dtables.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries_dtables.go index f91c14b6ed9..385e9eadb8c 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries_dtables.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries_dtables.go @@ -59,13 +59,12 @@ var DoltStatusTableScripts = []queries.ScriptTest{ }, Assertions: []queries.ScriptTestAssertion{ { - Query: "SELECT * FROM dolt_status;", - // abc was staged on branch1 and carried to main by the checkout, preserving its staged status. - Expected: []sql.Row{{"abc", byte(1), "new table"}, {"t", byte(0), "new table"}}, + Query: "SELECT * FROM dolt_status;", + Expected: []sql.Row{{"t", byte(0), "new table"}}, }, { Query: "SELECT * FROM `mydb/main`.dolt_status;", - Expected: []sql.Row{{"abc", byte(1), "new table"}, {"t", byte(0), "new table"}}, + Expected: []sql.Row{{"t", byte(0), "new table"}}, }, { Query: "SELECT * FROM dolt_status AS OF 'tag1';", @@ -76,17 +75,16 @@ var DoltStatusTableScripts = []queries.ScriptTest{ Expected: []sql.Row{}, }, { - // HEAD is a special revision spec that shows the current working and staged status. + // HEAD is a special revision spec Query: "SELECT * FROM dolt_status AS OF 'head';", - Expected: []sql.Row{{"abc", byte(1), "new table"}, {"t", byte(0), "new table"}}, + Expected: []sql.Row{{"t", byte(0), "new table"}}, }, { Query: "SELECT * FROM dolt_status AS OF 'HEAD~1';", Expected: []sql.Row{}, }, { - Query: "SELECT * FROM dolt_status AS OF 'branch1';", - // branch1 is unaffected by the carry, so abc remains staged there. + Query: "SELECT * FROM dolt_status AS OF 'branch1';", Expected: []sql.Row{{"abc", byte(1), "new table"}}, }, { diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries_nonlocal.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries_nonlocal.go index faa2fb0bb0d..eae0562253e 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries_nonlocal.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries_nonlocal.go @@ -164,10 +164,8 @@ var NonlocalScripts = []queries.ScriptTest{ }, Assertions: []queries.ScriptTestAssertion{ { - Query: "show tables", - // aliased_table was untracked on main and is carried to other by the checkout. - // table_alias_1, table_alias_2, and table_alias_wild_3 come from the nonlocal entries. - Expected: []sql.Row{{"aliased_table"}, {"table_alias_1"}, {"table_alias_2"}, {"table_alias_wild_3"}}, + Query: "show tables", + Expected: []sql.Row{{"table_alias_1"}, {"table_alias_2"}, {"table_alias_wild_3"}}, }, }, }, diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go index 6456861340c..c5168a80702 100755 --- a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go @@ -2445,6 +2445,208 @@ var BranchIsolationTests = []queries.TransactionTest{ }, }, }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout without --move is session-local across every uncommitted-state variant", + SetUpScript: []string{ + "create table tracked_tbl (x int primary key)", + "insert into tracked_tbl values (0)", + "call dolt_commit('-Am', 'add tracked_tbl with row 0')", + "call dolt_branch('feat')", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "/* client a */ select active_branch()", + Expected: []sql.Row{{"main"}}, + }, + { + Query: "/* client b */ select active_branch()", + Expected: []sql.Row{{"main"}}, + }, + { + Query: "/* client a */ create table untracked_tbl (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client a */ insert into untracked_tbl values (1)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client a */ create table staged_tbl (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client a */ insert into staged_tbl values (2)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client a */ call dolt_add('staged_tbl')", + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client a */ insert into tracked_tbl values (3)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client b */ call dolt_checkout('feat')", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "/* client b */ select active_branch()", + Expected: []sql.Row{{"feat"}}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", + // The untracked variant stays on main because the no-carry contract + // applies to bare dolt_checkout without --move. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'staged_tbl'", + // The staged variant stays on main for the same reason. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ select x from tracked_tbl order by x", + // tracked_tbl exists on both branches and feat sees only its own HEAD row, + // so the pending row A inserted on main does not appear. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client a */ select x from untracked_tbl", + // Client A is still on main and keeps full visibility into all three pending + // variants because B's checkout never touched main's working set. + Expected: []sql.Row{{1}}, + }, + { + Query: "/* client a */ select x from staged_tbl", + Expected: []sql.Row{{2}}, + }, + { + Query: "/* client a */ select x from tracked_tbl order by x", + Expected: []sql.Row{{0}, {3}}, + }, + { + Query: "/* client b */ select x from `mydb/main`.untracked_tbl", + // The pending state was left behind on main rather than destroyed, so + // revision-qualified access from feat still reaches it. + Expected: []sql.Row{{1}}, + }, + { + Query: "/* client b */ select x from `mydb/main`.staged_tbl", + Expected: []sql.Row{{2}}, + }, + { + Query: "/* client b */ select x from `mydb/main`.tracked_tbl order by x", + Expected: []sql.Row{{0}, {3}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + // See https://docs.dolthub.com/sql-reference/version-control/branches for + // transaction-start snapshot semantics. + Name: "dolt_checkout without --move respects transaction-start snapshot semantics", + SetUpScript: []string{ + "create table tracked_tbl (x int primary key)", + "call dolt_commit('-Am', 'add tracked_tbl')", + "call dolt_branch('feat')", + "set autocommit = 0", + }, + Assertions: []queries.ScriptTestAssertion{ + { + // Client B starts first so its snapshot is pinned before A makes any + // changes, which is the precondition the rest of the test exercises. + Query: "/* client b */ start transaction", + Expected: []sql.Row{}, + }, + { + Query: "/* client a */ start transaction", + Expected: []sql.Row{}, + }, + { + Query: "/* client a */ create table untracked_tbl (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client a */ insert into untracked_tbl values (42)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client a */ commit", + Expected: []sql.Row{}, + }, + { + Query: "/* client b */ call dolt_checkout('feat')", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", + // B's snapshot pre-dates A's commit, and the no-carry contract means feat + // does not pick up A's untracked_tbl even inside an open transaction. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ commit", + Expected: []sql.Row{}, + }, + { + Query: "/* client b */ start transaction", + Expected: []sql.Row{}, + }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", + // A fresh transaction refreshes B's snapshot, and untracked_tbl still belongs + // to main rather than feat because the no-carry contract holds across sessions. + Expected: []sql.Row{{0}}, + }, + { + Query: "/* client b */ select x from `mydb/main`.untracked_tbl", + // Revision-qualified access from feat reaches main's state regardless of which + // branch the session is currently on. + Expected: []sql.Row{{42}}, + }, + }, + }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout('--move') still carries uncommitted state for the calling client only", + SetUpScript: []string{ + "create table tracked_tbl (x int primary key)", + "call dolt_commit('-Am', 'add tracked_tbl')", + "call dolt_branch('feat')", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "/* client b */ create table moves_with_b (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client b */ insert into moves_with_b values (7)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client b */ call dolt_checkout('--move', 'feat')", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "/* client b */ select x from moves_with_b", + // The --move flag carried the untracked table onto feat together with B's + // pending row. + Expected: []sql.Row{{7}}, + }, + { + Query: "/* client a */ select active_branch()", + Expected: []sql.Row{{"main"}}, + }, + { + Query: "/* client a */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'moves_with_b'", + // --move cleaned main's working set on behalf of client B, so the carried + // table is no longer visible to client A on main. + Expected: []sql.Row{{0}}, + }, + }, + }, } var MultiDbTransactionTests = []queries.ScriptTest{ diff --git a/go/libraries/doltcore/sqle/statspro/worker_test.go b/go/libraries/doltcore/sqle/statspro/worker_test.go index 65a68ddb67b..9c33708f816 100644 --- a/go/libraries/doltcore/sqle/statspro/worker_test.go +++ b/go/libraries/doltcore/sqle/statspro/worker_test.go @@ -998,15 +998,13 @@ func TestStatsBranchConcurrency(t *testing.T) { require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_checkout('"+branchName+"')")) require.NoError(t, executeQuery(ctx, sqlEng, "create table xy (x int primary key, y int)")) require.NoError(t, executeQuery(ctx, sqlEng, "insert into xy values (0,0),(1,1),(2,2),(3,3),(4,4),(5,5), (6,"+strconv.Itoa(i)+")")) - require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_commit('-Am', 'add xy on "+branchName+"')")) require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_stats_wait()")) } dropBranch := func(dropCtx *sql.Context, branchName string) { //log.Println("delete branch: ", branchName) require.NoError(t, executeQuery(ctx, sqlEng, "use mydb")) - // Force delete because addData commits on each branch so the branch is no longer fully merged into main. - del := "call dolt_branch('-D', '" + branchName + "')" + del := "call dolt_branch('-d', '" + branchName + "')" require.NoError(t, executeQuery(ctx, sqlEng, del)) } @@ -1080,8 +1078,7 @@ func TestStatsCacheGrowth(t *testing.T) { require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_checkout('"+branchName+"')")) require.NoError(t, executeQuery(ctx, sqlEng, "create table xy (x int primary key, y int)")) require.NoError(t, executeQuery(ctx, sqlEng, "insert into xy values (0,0),(1,1),(2,2),(3,3),(4,4),(5,5), (6,"+strconv.Itoa(i)+")")) - // Commit so the table is tracked on this branch and does not carry to main on the next checkout. - require.NoError(t, executeQuery(ctx, sqlEng, "call dolt_commit('-Am', 'add xy on "+branchName+"')")) + } iters := 20 From 635212b0f5cba611a9c767ef197defdba3deebf4 Mon Sep 17 00:00:00 2001 From: elianddb Date: Mon, 18 May 2026 22:38:54 +0000 Subject: [PATCH 15/23] bats: add checkout ignored tables stay on source test --- integration-tests/bats/checkout.bats | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/integration-tests/bats/checkout.bats b/integration-tests/bats/checkout.bats index 7364fe8487e..f007c8c5e0e 100755 --- a/integration-tests/bats/checkout.bats +++ b/integration-tests/bats/checkout.bats @@ -1532,3 +1532,27 @@ SQL [ "$status" -eq 0 ] [[ "$output" =~ "main" ]] || false } + +@test "checkout: ignored tables stay on the source branch" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt sql -q "INSERT INTO dolt_ignore VALUES ('generated_*', 1)" + dolt commit -Am "ignore generated_*" + dolt branch other + + dolt sql -q "CREATE TABLE generated_x (pk INT PRIMARY KEY)" + dolt sql -q "INSERT INTO generated_x VALUES (1)" + + run dolt checkout other + [ "$status" -eq 0 ] + + run dolt sql -q "SELECT count(*) FROM information_schema.tables WHERE table_name = 'generated_x'" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "0" ]] || false + + run dolt checkout main + [ "$status" -eq 0 ] + + run dolt sql -q "SELECT pk FROM generated_x" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "1" ]] || false +} From eae044e3955d007335ff4a3f6e7699d0770cbfe5 Mon Sep 17 00:00:00 2001 From: elianddb Date: Wed, 20 May 2026 19:20:05 +0000 Subject: [PATCH 16/23] actions: carry writable system tables and allow identical-state checkout --- .../doltcore/env/actions/carry_tables.go | 17 +-- .../doltcore/env/actions/carry_tables_test.go | 4 +- go/libraries/doltcore/env/actions/checkout.go | 106 +++++++++--------- go/libraries/doltcore/schema/tag.go | 5 +- .../doltcore/sqle/enginetest/dolt_queries.go | 10 +- .../enginetest/dolt_transaction_queries.go | 8 +- 6 files changed, 78 insertions(+), 72 deletions(-) diff --git a/go/libraries/doltcore/env/actions/carry_tables.go b/go/libraries/doltcore/env/actions/carry_tables.go index 9f72acd197f..8f4175a801b 100644 --- a/go/libraries/doltcore/env/actions/carry_tables.go +++ b/go/libraries/doltcore/env/actions/carry_tables.go @@ -64,9 +64,9 @@ func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.Root } // uncommittedTableSchemas returns the subset of |srcSchemas| whose names are absent from -// |baseline| and are not dolt system tables. When |policy| is ExcludeIgnored, names matched -// by |src|'s dolt_ignore patterns are also removed. Pattern conflicts on |src| fall through -// to the caller so CheckOverwrittenIgnoredTables can surface them. +// |baseline| and are not read-only dolt system tables. When |policy| is ExcludeIgnored, names +// matched by |src|'s dolt_ignore patterns are also removed. A table matched by conflicting +// dolt_ignore patterns is treated as not ignored and kept. func uncommittedTableSchemas(ctx context.Context, src doltdb.RootValue, srcSchemas map[doltdb.TableName]schema.Schema, baseline doltdb.RootValue, policy CarryPolicy) (map[doltdb.TableName]schema.Schema, error) { baselineNames, err := baseline.GetAllTableNames(ctx, false) if err != nil { @@ -88,16 +88,17 @@ func uncommittedTableSchemas(ctx context.Context, src doltdb.RootValue, srcSchem uncommitted := make(map[doltdb.TableName]schema.Schema, len(srcSchemas)) for name, sch := range srcSchemas { - if baselineSet.Contains(name) || doltdb.IsSystemTable(name) { + if baselineSet.Contains(name) || doltdb.IsReadOnlySystemTable(name) { continue } if patternsBySchema != nil { patterns := patternsBySchema[name.Schema] result, ignoreErr := patterns.IsTableNameIgnored(name) - if doltdb.AsDoltIgnoreInConflict(ignoreErr) == nil && ignoreErr != nil { + if doltdb.AsDoltIgnoreInConflict(ignoreErr) != nil { + // Conflicting patterns: keep the table rather than guess at the intent. + } else if ignoreErr != nil { return nil, ignoreErr - } - if result == doltdb.Ignore { + } else if result == doltdb.Ignore { continue } } @@ -129,7 +130,7 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[do return nil, nil, nil, err } if !exists { - return nil, nil, nil, fmt.Errorf("table %q does not exist in src root", name) + return nil, nil, nil, fmt.Errorf("table %q does not exist in src root", name.String()) } columns := sch.GetAllCols().GetColumns() tagRemap := make(map[uint64]uint64) diff --git a/go/libraries/doltcore/env/actions/carry_tables_test.go b/go/libraries/doltcore/env/actions/carry_tables_test.go index 450736c6b87..97f7f494e56 100644 --- a/go/libraries/doltcore/env/actions/carry_tables_test.go +++ b/go/libraries/doltcore/env/actions/carry_tables_test.go @@ -49,7 +49,7 @@ func newEmptyRoot(t *testing.T) (context.Context, doltdb.RootValue) { t.Helper() dEnv, _ := createTestEnv() ctx := context.Background() - require.NoError(t, dEnv.InitRepo(ctx, types.Format_Default, "test user", "test@test.com", "main")) + require.NoError(t, dEnv.InitRepo(ctx, types.Format_DOLT, "test user", "test@test.com", "main")) emptyRoot, err := dEnv.WorkingRoot(ctx) require.NoError(t, err) return ctx, emptyRoot @@ -71,7 +71,7 @@ func TestCarryUncommittedTablesDoesNotDuplicatePreexistingFK(t *testing.T) { // See https://github.com/dolthub/dolt/issues/11007 ctx, emptyRoot := newEmptyRoot(t) - // Use non-colliding tags so no retag happens. This isolates the duplicate-FK guard. + // Use non-colliding tags so no retag happens. const parentTag uint64 = 50001 const childTag uint64 = 50002 diff --git a/go/libraries/doltcore/env/actions/checkout.go b/go/libraries/doltcore/env/actions/checkout.go index 528b981b934..c0f57193c82 100644 --- a/go/libraries/doltcore/env/actions/checkout.go +++ b/go/libraries/doltcore/env/actions/checkout.go @@ -143,7 +143,7 @@ func CheckoutWouldOverwriteUncommittedTables(ctx context.Context, roots doltdb.R var localChange, untracked []string for _, name := range workingNames { - if headNamesSet.Contains(name) || doltdb.IsSystemTable(name) { + if headNamesSet.Contains(name) || doltdb.IsReadOnlySystemTable(name) { continue } targetHash, _, err := targetRoot.GetTableHash(ctx, name) @@ -193,8 +193,8 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } } - // |force| skips the conflict check. The carry step below drops source tables whose name - // already exists on the destination so the destination version wins. + // The carry step below drops source tables whose name already exists on the destination, + // so the destination version wins when |force| skipped the conflict check. // Snapshot the pre-checkout roots before the three-way merge below reassigns roots.Working // and roots.Staged so the carry step still sees the original values. @@ -649,56 +649,34 @@ func writeTableHashes(ctx context.Context, head doltdb.RootValue, tblHashes map[ // CheckoutWouldOverwriteWorkingSetChanges reports whether a checkout would overwrite // uncommitted changes on the destination. System and dolt_ignore-matched tables are excluded. +// When both sides hold the same uncommitted changes the checkout would not lose anything, so +// it is not reported as an overwrite. func CheckoutWouldOverwriteWorkingSetChanges(ctx context.Context, sourceRoots, destRoots doltdb.Roots) (bool, error) { - srcTracked, err := buildTrackedPredicate(ctx, sourceRoots.Working) + srcW, srcS, srcH, err := trackedRootHashes(ctx, sourceRoots) if err != nil { return false, err } - sourceHas, err := rootsHaveTrackedChanges(ctx, sourceRoots, srcTracked) - if err != nil || !sourceHas { - return false, err + if maps.Equal(srcW, srcH) && maps.Equal(srcS, srcH) { + return false, nil } - destTracked, err := buildTrackedPredicate(ctx, destRoots.Working) + destW, destS, destH, err := trackedRootHashes(ctx, destRoots) if err != nil { return false, err } - return rootsHaveTrackedChanges(ctx, destRoots, destTracked) + if maps.Equal(destW, destH) && maps.Equal(destS, destH) { + return false, nil + } + return !maps.Equal(srcW, destW) || !maps.Equal(srcS, destS), nil } -// buildTrackedPredicate returns a closure that reports whether a table is tracked for the -// purpose of detecting uncommitted-change conflicts. The closure returns false for system -// tables and for tables matched by a dolt_ignore pattern on |root|. Patterns are read once so -// callers can reuse the closure across multiple roots. -func buildTrackedPredicate(ctx context.Context, root doltdb.RootValue) (func(doltdb.TableName) (bool, error), error) { - names, err := root.GetAllTableNames(ctx, false) - if err != nil { - return nil, err - } - schemas := doltdb.GetUniqueSchemaNamesFromTableNames(names) - patternsBySchema, err := doltdb.GetIgnoredTablePatterns(ctx, doltdb.Roots{Working: root, Staged: root, Head: root}, schemas) +// trackedRootHashes returns name to hash maps for working, staged, and head of |roots|, keeping +// only the tables that buildTrackedPredicate accepts. Read-only system tables and dolt_ignore +// matched tables are excluded, while writable system tables such as dolt_ignore are kept. +func trackedRootHashes(ctx context.Context, roots doltdb.Roots) (working, staged, head map[doltdb.TableName]hash.Hash, err error) { + isTracked, err := buildTrackedPredicate(ctx, roots.Working) if err != nil { - return nil, err + return nil, nil, nil, err } - return func(n doltdb.TableName) (bool, error) { - if doltdb.IsSystemTable(n) { - return false, nil - } - patterns := patternsBySchema[n.Schema] - result, ignoreErr := patterns.IsTableNameIgnored(n) - if doltdb.AsDoltIgnoreInConflict(ignoreErr) != nil { - return true, nil - } - if ignoreErr != nil { - return false, ignoreErr - } - return result != doltdb.Ignore, nil - }, nil -} - -// rootsHaveTrackedChanges reports whether |roots| have uncommitted changes on any table that -// |isTracked| accepts. Working differing from head, or staged differing from head, on at least -// one tracked table returns true. -func rootsHaveTrackedChanges(ctx context.Context, roots doltdb.Roots, isTracked func(doltdb.TableName) (bool, error)) (bool, error) { hashes := func(root doltdb.RootValue) (map[doltdb.TableName]hash.Hash, error) { names, err := root.GetAllTableNames(ctx, false) if err != nil { @@ -721,22 +699,50 @@ func rootsHaveTrackedChanges(ctx context.Context, roots doltdb.Roots, isTracked } return out, nil } - working, err := hashes(roots.Working) + working, err = hashes(roots.Working) if err != nil { - return false, err + return nil, nil, nil, err } - head, err := hashes(roots.Head) + staged, err = hashes(roots.Staged) if err != nil { - return false, err + return nil, nil, nil, err + } + head, err = hashes(roots.Head) + if err != nil { + return nil, nil, nil, err } - if !maps.Equal(working, head) { - return true, nil + return working, staged, head, nil +} + +// buildTrackedPredicate returns a function that reports whether a table is tracked for the +// purpose of detecting uncommitted-change conflicts. It returns false for read-only system +// tables and for tables matched by a dolt_ignore pattern on |root|. Writable system tables +// such as dolt_ignore are tracked so the conflict check covers the same tables the carry step +// acts on. +func buildTrackedPredicate(ctx context.Context, root doltdb.RootValue) (func(doltdb.TableName) (bool, error), error) { + names, err := root.GetAllTableNames(ctx, false) + if err != nil { + return nil, err } - staged, err := hashes(roots.Staged) + schemas := doltdb.GetUniqueSchemaNamesFromTableNames(names) + patternsBySchema, err := doltdb.GetIgnoredTablePatterns(ctx, doltdb.Roots{Working: root, Staged: root, Head: root}, schemas) if err != nil { - return false, err + return nil, err } - return !maps.Equal(staged, head), nil + return func(n doltdb.TableName) (bool, error) { + if doltdb.IsReadOnlySystemTable(n) { + return false, nil + } + patterns := patternsBySchema[n.Schema] + result, ignoreErr := patterns.IsTableNameIgnored(n) + if doltdb.AsDoltIgnoreInConflict(ignoreErr) != nil { + return true, nil + } + if ignoreErr != nil { + return false, ignoreErr + } + return result != doltdb.Ignore, nil + }, nil } // ClearFeatureVersion creates a new version of the provided roots where all three roots have the same diff --git a/go/libraries/doltcore/schema/tag.go b/go/libraries/doltcore/schema/tag.go index c8e90359a58..617650d163b 100644 --- a/go/libraries/doltcore/schema/tag.go +++ b/go/libraries/doltcore/schema/tag.go @@ -77,7 +77,8 @@ func (tm TagMapping) Size() int { } // RemapTags returns a new slice with each entry of |tags| replaced by its value in |remap|, -// or kept unchanged when absent. The input is returned aliased when |remap| is empty. +// or kept unchanged when absent. When |remap| is empty the same |tags| slice is returned, +// not a copy, so callers must not assume they can mutate the result safely. func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { if len(remap) == 0 { return tags @@ -95,7 +96,7 @@ func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { // RemapTagsByColumnName returns |tags| rewritten so each tag points at the same-named column // on |destSch| instead of |srcSch|. When either schema is nil or a tag cannot be resolved on -// both sides, the original |tags| are returned unchanged. +// both sides, the original |tags| slice is returned unchanged, not a copy. func RemapTagsByColumnName(tags []uint64, srcSch, destSch Schema) []uint64 { if srcSch == nil || destSch == nil { return tags diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index 46111d94efa..db6e93cc12f 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -4011,8 +4011,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { Query: "select count(*) from information_schema.tables where table_schema = database() and table_name in ('parent', 'child');", - // Bare SQL checkout is a session pointer flip and does not carry uncommitted - // tables, so empty stays empty. + // Bare SQL checkout does not carry uncommitted tables, so empty stays empty. Expected: []sql.Row{{0}}, }, { @@ -4061,8 +4060,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Assertions: []queries.ScriptTestAssertion{ { Query: "call dolt_checkout('feat');", - // Bare SQL flips the session to feat without trying to carry main's local - // conflict_tbl, so the overwrite check never fires. + // Bare SQL switches to feat without carrying main's local conflict_tbl, so + // nothing on feat would be overwritten. Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, }, { @@ -4102,8 +4101,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { Query: "select count(*) from information_schema.tables where table_schema = database() and table_name = 'child';", - // child stayed on main, so no orphan foreign key exists on no_parent and - // the next commit on this branch has nothing to surface. + // child stayed on main, so no foreign key exists on no_parent. Expected: []sql.Row{{0}}, }, { diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go index c5168a80702..d1039eba611 100755 --- a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go @@ -2497,8 +2497,8 @@ var BranchIsolationTests = []queries.TransactionTest{ }, { Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", - // The untracked variant stays on main because the no-carry contract - // applies to bare dolt_checkout without --move. + // The untracked variant stays on main because bare dolt_checkout without + // --move does not carry uncommitted tables. Expected: []sql.Row{{0}}, }, { @@ -2582,7 +2582,7 @@ var BranchIsolationTests = []queries.TransactionTest{ }, { Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", - // B's snapshot pre-dates A's commit, and the no-carry contract means feat + // B's snapshot pre-dates A's commit, and bare checkout does not carry, so feat // does not pick up A's untracked_tbl even inside an open transaction. Expected: []sql.Row{{0}}, }, @@ -2597,7 +2597,7 @@ var BranchIsolationTests = []queries.TransactionTest{ { Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'untracked_tbl'", // A fresh transaction refreshes B's snapshot, and untracked_tbl still belongs - // to main rather than feat because the no-carry contract holds across sessions. + // to main rather than feat because bare checkout never carries it. Expected: []sql.Row{{0}}, }, { From 5be427fc266b0e1eee045ee02ad5329b4db42edb Mon Sep 17 00:00:00 2001 From: elianddb Date: Wed, 20 May 2026 20:37:39 +0000 Subject: [PATCH 17/23] amend tests that expect mysql dialect --- go/libraries/doltcore/env/actions/reset.go | 4 +- .../doltcore/sqle/enginetest/dolt_queries.go | 44 ++++++++++++++----- integration-tests/bats/reset.bats | 40 +++++++++++++++++ 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/go/libraries/doltcore/env/actions/reset.go b/go/libraries/doltcore/env/actions/reset.go index d8c129e3271..7949c7f6f73 100644 --- a/go/libraries/doltcore/env/actions/reset.go +++ b/go/libraries/doltcore/env/actions/reset.go @@ -30,8 +30,8 @@ import ( ) // resetHardTables resolves a new HEAD commit from a refSpec and updates working set roots by -// resetting the table contexts for tracked tables. New tables are ignored. Returns new HEAD -// Commit and Roots. +// resetting tracked tables to that HEAD. Uncommitted tables are carried forward onto the new +// HEAD via CarryUncommittedTables. Returns the new HEAD Commit and Roots. func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr string, roots doltdb.Roots) (*doltdb.Commit, doltdb.Roots, error) { ddb := dbData.Ddb rsr := dbData.Rsr diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index db6e93cc12f..a913e466578 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -3994,7 +3994,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout via SQL leaves untracked tables with secondary indexes and foreign keys on the source branch", + Name: "dolt_checkout leaves untracked tables with secondary indexes and foreign keys on the source branch", SetUpScript: []string{ "call dolt_branch('empty');", "create table parent (id int primary key, name varchar(64));", @@ -4010,7 +4010,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'empty'"}}, }, { - Query: "select count(*) from information_schema.tables where table_schema = database() and table_name in ('parent', 'child');", + Query: "select count(*) from information_schema.tables where table_schema = database() and table_name in ('parent', 'child');", + Dialect: "mysql", // Bare SQL checkout does not carry uncommitted tables, so empty stays empty. Expected: []sql.Row{{0}}, }, @@ -4027,7 +4028,11 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{10, 1, "c_val"}}, }, { - Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", + // TODO(elianddb): the information_schema assertions in these carry tests are gated + // to mysql because Doltgres does not populate information_schema.statistics yet, so + // index lookups return no rows. Remove the Dialect gates once Doltgres supports it. + Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", + Dialect: "mysql", // The unique index survived the round trip because main's working set was // never touched. Expected: []sql.Row{{"idx_val", 0, 1, "val"}}, @@ -4038,17 +4043,19 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Dialect: "mysql", Expected: []sql.Row{{"fk_parent", "child"}}, }, { Query: "insert into child values (99, 999, 'orphan');", + Dialect: "mysql", ExpectedErr: sql.ErrForeignKeyChildViolation, }, }, }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout via SQL does not abort when the source has an untracked table that conflicts with the target", + Name: "dolt_checkout does not abort when the source has an untracked table that conflicts with the target", SetUpScript: []string{ "call dolt_branch('feat');", "call dolt_checkout('--move', 'feat');", @@ -4065,7 +4072,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, }, { - Query: "select column_name from information_schema.columns where table_schema = database() and table_name = 'conflict_tbl' order by ordinal_position;", + Query: "select column_name from information_schema.columns where table_schema = database() and table_name = 'conflict_tbl' order by ordinal_position;", + Dialect: "mysql", // feat sees its own committed two-column schema. Expected: []sql.Row{{"id"}, {"val"}}, }, @@ -4074,7 +4082,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'main'"}}, }, { - Query: "select column_name from information_schema.columns where table_schema = database() and table_name = 'conflict_tbl' order by ordinal_position;", + Query: "select column_name from information_schema.columns where table_schema = database() and table_name = 'conflict_tbl' order by ordinal_position;", + Dialect: "mysql", // main's untracked one-column copy is still there because bare SQL did not // touch its working set. Expected: []sql.Row{{"id"}}, @@ -4083,7 +4092,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout via SQL keeps an untracked foreign key on the source even when the parent is missing on the target", + Name: "dolt_checkout keeps an untracked foreign key on the source even when the parent is missing on the target", SetUpScript: []string{ "create table parent (id int primary key);", "call dolt_commit('-Am', 'add parent');", @@ -4100,7 +4109,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'no_parent'"}}, }, { - Query: "select count(*) from information_schema.tables where table_schema = database() and table_name = 'child';", + Query: "select count(*) from information_schema.tables where table_schema = database() and table_name = 'child';", + Dialect: "mysql", // child stayed on main, so no foreign key exists on no_parent. Expected: []sql.Row{{0}}, }, @@ -4109,7 +4119,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'main'"}}, }, { - Query: "select constraint_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Query: "select constraint_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Dialect: "mysql", // child still references parent on main, where parent exists. Expected: []sql.Row{{"fk_orphan"}}, }, @@ -4822,6 +4833,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'users' and index_name = 'idx_email';", + Dialect: "mysql", Expected: []sql.Row{{"idx_email", 1, 1, "email", "YES", "BTREE", ""}}, }, { @@ -4860,7 +4872,8 @@ var DoltResetTestScripts = []queries.ScriptTest{ Expected: []sql.Row{{"c_data"}}, }, { - Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'c' and index_name = 'idx_code';", + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'c' and index_name = 'idx_code';", + Dialect: "mysql", // non_unique=0 confirms the UNIQUE flag survived the retag. Expected: []sql.Row{{"idx_code", 0, 1, "code", "YES", "BTREE", ""}}, }, @@ -4870,6 +4883,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { Query: "insert into c values ('bad', 'x_bad');", + Dialect: "mysql", ExpectedErr: sql.ErrCheckConstraintViolated, }, { @@ -4878,6 +4892,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'e' and index_name = 'idx_label';", + Dialect: "mysql", Expected: []sql.Row{{"idx_label", 1, 1, "label", "YES", "BTREE", ""}}, }, { @@ -4885,7 +4900,8 @@ var DoltResetTestScripts = []queries.ScriptTest{ Expected: []sql.Row{{"e_data"}}, }, { - Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'e' and index_name = 'idx_composite' order by seq_in_index;", + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'e' and index_name = 'idx_composite' order by seq_in_index;", + Dialect: "mysql", // Two rows verify both column positions of the composite index were remapped correctly. Expected: []sql.Row{{"idx_composite", 1, 1, "label", "YES", "BTREE", ""}, {"idx_composite", 1, 2, "tag", "YES", "BTREE", ""}}, }, @@ -4899,6 +4915,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'kl' and index_name = 'idx_val';", + Dialect: "mysql", Expected: []sql.Row{{"idx_val", 1, 1, "val", "YES", "BTREE", ""}}, }, { @@ -4938,10 +4955,12 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { Query: "select constraint_name, table_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", + Dialect: "mysql", Expected: []sql.Row{{"fk_parent", "child"}}, }, { Query: "insert into child values (99, 999);", + Dialect: "mysql", ExpectedErr: sql.ErrForeignKeyChildViolation, }, }, @@ -4949,6 +4968,9 @@ var DoltResetTestScripts = []queries.ScriptTest{ { // See https://github.com/dolthub/dolt/issues/11007 Name: "dolt_reset('--hard') preserves untracked table schema properties", + // Auto-increment, table comment/collation, and generated columns are MySQL-dialect, which + // Doltgres handles differently, so this whole script is gated to the mysql harness. + Dialect: "mysql", SetUpScript: []string{ "call dolt_branch('empty');", "create table ai (id int auto_increment primary key, val varchar(64));", diff --git a/integration-tests/bats/reset.bats b/integration-tests/bats/reset.bats index 2ef8dd90fab..0b82322c578 100644 --- a/integration-tests/bats/reset.bats +++ b/integration-tests/bats/reset.bats @@ -457,3 +457,43 @@ SQL [ "$status" -eq 0 ] [[ "$output" =~ "untracked_tbl" ]] || false } + +@test "reset: dolt reset --hard retags an untracked table that collides with the target" { + # See https://github.com/dolthub/dolt/issues/11007 + # bar.code and users.name both auto-generate tag 9815 since each branch assigns tags + # without seeing the other, so the carry must retag users.name to keep both tables. + dolt checkout -b feat + dolt sql -q "CREATE TABLE bar (code varchar(64) PRIMARY KEY);" + dolt add . + dolt commit -m "add bar" + + run dolt schema tags -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "bar,code,9815" ]] || false + + dolt checkout main + dolt sql -q "CREATE TABLE users (name varchar(64) PRIMARY KEY);" + dolt sql -q "INSERT INTO users VALUES ('alice');" + + run dolt schema tags -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "users,name,9815" ]] || false + + run dolt reset --hard feat + [ "$status" -eq 0 ] + + run dolt sql -q "SELECT name FROM users" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "alice" ]] || false + + run dolt schema tags -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "bar,code,9815" ]] || false + [[ "$output" =~ "users,name,12204" ]] || false + + dolt sql -q "INSERT INTO users VALUES ('bob');" + run dolt sql -q "SELECT name FROM users ORDER BY name" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "alice" ]] || false + [[ "$output" =~ "bob" ]] || false +} From de010ba6c3378cfa7549b6241250ea96bea5ae04 Mon Sep 17 00:00:00 2001 From: elianddb Date: Fri, 22 May 2026 22:48:32 +0000 Subject: [PATCH 18/23] schema: add index properties accessor and tidy tag remap docs --- go/libraries/doltcore/schema/index.go | 16 ++++++++++ go/libraries/doltcore/schema/schema_impl.go | 13 ++------ go/libraries/doltcore/schema/schema_test.go | 33 ++++++++++++++++----- go/libraries/doltcore/schema/tag.go | 10 ++++--- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/go/libraries/doltcore/schema/index.go b/go/libraries/doltcore/schema/index.go index 64e4d4bb4cf..8b442d6a3be 100644 --- a/go/libraries/doltcore/schema/index.go +++ b/go/libraries/doltcore/schema/index.go @@ -56,6 +56,8 @@ type Index interface { FullTextProperties() FullTextProperties // VectorProperties returns all properties belonging to a vector index. VectorProperties() VectorProperties + // Properties returns the IndexProperties describing this index. + Properties() IndexProperties } var _ Index = (*indexImpl)(nil) @@ -280,6 +282,20 @@ func (ix *indexImpl) VectorProperties() VectorProperties { return ix.vectorProperties } +// Properties implements Index. +func (ix *indexImpl) Properties() IndexProperties { + return IndexProperties{ + IsUnique: ix.isUnique, + IsSpatial: ix.isSpatial, + IsFullText: ix.isFullText, + IsUserDefined: ix.isUserDefined, + Comment: ix.comment, + FullTextProperties: ix.fullTextProps, + IsVector: ix.isVector, + VectorProperties: ix.vectorProperties, + } +} + // copy returns an exact copy of the calling index. func (ix *indexImpl) copy() *indexImpl { newIx := *ix diff --git a/go/libraries/doltcore/schema/schema_impl.go b/go/libraries/doltcore/schema/schema_impl.go index e43b993166f..6bf8f44fcf2 100644 --- a/go/libraries/doltcore/schema/schema_impl.go +++ b/go/libraries/doltcore/schema/schema_impl.go @@ -158,17 +158,7 @@ func WithRemappedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, err err = sch.Indexes().Iter(func(idx Index) (stop bool, err error) { tags := RemapTags(idx.IndexedColumnTags(), tagRemap) - props := IndexProperties{ - IsUnique: idx.IsUnique(), - IsSpatial: idx.IsSpatial(), - IsFullText: idx.IsFullText(), - IsUserDefined: idx.IsUserDefined(), - Comment: idx.Comment(), - FullTextProperties: idx.FullTextProperties(), - IsVector: idx.IsVector(), - VectorProperties: idx.VectorProperties(), - } - _, err = newSch.Indexes().AddIndexByColTags(idx.Name(), tags, idx.PrefixLengths(), props) + _, err = newSch.Indexes().AddIndexByColTags(idx.Name(), tags, idx.PrefixLengths(), idx.Properties()) return false, err }) if err != nil { @@ -179,6 +169,7 @@ func WithRemappedColumnTags(sch Schema, tagRemap map[uint64]uint64) (Schema, err } // WithUpdatedColumnTag returns a copy of |sch| with the tag of the column named |name| set to |tag|. +// It returns an error if no column named |name| exists. func WithUpdatedColumnTag(sch Schema, name string, tag uint64) (Schema, error) { col, ok := sch.GetAllCols().GetByName(name) if !ok { diff --git a/go/libraries/doltcore/schema/schema_test.go b/go/libraries/doltcore/schema/schema_test.go index 6f268360688..afbd131b8c4 100644 --- a/go/libraries/doltcore/schema/schema_test.go +++ b/go/libraries/doltcore/schema/schema_test.go @@ -418,19 +418,38 @@ func validateCols(t *testing.T, cols []Column, colColl *ColCollection, msg strin } } -// TestWithRemappedColumnTagsPreservesMetadata verifies that WithRemappedColumnTags carries -// collation, comment, and target row size onto the returned schema. +// TestWithRemappedColumnTagsPreservesMetadata verifies that WithRemappedColumnTags rewrites the +// column and index tags and carries collation, comment, target row size, and index properties onto +// the returned schema. func TestWithRemappedColumnTagsPreservesMetadata(t *testing.T) { - col := NewColumn("val", 100, types.StringKind, true, NotNullConstraint{}) - sch, err := SchemaFromCols(NewColCollection(col)) + pk, err := NewColumnWithTypeInfo("id", 100, typeinfo.Int64Type, true, "", false, "", NotNullConstraint{}) + require.NoError(t, err) + val, err := NewColumnWithTypeInfo("val", 101, typeinfo.StringDefaultType, false, "", false, "") + require.NoError(t, err) + sch, err := NewSchema(NewColCollection(pk, val), []int{0}, Collation_utf8mb4_general_ci, nil, nil) require.NoError(t, err) - require.NoError(t, sch.SetPkOrdinals([]int{0})) - sch.SetCollation(Collation_utf8mb4_general_ci) sch.SetComment("test comment") sch.SetTargetRowSize(4096) + _, err = sch.Indexes().AddIndexByColTags("val_idx", []uint64{101}, nil, IndexProperties{IsUnique: true, IsUserDefined: true, Comment: "idx comment"}) + require.NoError(t, err) + wantProps := sch.Indexes().GetByName("val_idx").Properties() - remapped, err := WithRemappedColumnTags(sch, map[uint64]uint64{100: 200}) + remapped, err := WithRemappedColumnTags(sch, map[uint64]uint64{100: 200, 101: 201}) require.NoError(t, err) + + // The remap actually happened: the new tags resolve and the old ones are gone. + got, ok := remapped.GetAllCols().GetByName("val") + require.True(t, ok) + require.Equal(t, uint64(201), got.Tag, "column tag must be remapped") + _, ok = remapped.GetAllCols().GetByTag(101) + require.False(t, ok, "old column tag must be gone") + + // The index tags are remapped, and every other index property survives the retag. + idx := remapped.Indexes().GetByName("val_idx") + require.NotNil(t, idx) + require.Equal(t, []uint64{201}, idx.IndexedColumnTags(), "index tags must be remapped") + require.Equal(t, wantProps, idx.Properties(), "all index properties must survive the retag") + require.Equal(t, Collation_utf8mb4_general_ci, remapped.GetCollation(), "collation must survive WithRemappedColumnTags") require.Equal(t, "test comment", remapped.GetComment(), "comment must survive WithRemappedColumnTags") require.Equal(t, uint16(4096), remapped.GetTargetRowSize(), "target row size must survive WithRemappedColumnTags") diff --git a/go/libraries/doltcore/schema/tag.go b/go/libraries/doltcore/schema/tag.go index 617650d163b..f21c556f114 100644 --- a/go/libraries/doltcore/schema/tag.go +++ b/go/libraries/doltcore/schema/tag.go @@ -77,8 +77,8 @@ func (tm TagMapping) Size() int { } // RemapTags returns a new slice with each entry of |tags| replaced by its value in |remap|, -// or kept unchanged when absent. When |remap| is empty the same |tags| slice is returned, -// not a copy, so callers must not assume they can mutate the result safely. +// or kept unchanged when absent. When |remap| is empty the input |tags| slice is returned as is +// rather than a fresh allocation, so the result must be treated as read-only. func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { if len(remap) == 0 { return tags @@ -95,8 +95,10 @@ func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { } // RemapTagsByColumnName returns |tags| rewritten so each tag points at the same-named column -// on |destSch| instead of |srcSch|. When either schema is nil or a tag cannot be resolved on -// both sides, the original |tags| slice is returned unchanged, not a copy. +// on |destSch| instead of |srcSch|. When either schema is nil or any tag cannot be resolved on +// both sides, the input |tags| slice is returned as is (treat it as read-only). The all-or-nothing +// fallback leaves the foreign key pointing at the source tags, which is preferable to a partial +// remap that would mix source and destination tags into one key. func RemapTagsByColumnName(tags []uint64, srcSch, destSch Schema) []uint64 { if srcSch == nil || destSch == nil { return tags From 68d85642f1a96fb7dd482bdd7df3f0292712db3a Mon Sep 17 00:00:00 2001 From: elianddb Date: Fri, 22 May 2026 22:48:32 +0000 Subject: [PATCH 19/23] doltdb: note dolt_ignore conflict tie-break todo --- go/libraries/doltcore/doltdb/ignore.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/go/libraries/doltcore/doltdb/ignore.go b/go/libraries/doltcore/doltdb/ignore.go index e0429c52ff5..70e53bf5520 100644 --- a/go/libraries/doltcore/doltdb/ignore.go +++ b/go/libraries/doltcore/doltdb/ignore.go @@ -311,5 +311,9 @@ func (ip *IgnorePatterns) IsTableNameIgnored(tableName TableName) (IgnoreResult, } // The table name matched both positive and negative patterns. // More specific patterns override less specific patterns. + // + // TODO(elianddb): break ties when no pattern is more specific. dolt_ignore rows have no order, + // so give them one and let the last rule added win, the way git lets a later ignore rule + // override an earlier one. return resolveConflictingPatterns(trueMatches, falseMatches, tableName) } From f82ee8d2385d6834e0d194d708886f2e246bdd70 Mon Sep 17 00:00:00 2001 From: elianddb Date: Fri, 22 May 2026 22:48:32 +0000 Subject: [PATCH 20/23] actions: thread ignored table set through carry and checkout --- .../doltcore/env/actions/carry_tables.go | 82 +++--- .../doltcore/env/actions/carry_tables_test.go | 257 +++++++++++------- go/libraries/doltcore/env/actions/checkout.go | 117 ++++---- go/libraries/doltcore/env/actions/errors.go | 18 +- go/libraries/doltcore/env/actions/reset.go | 7 +- .../sqle/dprocedures/dolt_checkout_helpers.go | 25 +- .../doltcore/sqle/dprocedures/dolt_rebase.go | 2 +- 7 files changed, 273 insertions(+), 235 deletions(-) diff --git a/go/libraries/doltcore/env/actions/carry_tables.go b/go/libraries/doltcore/env/actions/carry_tables.go index 8f4175a801b..bd0af859ef5 100644 --- a/go/libraries/doltcore/env/actions/carry_tables.go +++ b/go/libraries/doltcore/env/actions/carry_tables.go @@ -17,26 +17,19 @@ package actions import ( "context" "fmt" + "slices" + "strings" "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" "github.com/dolthub/dolt/go/libraries/doltcore/schema" "github.com/dolthub/dolt/go/store/types" ) -// CarryPolicy selects which uncommitted tables CarryUncommittedTables copies. -type CarryPolicy int - -const ( - // CarryAll carries every uncommitted table including those matched by dolt_ignore. - CarryAll CarryPolicy = iota - // ExcludeIgnored skips tables matched by the source's dolt_ignore patterns. - ExcludeIgnored -) - // CarryUncommittedTables copies tables present in |src| but not in |baseline| into |dest|, -// resolving column tag collisions and carrying their foreign keys. Tables already on |dest| -// are kept unchanged. |policy| filters which uncommitted tables are eligible; see CarryPolicy. -func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.RootValue, policy CarryPolicy) (doltdb.RootValue, error) { +// resolving column tag collisions and carrying their foreign keys. Tables already on |dest| are +// kept unchanged. Tables named in |exclude| are not carried, so pass nil to carry every uncommitted +// table. +func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.RootValue, exclude *doltdb.TableNameSet) (doltdb.RootValue, error) { srcSchemas, err := doltdb.GetAllSchemas(ctx, src) if err != nil { return nil, err @@ -46,7 +39,7 @@ func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.Root return nil, err } - uncommitted, err := uncommittedTableSchemas(ctx, src, srcSchemas, baseline, policy) + uncommitted, err := uncommittedTableSchemas(ctx, srcSchemas, baseline, exclude) if err != nil { return nil, err } @@ -63,44 +56,23 @@ func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.Root return carryForeignKeys(ctx, src, dest, carried, tagRemaps, srcSchemas, destSchemas) } -// uncommittedTableSchemas returns the subset of |srcSchemas| whose names are absent from -// |baseline| and are not read-only dolt system tables. When |policy| is ExcludeIgnored, names -// matched by |src|'s dolt_ignore patterns are also removed. A table matched by conflicting -// dolt_ignore patterns is treated as not ignored and kept. -func uncommittedTableSchemas(ctx context.Context, src doltdb.RootValue, srcSchemas map[doltdb.TableName]schema.Schema, baseline doltdb.RootValue, policy CarryPolicy) (map[doltdb.TableName]schema.Schema, error) { +// uncommittedTableSchemas returns the subset of |srcSchemas| for tables that |src| holds but +// |baseline| does not, excluding read-only dolt system tables and any names in |exclude|. Tables +// present on |baseline| are left to the three-way merge even when their contents differ. +func uncommittedTableSchemas(ctx context.Context, srcSchemas map[doltdb.TableName]schema.Schema, baseline doltdb.RootValue, exclude *doltdb.TableNameSet) (map[doltdb.TableName]schema.Schema, error) { baselineNames, err := baseline.GetAllTableNames(ctx, false) if err != nil { return nil, err } baselineSet := doltdb.NewTableNameSet(baselineNames) - var patternsBySchema map[string]doltdb.IgnorePatterns - if policy == ExcludeIgnored && len(srcSchemas) > 0 { - names := make([]doltdb.TableName, 0, len(srcSchemas)) - for n := range srcSchemas { - names = append(names, n) - } - patternsBySchema, err = doltdb.GetIgnoredTablePatterns(ctx, doltdb.Roots{Working: src, Staged: src, Head: src}, doltdb.GetUniqueSchemaNamesFromTableNames(names)) - if err != nil { - return nil, err - } - } - uncommitted := make(map[doltdb.TableName]schema.Schema, len(srcSchemas)) for name, sch := range srcSchemas { if baselineSet.Contains(name) || doltdb.IsReadOnlySystemTable(name) { continue } - if patternsBySchema != nil { - patterns := patternsBySchema[name.Schema] - result, ignoreErr := patterns.IsTableNameIgnored(name) - if doltdb.AsDoltIgnoreInConflict(ignoreErr) != nil { - // Conflicting patterns: keep the table rather than guess at the intent. - } else if ignoreErr != nil { - return nil, ignoreErr - } else if result == doltdb.Ignore { - continue - } + if exclude != nil && exclude.Contains(name) { + continue } uncommitted[name] = sch } @@ -111,7 +83,7 @@ func uncommittedTableSchemas(ctx context.Context, src doltdb.RootValue, srcSchem // tag collides with one held by |dest| or by an earlier carried table. Names already on |dest| // are skipped so the dest version wins. Returns the updated |dest|, the names that were // carried, and the per-table tag remaps so the caller can fix up foreign keys. -func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[doltdb.TableName]schema.Schema, destSchemas map[doltdb.TableName]schema.Schema) (doltdb.RootValue, []doltdb.TableName, map[doltdb.TableName]map[uint64]uint64, error) { +func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry, destSchemas map[doltdb.TableName]schema.Schema) (doltdb.RootValue, []doltdb.TableName, map[doltdb.TableName]map[uint64]uint64, error) { heldTags := make(schema.TagMapping) for tblName, tblSch := range destSchemas { for _, t := range tblSch.GetAllCols().Tags { @@ -121,10 +93,19 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[do allTagRemaps := make(map[doltdb.TableName]map[uint64]uint64) var carried []doltdb.TableName - for name, sch := range toCarry { + // Carry tables in a stable order. AutoGenerateTag derives replacement tags from the tags + // already held, which accumulate as tables are carried, so a nondeterministic map order could + // produce different tags for the same checkout across runs. + names := make([]doltdb.TableName, 0, len(toCarry)) + for name := range toCarry { + names = append(names, name) + } + slices.SortFunc(names, func(a, b doltdb.TableName) int { return strings.Compare(a.String(), b.String()) }) + for _, name := range names { if _, exists := destSchemas[name]; exists { continue } + tbl, exists, err := src.GetTable(ctx, name) if err != nil { return nil, nil, nil, err @@ -132,6 +113,8 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[do if !exists { return nil, nil, nil, fmt.Errorf("table %q does not exist in src root", name.String()) } + + sch := toCarry[name] columns := sch.GetAllCols().GetColumns() tagRemap := make(map[uint64]uint64) priorKinds := make([]types.NomsKind, 0, len(columns)) @@ -144,6 +127,7 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[do priorKinds = append(priorKinds, column.Kind) heldTags.Add(column.Tag, name.Name) } + if len(tagRemap) > 0 { allTagRemaps[name] = tagRemap newSch, err := schema.WithRemappedColumnTags(sch, tagRemap) @@ -166,9 +150,9 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry map[do // carryForeignKeys copies foreign keys from |src| whose child table is in |carried| into // |dest|'s FK collection and applies |tagRemaps| so retagged child columns stay consistent. -// Keys already present on |dest| are skipped so a pre-merged FK is not duplicated. When a -// carried key references a parent that already lives on |dest|, the referenced column tags -// are re-resolved by column name against |destSchemas| so the same column can carry a +// A key already present on |dest| is replaced so the retagged carried columns are reflected rather +// than leaving a stale tag. When a carried key references a parent that already lives on |dest|, the referenced +// column tags are re-resolved by column name against |destSchemas| so the same column can carry a // different internal tag on each branch and the foreign key stays valid. func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried []doltdb.TableName, tagRemaps map[doltdb.TableName]map[uint64]uint64, srcSchemas, destSchemas map[doltdb.TableName]schema.Schema) (doltdb.RootValue, error) { destFks, err := dest.GetForeignKeyCollection(ctx) @@ -184,15 +168,15 @@ func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried [ if !carriedSet.Contains(fk.TableName) { return false, nil } - if destFks.Contains(fk.Name, fk.TableName) { - return false, nil - } fk.TableColumns = schema.RemapTags(fk.TableColumns, tagRemaps[fk.TableName]) if carriedSet.Contains(fk.ReferencedTableName) { fk.ReferencedTableColumns = schema.RemapTags(fk.ReferencedTableColumns, tagRemaps[fk.ReferencedTableName]) } else { fk.ReferencedTableColumns = schema.RemapTagsByColumnName(fk.ReferencedTableColumns, srcSchemas[fk.ReferencedTableName], destSchemas[fk.ReferencedTableName]) } + // Drop any pre-merged copy first so AddKeys does not reject a duplicate name and the + // remapped key replaces the stale one. + destFks.RemoveKeyByName(fk.Name, fk.TableName) return false, destFks.AddKeys(fk) }) if err != nil { diff --git a/go/libraries/doltcore/env/actions/carry_tables_test.go b/go/libraries/doltcore/env/actions/carry_tables_test.go index 97f7f494e56..56652e054b5 100644 --- a/go/libraries/doltcore/env/actions/carry_tables_test.go +++ b/go/libraries/doltcore/env/actions/carry_tables_test.go @@ -21,17 +21,9 @@ import ( "github.com/stretchr/testify/require" "github.com/dolthub/dolt/go/libraries/doltcore/doltdb" + "github.com/dolthub/dolt/go/libraries/doltcore/dtestutils" "github.com/dolthub/dolt/go/libraries/doltcore/schema" - "github.com/dolthub/dolt/go/store/types" -) - -// collidingTag is used for both bar.code and users.name to engineer a tag collision that -// CarryUncommittedTables must resolve via a retag. -const ( - collidingTag uint64 = 9815 - barCodeTag = collidingTag - usersNameTag = collidingTag - postsTitleTag uint64 = 13593 + "github.com/dolthub/dolt/go/libraries/doltcore/schema/typeinfo" ) // putFK installs a single-entry foreign key collection onto |root| and returns the updated root. @@ -44,13 +36,11 @@ func putFK(t *testing.T, ctx context.Context, root doltdb.RootValue, fk doltdb.F return updated } -// newEmptyRoot returns a context and the empty working root of a freshly initialised repo. +// newEmptyRoot returns a context and the empty working root of a freshly initialized repo. func newEmptyRoot(t *testing.T) (context.Context, doltdb.RootValue) { t.Helper() - dEnv, _ := createTestEnv() ctx := context.Background() - require.NoError(t, dEnv.InitRepo(ctx, types.Format_DOLT, "test user", "test@test.com", "main")) - emptyRoot, err := dEnv.WorkingRoot(ctx) + emptyRoot, err := dtestutils.CreateTestEnv().WorkingRoot(ctx) require.NoError(t, err) return ctx, emptyRoot } @@ -58,49 +48,150 @@ func newEmptyRoot(t *testing.T) (context.Context, doltdb.RootValue) { // singleColPKSchema builds a one-column primary key schema using |colName| and |tag|. func singleColPKSchema(t *testing.T, colName string, tag uint64) schema.Schema { t.Helper() - col := schema.NewColumn(colName, tag, types.StringKind, true, schema.NotNullConstraint{}) - sch, err := schema.SchemaFromCols(schema.NewColCollection(col)) + col, err := schema.NewColumnWithTypeInfo(colName, tag, typeinfo.StringDefaultType, true, "", false, "", schema.NotNullConstraint{}) + require.NoError(t, err) + return dtestutils.CreateSchema(col) +} + +// TestCarryUncommittedTables verifies that CarryUncommittedTables carries untracked tables into the +// target root, resolves tag collisions, and propagates foreign keys. +func TestCarryUncommittedTables(t *testing.T) { + // See https://github.com/dolthub/dolt/issues/11007 + ctx, emptyRoot := newEmptyRoot(t) + + // bar.code and users.name share collidingTag so the carry must retag users.name; posts.title + // gets a distinct tag and must be left untouched. + const collidingTag uint64 = 9815 + const postsTitleTag uint64 = 13593 + + withUniqueIndex := func(sch schema.Schema, indexName string, tag uint64) schema.Schema { + _, err := sch.Indexes().AddIndexByColTags(indexName, []uint64{tag}, nil, schema.IndexProperties{IsUserDefined: true, IsUnique: true}) + require.NoError(t, err) + return sch + } + + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", collidingTag)) require.NoError(t, err) - require.NoError(t, sch.SetPkOrdinals([]int{0})) - return sch + + // Source has bar (so the foreign key can reference it), plus untracked users and posts. + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", collidingTag)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, withUniqueIndex(singleColPKSchema(t, "name", collidingTag), "idx_name", collidingTag)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, singleColPKSchema(t, "title", postsTitleTag)) + require.NoError(t, err) + + working = putFK(t, ctx, working, doltdb.ForeignKey{ + Name: "fk_bar", + TableName: doltdb.TableName{Name: "users"}, + TableColumns: []uint64{collidingTag}, + ReferencedTableName: doltdb.TableName{Name: "bar"}, + ReferencedTableColumns: []uint64{collidingTag}, + }) + + // |baseline| is target so only the child table is carried. Generally we exclude tables that exist on dest. + result, err := CarryUncommittedTables(ctx, working, target, target, nil) + require.NoError(t, err) + + t.Run("preserves tag on non-collision", func(t *testing.T) { + tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "posts"}) + require.NoError(t, err) + require.True(t, ok, "posts table must be present in the result") + sch, err := tbl.GetSchema(ctx) + require.NoError(t, err) + col, ok := sch.GetAllCols().GetByName("title") + require.True(t, ok) + require.Equal(t, postsTitleTag, col.Tag, "posts.title tag must not change when there is no collision") + }) + + t.Run("retags colliding column", func(t *testing.T) { + tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) + require.NoError(t, err) + require.True(t, ok, "users table must be present in the result") + sch, err := tbl.GetSchema(ctx) + require.NoError(t, err) + col, ok := sch.GetAllCols().GetByName("name") + require.True(t, ok) + require.NotEqual(t, collidingTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") + + idx, ok := sch.Indexes().GetByNameCaseInsensitive("idx_name") + require.True(t, ok, "idx_name must survive the retag") + require.Equal(t, []uint64{col.Tag}, idx.IndexedColumnTags(), "idx_name must reference the retagged column, not the old tag") + require.Equal(t, schema.IndexProperties{IsUserDefined: true, IsUnique: true}, idx.Properties(), + "idx_name properties must survive the retag unchanged") + }) + + t.Run("FK child column retagged, parent column unchanged", func(t *testing.T) { + // users.name and bar.code share collidingTag so users.name is retagged. bar.code is committed + // on the target and keeps its tag, so only the child side of the FK changes. + usersTbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) + require.NoError(t, err) + require.True(t, ok) + usersSch, err := usersTbl.GetSchema(ctx) + require.NoError(t, err) + nameCol, ok := usersSch.GetAllCols().GetByName("name") + require.True(t, ok) + + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_bar", doltdb.TableName{Name: "users"}) + require.True(t, ok, "fk_bar must be present in result FK collection") + require.Equal(t, []uint64{nameCol.Tag}, got.TableColumns, + "child column must follow the retagged users.name tag") + require.Equal(t, []uint64{collidingTag}, got.ReferencedTableColumns, + "committed parent column tag must not change") + }) } -// TestCarryUncommittedTablesDoesNotDuplicatePreexistingFK verifies that CarryUncommittedTables does not return an error -// when the target foreign key collection already contains a key being carried. -func TestCarryUncommittedTablesDoesNotDuplicatePreexistingFK(t *testing.T) { +// TestCarryUncommittedTablesMergedForeignKeyFollowsRetag verifies that when a carried child's column +// is retagged to resolve a collision, a foreign key already merged onto the target is rewritten to +// the new tag instead of being left pointing at the stale source tag. +func TestCarryUncommittedTablesMergedForeignKeyFollowsRetag(t *testing.T) { // See https://github.com/dolthub/dolt/issues/11007 ctx, emptyRoot := newEmptyRoot(t) - // Use non-colliding tags so no retag happens. - const parentTag uint64 = 50001 - const childTag uint64 = 50002 + const collide uint64 = 70001 // shared by target anchor.x and source child.cid, forcing a retag + const parentTag uint64 = 70002 target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTag)) require.NoError(t, err) + target, err = doltdb.CreateEmptyTable(ctx, target, doltdb.TableName{Name: "anchor"}, singleColPKSchema(t, "x", collide)) + require.NoError(t, err) - working := emptyRoot - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPKSchema(t, "pid", childTag)) + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTag)) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPKSchema(t, "cid", collide)) require.NoError(t, err) fk := doltdb.ForeignKey{ - Name: "fk_pre", + Name: "fk_child", TableName: doltdb.TableName{Name: "child"}, - TableColumns: []uint64{childTag}, + TableColumns: []uint64{collide}, ReferencedTableName: doltdb.TableName{Name: "parent"}, ReferencedTableColumns: []uint64{parentTag}, } working = putFK(t, ctx, working, fk) - // Simulate the FK pre-merge that RootsForBranch performs before calling CarryUncommittedTables. target = putFK(t, ctx, target, fk) - result, err := CarryUncommittedTables(ctx, working, emptyRoot, target, CarryAll) + // |baseline| is target so parent is treated as tracked and only child is carried. + result, err := CarryUncommittedTables(ctx, working, target, target, nil) + require.NoError(t, err) + + childTbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "child"}) require.NoError(t, err) + require.True(t, ok) + childSch, err := childTbl.GetSchema(ctx) + require.NoError(t, err) + cid, ok := childSch.GetAllCols().GetByName("cid") + require.True(t, ok) + require.NotEqual(t, collide, cid.Tag, "child.cid must be retagged off the colliding tag") resultFks, err := result.GetForeignKeyCollection(ctx) require.NoError(t, err) - got, ok := resultFks.GetByNameCaseInsensitive("fk_pre", doltdb.TableName{Name: "child"}) - require.True(t, ok, "fk_pre must be present in result FK collection") - require.Equal(t, []uint64{childTag}, got.TableColumns) + got, ok := resultFks.GetByNameCaseInsensitive("fk_child", doltdb.TableName{Name: "child"}) + require.True(t, ok, "fk_child must be present") + require.Equal(t, []uint64{cid.Tag}, got.TableColumns, + "carried foreign key must follow the child column retag, not keep the stale source tag") } // TestCarryUncommittedTablesParentTagMismatch verifies that a foreign key carried from source onto @@ -130,9 +221,8 @@ func TestCarryUncommittedTablesParentTagMismatch(t *testing.T) { ReferencedTableColumns: []uint64{parentTagOnSource}, }) - // |baseline| is target so only the child table is carried. The parent table already exists on - // target and is filtered out of the carry candidates. - result, err := CarryUncommittedTables(ctx, working, target, target, CarryAll) + // |baseline| is target so only the child table is carried. Generally we exclude tables that exist on dest. + result, err := CarryUncommittedTables(ctx, working, target, target, nil) require.NoError(t, err) resultFks, err := result.GetForeignKeyCollection(ctx) @@ -145,87 +235,44 @@ func TestCarryUncommittedTablesParentTagMismatch(t *testing.T) { "child column tag must be unchanged because there was no collision") } -// TestCarryUncommittedTables verifies that CarryUncommittedTables carries untracked tables into the -// target root, resolves tag collisions, and propagates foreign keys. -func TestCarryUncommittedTables(t *testing.T) { +// TestCarryUncommittedTablesParentColumnRenamedKeepsSourceTag documents the accepted divergence when +// the referenced parent column has a different name on source and target (renamed on the target +// branch). RemapTagsByColumnName cannot match the source column name against the target parent, so it +// leaves the carried foreign key on the source tag. +func TestCarryUncommittedTablesParentColumnRenamedKeepsSourceTag(t *testing.T) { // See https://github.com/dolthub/dolt/issues/11007 ctx, emptyRoot := newEmptyRoot(t) - withUniqueIndex := func(sch schema.Schema, indexName string, tag uint64) schema.Schema { - _, err := sch.Indexes().AddIndexByColTags(indexName, []uint64{tag}, nil, schema.IndexProperties{IsUserDefined: true, IsUnique: true}) - require.NoError(t, err) - return sch - } + const parentTagOnSource uint64 = 60101 + const parentTagOnTarget uint64 = 60102 + const childTag uint64 = 60103 - target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", barCodeTag)) + target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "ident", parentTagOnTarget)) require.NoError(t, err) - // Source has bar (so the foreign key can reference it), plus untracked users and posts. - working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", barCodeTag)) - require.NoError(t, err) - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, withUniqueIndex(singleColPKSchema(t, "name", usersNameTag), "idx_name", usersNameTag)) + working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTagOnSource)) require.NoError(t, err) - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, singleColPKSchema(t, "title", postsTitleTag)) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "child"}, singleColPKSchema(t, "pid", childTag)) require.NoError(t, err) working = putFK(t, ctx, working, doltdb.ForeignKey{ - Name: "fk_bar", - TableName: doltdb.TableName{Name: "users"}, - TableColumns: []uint64{usersNameTag}, - ReferencedTableName: doltdb.TableName{Name: "bar"}, - ReferencedTableColumns: []uint64{barCodeTag}, + Name: "fk_cross", + TableName: doltdb.TableName{Name: "child"}, + TableColumns: []uint64{childTag}, + ReferencedTableName: doltdb.TableName{Name: "parent"}, + ReferencedTableColumns: []uint64{parentTagOnSource}, }) - // |baseline| is target so bar is treated as tracked on source and only users and posts are carried. - result, err := CarryUncommittedTables(ctx, working, target, target, CarryAll) + // |baseline| is target so only the child table is carried. + result, err := CarryUncommittedTables(ctx, working, target, target, nil) require.NoError(t, err) - t.Run("preserves tag on non-collision", func(t *testing.T) { - tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "posts"}) - require.NoError(t, err) - require.True(t, ok, "posts table must be present in the result") - sch, err := tbl.GetSchema(ctx) - require.NoError(t, err) - col, ok := sch.GetAllCols().GetByName("title") - require.True(t, ok) - require.Equal(t, postsTitleTag, col.Tag, "posts.title tag must not change when there is no collision") - }) - - t.Run("retags colliding column", func(t *testing.T) { - tbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) - require.NoError(t, err) - require.True(t, ok, "users table must be present in the result") - sch, err := tbl.GetSchema(ctx) - require.NoError(t, err) - col, ok := sch.GetAllCols().GetByName("name") - require.True(t, ok) - require.NotEqual(t, barCodeTag, col.Tag, "users.name must be retagged to avoid colliding with bar.code") - - idx, ok := sch.Indexes().GetByNameCaseInsensitive("idx_name") - require.True(t, ok, "idx_name must survive the retag") - require.Equal(t, []uint64{col.Tag}, idx.IndexedColumnTags(), "idx_name must reference the retagged column, not the old tag") - require.True(t, idx.IsUnique(), "idx_name is unique and must remain so after retag") - require.True(t, idx.IsUserDefined(), "idx_name is user-defined and must remain so after retag") - }) - - t.Run("FK child column retagged, parent column unchanged", func(t *testing.T) { - // users.name and bar.code share collidingTag so users.name is retagged. bar.code is committed - // on the target and keeps its tag, so only the child side of the FK changes. - usersTbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) - require.NoError(t, err) - require.True(t, ok) - usersSch, err := usersTbl.GetSchema(ctx) - require.NoError(t, err) - nameCol, ok := usersSch.GetAllCols().GetByName("name") - require.True(t, ok) - - resultFks, err := result.GetForeignKeyCollection(ctx) - require.NoError(t, err) - got, ok := resultFks.GetByNameCaseInsensitive("fk_bar", doltdb.TableName{Name: "users"}) - require.True(t, ok, "fk_bar must be present in result FK collection") - require.Equal(t, []uint64{nameCol.Tag}, got.TableColumns, - "child column must follow the retagged users.name tag") - require.Equal(t, []uint64{barCodeTag}, got.ReferencedTableColumns, - "committed parent column tag must not change") - }) + resultFks, err := result.GetForeignKeyCollection(ctx) + require.NoError(t, err) + got, ok := resultFks.GetByNameCaseInsensitive("fk_cross", doltdb.TableName{Name: "child"}) + require.True(t, ok, "fk_cross must be present in result FK collection") + require.Equal(t, []uint64{parentTagOnSource}, got.ReferencedTableColumns, + "referenced tag stays on the source tag because the parent column name does not exist on target") + require.Equal(t, []uint64{childTag}, got.TableColumns, + "child column tag must be unchanged because there was no collision") } diff --git a/go/libraries/doltcore/env/actions/checkout.go b/go/libraries/doltcore/env/actions/checkout.go index c0f57193c82..c30d7ad9ab6 100644 --- a/go/libraries/doltcore/env/actions/checkout.go +++ b/go/libraries/doltcore/env/actions/checkout.go @@ -161,9 +161,9 @@ func CheckoutWouldOverwriteUncommittedTables(ctx context.Context, roots doltdb.R continue } if stagedNamesSet.Contains(name) { - localChange = append(localChange, name.Name) + localChange = append(localChange, name.String()) } else { - untracked = append(untracked, name.Name) + untracked = append(untracked, name.String()) } } @@ -178,8 +178,8 @@ func CheckoutWouldOverwriteUncommittedTables(ctx context.Context, roots doltdb.R // RootsForBranch returns the roots for checking out a branch whose head is |branchRoot|. // |roots.Head| must be the pre-checkout head. Uncommitted tables, those present in working // or staged but absent from the old head, are moved into the new root via -// [CarryUncommittedTables] so any column tag collisions are resolved. -func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.RootValue, force bool) (doltdb.Roots, error) { +// [CarryUncommittedTables], except the tables in |ignored|, which stay on the source branch. +func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.RootValue, force bool, ignored *doltdb.TableNameSet) (doltdb.Roots, error) { conflicts := doltdb.NewTableNameSet(nil) if roots.Head == nil { roots.Working = branchRoot @@ -193,12 +193,12 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } } - // The carry step below drops source tables whose name already exists on the destination, - // so the destination version wins when |force| skipped the conflict check. + // When |force| skipped the conflict check, the carry below keeps the destination's version on + // any name collision. // Snapshot the pre-checkout roots before the three-way merge below reassigns roots.Working // and roots.Staged so the carry step still sees the original values. - preCheckout := roots + preCheckoutRoots := roots wrkTblHashes, err := threeWayMergeTableHashes(ctx, roots.Head, branchRoot, roots.Working, conflicts, force) if err != nil { @@ -245,13 +245,14 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } - // Ignored tables stay on the source branch because dolt_ignore is branch-scoped. - roots.Working, err = CarryUncommittedTables(ctx, preCheckout.Working, preCheckout.Head, roots.Working, ExcludeIgnored) + // Ignored tables stay on the source branch because dolt_ignore is branch-scoped. |ignored| is + // the precomputed set the caller excludes from both carries. + roots.Working, err = CarryUncommittedTables(ctx, preCheckoutRoots.Working, preCheckoutRoots.Head, roots.Working, ignored) if err != nil { return doltdb.Roots{}, err } - roots.Staged, err = CarryUncommittedTables(ctx, preCheckout.Staged, preCheckout.Head, roots.Staged, ExcludeIgnored) + roots.Staged, err = CarryUncommittedTables(ctx, preCheckoutRoots.Staged, preCheckoutRoots.Head, roots.Staged, ignored) if err != nil { return doltdb.Roots{}, err } @@ -299,8 +300,8 @@ func CleanOldWorkingSet( Staged: workingSet.StagedRoot(), } - // we also have to do a clean, because we the ResetHard won't touch any new tables (tables only in the working set). - // Respect ignore rules so dolt_ignore-matched tables stay on the source branch per the documented policy. + // we also have to do a clean, because the ResetHard won't touch any new tables (tables only in the working set). + // Respect ignore rules so dolt_ignore-matched tables stay on the source branch. newRoots, err := CleanUntracked(ctx, resetRoots, []string{}, false, true, true) if err != nil { return err @@ -358,8 +359,7 @@ func BranchHeadRoot(ctx context.Context, db *doltdb.DoltDB, brName string) (dolt // threeWayMergeTableHashes performs a 3-way merge of per-table hashes across |oldRoot|, // |newRoot|, and |changedRoot|. Each table picks the side that changed against |oldRoot|, or // |newRoot| when |force| is true. Tables changed on both sides go into |conflicts| so the -// caller can surface ErrCheckoutWouldOverwrite. Uncommitted tables are skipped here and -// handled by [CarryUncommittedTables]. +// caller can surface ErrCheckoutWouldOverwrite. Uncommitted tables are skipped here. func threeWayMergeTableHashes(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, conflicts *doltdb.TableNameSet, force bool) (map[doltdb.TableName]hash.Hash, error) { resultMap := make(map[doltdb.TableName]hash.Hash) tblNames, err := doltdb.UnionTableNames(ctx, newRoot) @@ -413,8 +413,7 @@ func threeWayMergeTableHashes(ctx context.Context, oldRoot, newRoot, changedRoot // Skip uncommitted tables here so CarryUncommittedTables can pick them up after // the merged tracked state lands on the destination. Carry needs that final state - // to detect column tag collisions and to rewrite foreign key references against - // the destination's parent schemas. + // to detect column tag collisions and to rewrite foreign key references. if oldHash == emptyHash { continue } else if force { @@ -479,7 +478,7 @@ func CheckOverwrittenIgnoredTables(ctx context.Context, roots doltdb.Roots, bran // threeWayMergeForeignKeys performs a 3-way merge of the foreign key collections from |oldRoot|, // |newRoot|, and |changedRoot|. If one side did not change the collection it returns the other, -// and otherwise delegates to [mergeForeignKeyChanges] to merge changes from both sides. +// and otherwise delegates to mergeForeignKeyChanges to merge changes from both sides. func threeWayMergeForeignKeys(ctx context.Context, oldRoot, newRoot, changedRoot doltdb.RootValue, force bool) (*doltdb.ForeignKeyCollection, error) { oldFks, err := oldRoot.GetForeignKeyCollection(ctx) if err != nil { @@ -649,17 +648,44 @@ func writeTableHashes(ctx context.Context, head doltdb.RootValue, tblHashes map[ // CheckoutWouldOverwriteWorkingSetChanges reports whether a checkout would overwrite // uncommitted changes on the destination. System and dolt_ignore-matched tables are excluded. -// When both sides hold the same uncommitted changes the checkout would not lose anything, so -// it is not reported as an overwrite. -func CheckoutWouldOverwriteWorkingSetChanges(ctx context.Context, sourceRoots, destRoots doltdb.Roots) (bool, error) { - srcW, srcS, srcH, err := trackedRootHashes(ctx, sourceRoots) +// Identical uncommitted changes on both sides are not reported as an overwrite. +func CheckoutWouldOverwriteWorkingSetChanges(ctx context.Context, sourceRoots, destRoots doltdb.Roots, ignored *doltdb.TableNameSet) (bool, error) { + // Fast path, like comparing tree hashes in git: if either side has no uncommitted changes, + // nothing can be overwritten, so skip the per-table hashing. This whole-root check may count + // ignored or system tables as dirty, which only costs a fall-through to the precise comparison + // below. + srcDirty, _, _, err := RootHasUncommittedChanges(sourceRoots) + if err != nil { + return false, err + } + if !srcDirty { + return false, nil + } + destDirty, _, _, err := RootHasUncommittedChanges(destRoots) + if err != nil { + return false, err + } + if !destDirty { + return false, nil + } + + // Both sides have uncommitted changes. Compare only the tracked tables to decide whether the + // carry would actually overwrite the destination's uncommitted work. + // + // TODO(elianddb): this scans every tracked table on both branches, but only tables that + // differ from their branch's last commit can be overwritten. The table list is stored as a + // sorted tree that can compare two versions while skipping the parts that did not change, + // like git comparing trees, so we could look at just the changed tables instead of all of + // them. Doing so needs a small accessor to reach that tree, which the storage layer keeps + // private today. + srcW, srcS, srcH, err := trackedRootHashes(ctx, sourceRoots, ignored) if err != nil { return false, err } if maps.Equal(srcW, srcH) && maps.Equal(srcS, srcH) { return false, nil } - destW, destS, destH, err := trackedRootHashes(ctx, destRoots) + destW, destS, destH, err := trackedRootHashes(ctx, destRoots, ignored) if err != nil { return false, err } @@ -670,13 +696,9 @@ func CheckoutWouldOverwriteWorkingSetChanges(ctx context.Context, sourceRoots, d } // trackedRootHashes returns name to hash maps for working, staged, and head of |roots|, keeping -// only the tables that buildTrackedPredicate accepts. Read-only system tables and dolt_ignore -// matched tables are excluded, while writable system tables such as dolt_ignore are kept. -func trackedRootHashes(ctx context.Context, roots doltdb.Roots) (working, staged, head map[doltdb.TableName]hash.Hash, err error) { - isTracked, err := buildTrackedPredicate(ctx, roots.Working) - if err != nil { - return nil, nil, nil, err - } +// only tracked tables. Read-only system tables and the tables in |ignored| are excluded, while +// writable system tables such as dolt_ignore are kept. +func trackedRootHashes(ctx context.Context, roots doltdb.Roots, ignored *doltdb.TableNameSet) (working, staged, head map[doltdb.TableName]hash.Hash, err error) { hashes := func(root doltdb.RootValue) (map[doltdb.TableName]hash.Hash, error) { names, err := root.GetAllTableNames(ctx, false) if err != nil { @@ -684,11 +706,7 @@ func trackedRootHashes(ctx context.Context, roots doltdb.Roots) (working, staged } out := make(map[doltdb.TableName]hash.Hash, len(names)) for _, n := range names { - tracked, err := isTracked(n) - if err != nil { - return nil, err - } - if !tracked { + if doltdb.IsReadOnlySystemTable(n) || ignored.Contains(n) { continue } h, _, err := root.GetTableHash(ctx, n) @@ -714,37 +732,6 @@ func trackedRootHashes(ctx context.Context, roots doltdb.Roots) (working, staged return working, staged, head, nil } -// buildTrackedPredicate returns a function that reports whether a table is tracked for the -// purpose of detecting uncommitted-change conflicts. It returns false for read-only system -// tables and for tables matched by a dolt_ignore pattern on |root|. Writable system tables -// such as dolt_ignore are tracked so the conflict check covers the same tables the carry step -// acts on. -func buildTrackedPredicate(ctx context.Context, root doltdb.RootValue) (func(doltdb.TableName) (bool, error), error) { - names, err := root.GetAllTableNames(ctx, false) - if err != nil { - return nil, err - } - schemas := doltdb.GetUniqueSchemaNamesFromTableNames(names) - patternsBySchema, err := doltdb.GetIgnoredTablePatterns(ctx, doltdb.Roots{Working: root, Staged: root, Head: root}, schemas) - if err != nil { - return nil, err - } - return func(n doltdb.TableName) (bool, error) { - if doltdb.IsReadOnlySystemTable(n) { - return false, nil - } - patterns := patternsBySchema[n.Schema] - result, ignoreErr := patterns.IsTableNameIgnored(n) - if doltdb.AsDoltIgnoreInConflict(ignoreErr) != nil { - return true, nil - } - if ignoreErr != nil { - return false, ignoreErr - } - return result != doltdb.Ignore, nil - }, nil -} - // ClearFeatureVersion creates a new version of the provided roots where all three roots have the same // feature version. By hashing these new roots, we can easily determine whether the roots differ only by // their feature version. diff --git a/go/libraries/doltcore/env/actions/errors.go b/go/libraries/doltcore/env/actions/errors.go index c2b122208cb..f37c4e0a701 100644 --- a/go/libraries/doltcore/env/actions/errors.go +++ b/go/libraries/doltcore/env/actions/errors.go @@ -15,7 +15,7 @@ package actions import ( - "bytes" + "errors" "strings" goerrors "gopkg.in/src-d/go-errors.v1" @@ -124,18 +124,22 @@ type ErrCheckoutWouldOverwrite struct { } func (cwo ErrCheckoutWouldOverwrite) Error() string { - var buffer bytes.Buffer + var buffer strings.Builder if len(cwo.LocalChangeTables) > 0 { buffer.WriteString("Your local changes to the following tables would be overwritten by checkout:\n") for _, tbl := range cwo.LocalChangeTables { - buffer.WriteString("\t" + tbl + "\n") + buffer.WriteString("\t") + buffer.WriteString(tbl) + buffer.WriteString("\n") } buffer.WriteString("Please commit your changes or stash them before you switch branches.\n") } if len(cwo.UntrackedTables) > 0 { buffer.WriteString("The following untracked tables would be overwritten by checkout:\n") for _, tbl := range cwo.UntrackedTables { - buffer.WriteString("\t" + tbl + "\n") + buffer.WriteString("\t") + buffer.WriteString(tbl) + buffer.WriteString("\n") } buffer.WriteString("Please move or remove them before you switch branches.\n") } @@ -144,8 +148,8 @@ func (cwo ErrCheckoutWouldOverwrite) Error() string { } func IsCheckoutWouldOverwrite(err error) bool { - _, ok := err.(ErrCheckoutWouldOverwrite) - return ok + var cwo ErrCheckoutWouldOverwrite + return errors.As(err, &cwo) } var ErrCheckoutWouldOverwriteIgnoredTables = goerrors.NewKind( @@ -170,7 +174,7 @@ func NothingStagedTblDiffs(err error) []diff.TableDelta { ns, ok := err.(NothingStaged) if !ok { - panic("Must validate with IsCheckoutWouldOverwrite before calling CheckoutWouldOverwriteTables") + panic("Must validate with IsNothingStaged before calling NothingStagedTblDiffs") } return ns.NotStagedTbls diff --git a/go/libraries/doltcore/env/actions/reset.go b/go/libraries/doltcore/env/actions/reset.go index 7949c7f6f73..7b3fb558dd9 100644 --- a/go/libraries/doltcore/env/actions/reset.go +++ b/go/libraries/doltcore/env/actions/reset.go @@ -30,8 +30,9 @@ import ( ) // resetHardTables resolves a new HEAD commit from a refSpec and updates working set roots by -// resetting tracked tables to that HEAD. Uncommitted tables are carried forward onto the new -// HEAD via CarryUncommittedTables. Returns the new HEAD Commit and Roots. +// resetting tracked tables to that HEAD. Uncommitted tables are carried forward onto the new HEAD +// via CarryUncommittedTables so a hard reset does not destroy tables that were never committed. +// Returns the new HEAD Commit and Roots. func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr string, roots doltdb.Roots) (*doltdb.Commit, doltdb.Roots, error) { ddb := dbData.Ddb rsr := dbData.Rsr @@ -63,7 +64,7 @@ func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr str } } - newWorking, err := CarryUncommittedTables(ctx, roots.Working, roots.Staged, roots.Head, CarryAll) + newWorking, err := CarryUncommittedTables(ctx, roots.Working, roots.Staged, roots.Head, nil) if err != nil { return nil, doltdb.Roots{}, err } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go index efb6a8fcafa..d80f41fb384 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go @@ -73,13 +73,27 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return fmt.Errorf("unable to resolve roots for %s", dbName) } + // Compute the dolt_ignore-matched table set once for the whole checkout, then pass it to the + // overwrite check and the carry instead of recomputing it at each site. dolt_ignore is + // branch-scoped, so the source branch's patterns classify the tables. The carry leaves these + // tables on the source branch. + ignoredNames, err := doltdb.UnionTableNames(ctx, initialRoots.Working, initialRoots.Staged, branchHead) + if err != nil { + return err + } + ignoredList, err := doltdb.IdentifyIgnoredTables(ctx, initialRoots, ignoredNames) + if err != nil { + return err + } + ignored := doltdb.NewTableNameSet(ignoredList) + if !force { newBranchRoots, err := db.ResolveBranchRoots(ctx, branchRef) if err != nil { return err } if !isNewBranch { - wouldOverwrite, err := actions.CheckoutWouldOverwriteWorkingSetChanges(ctx, initialRoots, newBranchRoots) + wouldOverwrite, err := actions.CheckoutWouldOverwriteWorkingSetChanges(ctx, initialRoots, newBranchRoots, ignored) if err != nil { return err } @@ -107,10 +121,10 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr } // Only if the current working set has uncommitted changes do we carry them forward to the branch being checked out. - // If this is the case, then the destination branch must *not* have any uncommitted changes, as checked by - // CheckoutWouldOverwriteWorkingSetChanges. + // In that case the destination branch must not have its own uncommitted changes, as checked by + // CheckoutWouldOverwriteWorkingSetChanges, which only runs when not forced and not creating a new branch. if hasChanges { - err = transferWorkingChanges(ctx, dbName, initialRoots, branchHead, branchRef, force) + err = transferWorkingChanges(ctx, dbName, initialRoots, branchHead, branchRef, force, ignored) if err != nil { return err } @@ -151,12 +165,13 @@ func transferWorkingChanges( branchHead doltdb.RootValue, branchRef ref.BranchRef, force bool, + ignored *doltdb.TableNameSet, ) error { dSess := dsess.DSessFromSess(ctx.Session) // Compute the new roots before switching the working set. // This way, we don't leave the branch in a bad state in the event of an error. - newRoots, err := actions.RootsForBranch(ctx, initialRoots, branchHead, force) + newRoots, err := actions.RootsForBranch(ctx, initialRoots, branchHead, force, ignored) if err != nil { return err } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go index 55872e7f23f..12557093bff 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go @@ -809,7 +809,7 @@ func continueRebase(ctx *sql.Context) rebaseResult { if err != nil { return newRebaseError(err) } - restoredWorking, err := actions.CarryUncommittedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot(), actions.CarryAll) + restoredWorking, err := actions.CarryUncommittedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot(), nil) if err != nil { return newRebaseError(err) } From 4662fe91e506d72a9dc355acc89bbe5ab4d7393e Mon Sep 17 00:00:00 2001 From: elianddb Date: Fri, 22 May 2026 22:48:32 +0000 Subject: [PATCH 21/23] tests: cover uncommitted table carry on checkout and reset --- .../doltcore/sqle/enginetest/dolt_queries.go | 11 +++---- .../enginetest/dolt_transaction_queries.go | 3 +- integration-tests/bats/checkout.bats | 31 +++++++++++++++++++ integration-tests/bats/reset.bats | 2 +- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index a913e466578..c1532e05034 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -4028,10 +4028,10 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{10, 1, "c_val"}}, }, { + Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", // TODO(elianddb): the information_schema assertions in these carry tests are gated // to mysql because Doltgres does not populate information_schema.statistics yet, so // index lookups return no rows. Remove the Dialect gates once Doltgres supports it. - Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", Dialect: "mysql", // The unique index survived the round trip because main's working set was // never touched. @@ -4874,7 +4874,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ { Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'c' and index_name = 'idx_code';", Dialect: "mysql", - // non_unique=0 confirms the UNIQUE flag survived the retag. + // The UNIQUE flag survived the retag. Expected: []sql.Row{{"idx_code", 0, 1, "code", "YES", "BTREE", ""}}, }, { @@ -4902,7 +4902,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ { Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'e' and index_name = 'idx_composite' order by seq_in_index;", Dialect: "mysql", - // Two rows verify both column positions of the composite index were remapped correctly. + // Both column positions of the composite index were remapped. Expected: []sql.Row{{"idx_composite", 1, 1, "label", "YES", "BTREE", ""}, {"idx_composite", 1, 2, "tag", "YES", "BTREE", ""}}, }, { @@ -4988,7 +4988,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { Query: "insert into ai (val) values ('row3');", - // InsertID=3 confirms the auto-increment counter was not reset to 1 after the reset. + // The auto-increment counter was not reset by the hard reset. Expected: []sql.Row{{types.OkResult{RowsAffected: 1, InsertID: 3}}}, }, { @@ -5039,8 +5039,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ Expected: []sql.Row{{0}}, }, { - Query: "select code from overlap;", - // The target branch version wins over the untracked copy. + Query: "select code from overlap;", Expected: []sql.Row{{"feat_data"}}, }, }, diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go index d1039eba611..6548c2ff718 100755 --- a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go @@ -2502,8 +2502,7 @@ var BranchIsolationTests = []queries.TransactionTest{ Expected: []sql.Row{{0}}, }, { - Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'staged_tbl'", - // The staged variant stays on main for the same reason. + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = database() and table_name = 'staged_tbl'", Expected: []sql.Row{{0}}, }, { diff --git a/integration-tests/bats/checkout.bats b/integration-tests/bats/checkout.bats index f007c8c5e0e..f0d9ab38e17 100755 --- a/integration-tests/bats/checkout.bats +++ b/integration-tests/bats/checkout.bats @@ -1526,11 +1526,19 @@ SQL run dolt checkout feat [ "$status" -ne 0 ] + # conflict_tbl was never staged, so it is reported as untracked, not a local change. + [[ "$output" =~ "untracked tables would be overwritten by checkout" ]] || false [[ "$output" =~ "conflict_tbl" ]] || false + [[ "$output" =~ "Aborting" ]] || false run dolt branch --show-current [ "$status" -eq 0 ] [[ "$output" =~ "main" ]] || false + + # The abort leaves the local table untouched: still the one-column version. + run dolt schema show conflict_tbl + [ "$status" -eq 0 ] + [[ ! "$output" =~ "val" ]] || false } @test "checkout: ignored tables stay on the source branch" { @@ -1556,3 +1564,26 @@ SQL [ "$status" -eq 0 ] [[ "$output" =~ "1" ]] || false } + +@test "checkout: aborts when an untracked table matches conflicting dolt_ignore patterns" { + # See https://github.com/dolthub/dolt/issues/11007 + # dataset matches conflicting dolt_ignore patterns, so it cannot be classified and checkout aborts. + dolt sql -q "INSERT INTO dolt_ignore VALUES ('data*', true), ('*set', false)" + dolt commit -Am "conflicting ignore rules" + dolt branch other + + dolt sql -q "CREATE TABLE dataset (pk INT PRIMARY KEY)" + dolt sql -q "INSERT INTO dataset VALUES (7)" + + run dolt checkout other + [ "$status" -ne 0 ] + [[ "$output" =~ "conflicting patterns in dolt_ignore" ]] || false + + run dolt branch --show-current + [ "$status" -eq 0 ] + [[ "$output" =~ "main" ]] || false + + run dolt sql -q "SELECT pk FROM dataset" -r csv + [ "$status" -eq 0 ] + [[ "$output" =~ "7" ]] || false +} diff --git a/integration-tests/bats/reset.bats b/integration-tests/bats/reset.bats index 0b82322c578..87ee849e730 100644 --- a/integration-tests/bats/reset.bats +++ b/integration-tests/bats/reset.bats @@ -451,7 +451,7 @@ SQL run dolt sql -q "SELECT pk, val FROM untracked_tbl" -r csv [ "$status" -eq 0 ] - [[ "$output" =~ "1,42" ]] || false + [ "${lines[1]}" = "1,42" ] run dolt status [ "$status" -eq 0 ] From e7013ace821a8eac5d3f6ae5fa092408e953ea2e Mon Sep 17 00:00:00 2001 From: elianddb Date: Wed, 27 May 2026 01:25:14 +0000 Subject: [PATCH 22/23] doltdb: add RootsStatus and lift DiffTableHashes from merge --- go/libraries/doltcore/doltdb/ignore.go | 4 - go/libraries/doltcore/doltdb/root_val.go | 23 ++ go/libraries/doltcore/doltdb/roots_status.go | 95 ++++++ .../doltcore/env/actions/carry_tables.go | 42 ++- .../doltcore/env/actions/carry_tables_test.go | 47 ++- go/libraries/doltcore/env/actions/checkout.go | 220 ++++---------- go/libraries/doltcore/env/actions/reset.go | 4 +- go/libraries/doltcore/merge/merge.go | 27 +- go/libraries/doltcore/schema/schema_test.go | 2 +- go/libraries/doltcore/schema/tag.go | 8 +- .../sqle/dprocedures/dolt_checkout_helpers.go | 35 +-- .../doltcore/sqle/dprocedures/dolt_rebase.go | 2 +- .../dprocedures/dolt_update_column_tag.go | 3 +- .../doltcore/sqle/enginetest/dolt_queries.go | 148 ++------- .../enginetest/dolt_transaction_queries.go | 52 +++- integration-tests/bats/checkout.bats | 285 +++++++++++++++++- integration-tests/bats/reset.bats | 2 - 17 files changed, 599 insertions(+), 400 deletions(-) create mode 100644 go/libraries/doltcore/doltdb/roots_status.go diff --git a/go/libraries/doltcore/doltdb/ignore.go b/go/libraries/doltcore/doltdb/ignore.go index 70e53bf5520..e0429c52ff5 100644 --- a/go/libraries/doltcore/doltdb/ignore.go +++ b/go/libraries/doltcore/doltdb/ignore.go @@ -311,9 +311,5 @@ func (ip *IgnorePatterns) IsTableNameIgnored(tableName TableName) (IgnoreResult, } // The table name matched both positive and negative patterns. // More specific patterns override less specific patterns. - // - // TODO(elianddb): break ties when no pattern is more specific. dolt_ignore rows have no order, - // so give them one and let the last rule added win, the way git lets a later ignore rule - // override an earlier one. return resolveConflictingPatterns(trueMatches, falseMatches, tableName) } diff --git a/go/libraries/doltcore/doltdb/root_val.go b/go/libraries/doltcore/doltdb/root_val.go index b7cd3b53abf..8c2b8f11d19 100644 --- a/go/libraries/doltcore/doltdb/root_val.go +++ b/go/libraries/doltcore/doltdb/root_val.go @@ -1368,6 +1368,29 @@ func (root *rootValue) DebugString(ctx context.Context, transitive bool) string return buf.String() } +// DiffTableHashes returns the table-level changes between |from| and |to|, both produced by +// [MapTableHashes] on two roots. Modified and added tables map to their |to| hash. Deleted +// tables map to the zero hash rather than being omitted, so two diffs computed against the +// same baseline compare equal under [maps.Equal] when the same tables were dropped. +func DiffTableHashes(from, to map[TableName]hash.Hash) map[TableName]hash.Hash { + diffs := make(map[TableName]hash.Hash) + for name, fromHash := range from { + if toHash, ok := to[name]; ok { + if toHash != fromHash { + diffs[name] = toHash + } + } else { + diffs[name] = hash.Hash{} + } + } + for name, toHash := range to { + if _, ok := from[name]; !ok { + diffs[name] = toHash + } + } + return diffs +} + // MapTableHashes returns a map of each table name and hash. func MapTableHashes(ctx context.Context, root RootValue) (map[TableName]hash.Hash, error) { names, err := UnionTableNames(ctx, root) diff --git a/go/libraries/doltcore/doltdb/roots_status.go b/go/libraries/doltcore/doltdb/roots_status.go new file mode 100644 index 00000000000..cb921b8f0aa --- /dev/null +++ b/go/libraries/doltcore/doltdb/roots_status.go @@ -0,0 +1,95 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed 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 doltdb + +import ( + "context" + "maps" + + "github.com/dolthub/dolt/go/store/hash" +) + +// RootsStatus holds a [Roots] bundle's staged and unstaged table diffs. Construct with +// [NewRootsStatus]; a nil value means the branch has no uncommitted changes. +type RootsStatus struct { + // Head holds the committed table hashes, used to classify entries in Staged and Unstaged. + Head map[TableName]hash.Hash + // Staged is the head-to-staged diff: tables where staged differs from head. + Staged map[TableName]hash.Hash + // Unstaged is the staged-to-working diff: tables where working differs from staged. + Unstaged map[TableName]hash.Hash +} + +// NewRootsStatus returns |roots|' staged and unstaged table diffs, or nil when both are +// empty. Read-only system tables and tables in |ignored| are excluded. +func NewRootsStatus(ctx context.Context, roots Roots, ignored *TableNameSet) (*RootsStatus, error) { + head, err := userTableHashes(ctx, roots.Head, ignored) + if err != nil { + return nil, err + } + stagedHashes, err := userTableHashes(ctx, roots.Staged, ignored) + if err != nil { + return nil, err + } + workingHashes, err := userTableHashes(ctx, roots.Working, ignored) + if err != nil { + return nil, err + } + staged := DiffTableHashes(head, stagedHashes) + unstaged := DiffTableHashes(stagedHashes, workingHashes) + if len(staged) == 0 && len(unstaged) == 0 { + return nil, nil + } + return &RootsStatus{Head: head, Staged: staged, Unstaged: unstaged}, nil +} + +// Added returns staged tables that are absent from head. +func (s *RootsStatus) Added() []TableName { + var out []TableName + for name := range s.Staged { + if _, inHead := s.Head[name]; !inHead { + out = append(out, name) + } + } + return out +} + +// Untracked returns unstaged tables that are absent from both staged and head. +func (s *RootsStatus) Untracked() []TableName { + var out []TableName + for name := range s.Unstaged { + if _, inHead := s.Head[name]; inHead { + continue + } + if _, inStaged := s.Staged[name]; inStaged { + continue + } + out = append(out, name) + } + return out +} + +// userTableHashes returns |root|'s table hashes excluding read-only system tables and any +// table named in |ignored|. +func userTableHashes(ctx context.Context, root RootValue, ignored *TableNameSet) (map[TableName]hash.Hash, error) { + hashes, err := MapTableHashes(ctx, root) + if err != nil { + return nil, err + } + maps.DeleteFunc(hashes, func(name TableName, _ hash.Hash) bool { + return IsReadOnlySystemTable(name) || ignored.Contains(name) + }) + return hashes, nil +} diff --git a/go/libraries/doltcore/env/actions/carry_tables.go b/go/libraries/doltcore/env/actions/carry_tables.go index bd0af859ef5..0a79abc65fe 100644 --- a/go/libraries/doltcore/env/actions/carry_tables.go +++ b/go/libraries/doltcore/env/actions/carry_tables.go @@ -25,11 +25,10 @@ import ( "github.com/dolthub/dolt/go/store/types" ) -// CarryUncommittedTables copies tables present in |src| but not in |baseline| into |dest|, -// resolving column tag collisions and carrying their foreign keys. Tables already on |dest| are -// kept unchanged. Tables named in |exclude| are not carried, so pass nil to carry every uncommitted -// table. -func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.RootValue, exclude *doltdb.TableNameSet) (doltdb.RootValue, error) { +// CarryTablesAbsentFromBaseline copies tables that exist in |src| but not in |baseline| into +// |dest|, resolving column tag collisions and carrying matching foreign keys. Tables already +// on |dest| are left alone. Names in |exclude| are skipped; pass nil to carry all. +func CarryTablesAbsentFromBaseline(ctx context.Context, src, baseline, dest doltdb.RootValue, exclude *doltdb.TableNameSet) (doltdb.RootValue, error) { srcSchemas, err := doltdb.GetAllSchemas(ctx, src) if err != nil { return nil, err @@ -39,12 +38,12 @@ func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.Root return nil, err } - uncommitted, err := uncommittedTableSchemas(ctx, srcSchemas, baseline, exclude) + absent, err := tablesAbsentFromBaseline(ctx, srcSchemas, baseline, exclude) if err != nil { return nil, err } - dest, carried, tagRemaps, err := carryTables(ctx, src, dest, uncommitted, destSchemas) + dest, carried, tagRemaps, err := carryTables(ctx, src, dest, absent, destSchemas) if err != nil { return nil, err } @@ -56,17 +55,16 @@ func CarryUncommittedTables(ctx context.Context, src, baseline, dest doltdb.Root return carryForeignKeys(ctx, src, dest, carried, tagRemaps, srcSchemas, destSchemas) } -// uncommittedTableSchemas returns the subset of |srcSchemas| for tables that |src| holds but -// |baseline| does not, excluding read-only dolt system tables and any names in |exclude|. Tables -// present on |baseline| are left to the three-way merge even when their contents differ. -func uncommittedTableSchemas(ctx context.Context, srcSchemas map[doltdb.TableName]schema.Schema, baseline doltdb.RootValue, exclude *doltdb.TableNameSet) (map[doltdb.TableName]schema.Schema, error) { +// tablesAbsentFromBaseline returns the entries of |srcSchemas| not present in |baseline|, +// excluding read-only dolt system tables and any names in |exclude|. +func tablesAbsentFromBaseline(ctx context.Context, srcSchemas map[doltdb.TableName]schema.Schema, baseline doltdb.RootValue, exclude *doltdb.TableNameSet) (map[doltdb.TableName]schema.Schema, error) { baselineNames, err := baseline.GetAllTableNames(ctx, false) if err != nil { return nil, err } baselineSet := doltdb.NewTableNameSet(baselineNames) - uncommitted := make(map[doltdb.TableName]schema.Schema, len(srcSchemas)) + absent := make(map[doltdb.TableName]schema.Schema, len(srcSchemas)) for name, sch := range srcSchemas { if baselineSet.Contains(name) || doltdb.IsReadOnlySystemTable(name) { continue @@ -74,9 +72,9 @@ func uncommittedTableSchemas(ctx context.Context, srcSchemas map[doltdb.TableNam if exclude != nil && exclude.Contains(name) { continue } - uncommitted[name] = sch + absent[name] = sch } - return uncommitted, nil + return absent, nil } // carryTables copies each entry of |toCarry| from |src| into |dest|, retagging columns whose @@ -93,9 +91,8 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry, destS allTagRemaps := make(map[doltdb.TableName]map[uint64]uint64) var carried []doltdb.TableName - // Carry tables in a stable order. AutoGenerateTag derives replacement tags from the tags - // already held, which accumulate as tables are carried, so a nondeterministic map order could - // produce different tags for the same checkout across runs. + // Sort so retags reproduce across runs. Each table's new tags depend on tables + // carried before it. names := make([]doltdb.TableName, 0, len(toCarry)) for name := range toCarry { names = append(names, name) @@ -149,11 +146,9 @@ func carryTables(ctx context.Context, src, dest doltdb.RootValue, toCarry, destS } // carryForeignKeys copies foreign keys from |src| whose child table is in |carried| into -// |dest|'s FK collection and applies |tagRemaps| so retagged child columns stay consistent. -// A key already present on |dest| is replaced so the retagged carried columns are reflected rather -// than leaving a stale tag. When a carried key references a parent that already lives on |dest|, the referenced -// column tags are re-resolved by column name against |destSchemas| so the same column can carry a -// different internal tag on each branch and the foreign key stays valid. +// |dest|, applying |tagRemaps| to child columns. Parent column tags are re-resolved by +// column name via |destSchemas| when the parent already lives on |dest|. An existing key +// of the same name on |dest| is replaced. func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried []doltdb.TableName, tagRemaps map[doltdb.TableName]map[uint64]uint64, srcSchemas, destSchemas map[doltdb.TableName]schema.Schema) (doltdb.RootValue, error) { destFks, err := dest.GetForeignKeyCollection(ctx) if err != nil { @@ -174,8 +169,7 @@ func carryForeignKeys(ctx context.Context, src, dest doltdb.RootValue, carried [ } else { fk.ReferencedTableColumns = schema.RemapTagsByColumnName(fk.ReferencedTableColumns, srcSchemas[fk.ReferencedTableName], destSchemas[fk.ReferencedTableName]) } - // Drop any pre-merged copy first so AddKeys does not reject a duplicate name and the - // remapped key replaces the stale one. + // Remove the merge's stale copy first. AddKeys would otherwise refuse the duplicate name. destFks.RemoveKeyByName(fk.Name, fk.TableName) return false, destFks.AddKeys(fk) }) diff --git a/go/libraries/doltcore/env/actions/carry_tables_test.go b/go/libraries/doltcore/env/actions/carry_tables_test.go index 56652e054b5..709cde4e4f9 100644 --- a/go/libraries/doltcore/env/actions/carry_tables_test.go +++ b/go/libraries/doltcore/env/actions/carry_tables_test.go @@ -53,30 +53,26 @@ func singleColPKSchema(t *testing.T, colName string, tag uint64) schema.Schema { return dtestutils.CreateSchema(col) } -// TestCarryUncommittedTables verifies that CarryUncommittedTables carries untracked tables into the +// TestCarryTablesAbsentFromBaseline verifies that CarryTablesAbsentFromBaseline carries untracked tables into the // target root, resolves tag collisions, and propagates foreign keys. -func TestCarryUncommittedTables(t *testing.T) { +func TestCarryTablesAbsentFromBaseline(t *testing.T) { // See https://github.com/dolthub/dolt/issues/11007 ctx, emptyRoot := newEmptyRoot(t) - // bar.code and users.name share collidingTag so the carry must retag users.name; posts.title - // gets a distinct tag and must be left untouched. const collidingTag uint64 = 9815 const postsTitleTag uint64 = 13593 - withUniqueIndex := func(sch schema.Schema, indexName string, tag uint64) schema.Schema { - _, err := sch.Indexes().AddIndexByColTags(indexName, []uint64{tag}, nil, schema.IndexProperties{IsUserDefined: true, IsUnique: true}) - require.NoError(t, err) - return sch - } - target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", collidingTag)) require.NoError(t, err) // Source has bar (so the foreign key can reference it), plus untracked users and posts. working, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "bar"}, singleColPKSchema(t, "code", collidingTag)) require.NoError(t, err) - working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, withUniqueIndex(singleColPKSchema(t, "name", collidingTag), "idx_name", collidingTag)) + // bar.code and users.name share collidingTag so the carry must retag users.name. + usersSch := singleColPKSchema(t, "name", collidingTag) + _, err = usersSch.Indexes().AddIndexByColTags("idx_name", []uint64{collidingTag}, nil, schema.IndexProperties{IsUserDefined: true, IsUnique: true}) + require.NoError(t, err) + working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "users"}, usersSch) require.NoError(t, err) working, err = doltdb.CreateEmptyTable(ctx, working, doltdb.TableName{Name: "posts"}, singleColPKSchema(t, "title", postsTitleTag)) require.NoError(t, err) @@ -89,8 +85,7 @@ func TestCarryUncommittedTables(t *testing.T) { ReferencedTableColumns: []uint64{collidingTag}, }) - // |baseline| is target so only the child table is carried. Generally we exclude tables that exist on dest. - result, err := CarryUncommittedTables(ctx, working, target, target, nil) + result, err := CarryTablesAbsentFromBaseline(ctx, working, target, target, nil) require.NoError(t, err) t.Run("preserves tag on non-collision", func(t *testing.T) { @@ -122,8 +117,6 @@ func TestCarryUncommittedTables(t *testing.T) { }) t.Run("FK child column retagged, parent column unchanged", func(t *testing.T) { - // users.name and bar.code share collidingTag so users.name is retagged. bar.code is committed - // on the target and keeps its tag, so only the child side of the FK changes. usersTbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "users"}) require.NoError(t, err) require.True(t, ok) @@ -143,14 +136,15 @@ func TestCarryUncommittedTables(t *testing.T) { }) } -// TestCarryUncommittedTablesMergedForeignKeyFollowsRetag verifies that when a carried child's column +// TestCarryTablesAbsentFromBaselineMergedForeignKeyFollowsRetag verifies that when a carried child's column // is retagged to resolve a collision, a foreign key already merged onto the target is rewritten to // the new tag instead of being left pointing at the stale source tag. -func TestCarryUncommittedTablesMergedForeignKeyFollowsRetag(t *testing.T) { +func TestCarryTablesAbsentFromBaselineMergedForeignKeyFollowsRetag(t *testing.T) { // See https://github.com/dolthub/dolt/issues/11007 ctx, emptyRoot := newEmptyRoot(t) - const collide uint64 = 70001 // shared by target anchor.x and source child.cid, forcing a retag + // Shared by target anchor.x and source child.cid, forcing a retag + const collide uint64 = 70001 const parentTag uint64 = 70002 target, err := doltdb.CreateEmptyTable(ctx, emptyRoot, doltdb.TableName{Name: "parent"}, singleColPKSchema(t, "id", parentTag)) @@ -173,8 +167,7 @@ func TestCarryUncommittedTablesMergedForeignKeyFollowsRetag(t *testing.T) { working = putFK(t, ctx, working, fk) target = putFK(t, ctx, target, fk) - // |baseline| is target so parent is treated as tracked and only child is carried. - result, err := CarryUncommittedTables(ctx, working, target, target, nil) + result, err := CarryTablesAbsentFromBaseline(ctx, working, target, target, nil) require.NoError(t, err) childTbl, ok, err := result.GetTable(ctx, doltdb.TableName{Name: "child"}) @@ -194,10 +187,10 @@ func TestCarryUncommittedTablesMergedForeignKeyFollowsRetag(t *testing.T) { "carried foreign key must follow the child column retag, not keep the stale source tag") } -// TestCarryUncommittedTablesParentTagMismatch verifies that a foreign key carried from source onto +// TestCarryTablesAbsentFromBaselineParentTagMismatch verifies that a foreign key carried from source onto // target gets its referenced column tags rewritten to match the target parent schema when the // same parent column has a different tag on source and target. -func TestCarryUncommittedTablesParentTagMismatch(t *testing.T) { +func TestCarryTablesAbsentFromBaselineParentTagMismatch(t *testing.T) { // See https://github.com/dolthub/dolt/issues/11007 ctx, emptyRoot := newEmptyRoot(t) @@ -221,8 +214,7 @@ func TestCarryUncommittedTablesParentTagMismatch(t *testing.T) { ReferencedTableColumns: []uint64{parentTagOnSource}, }) - // |baseline| is target so only the child table is carried. Generally we exclude tables that exist on dest. - result, err := CarryUncommittedTables(ctx, working, target, target, nil) + result, err := CarryTablesAbsentFromBaseline(ctx, working, target, target, nil) require.NoError(t, err) resultFks, err := result.GetForeignKeyCollection(ctx) @@ -235,11 +227,11 @@ func TestCarryUncommittedTablesParentTagMismatch(t *testing.T) { "child column tag must be unchanged because there was no collision") } -// TestCarryUncommittedTablesParentColumnRenamedKeepsSourceTag documents the accepted divergence when +// TestCarryTablesAbsentFromBaselineParentColumnRenamedKeepsSourceTag documents the accepted divergence when // the referenced parent column has a different name on source and target (renamed on the target // branch). RemapTagsByColumnName cannot match the source column name against the target parent, so it // leaves the carried foreign key on the source tag. -func TestCarryUncommittedTablesParentColumnRenamedKeepsSourceTag(t *testing.T) { +func TestCarryTablesAbsentFromBaselineParentColumnRenamedKeepsSourceTag(t *testing.T) { // See https://github.com/dolthub/dolt/issues/11007 ctx, emptyRoot := newEmptyRoot(t) @@ -263,8 +255,7 @@ func TestCarryUncommittedTablesParentColumnRenamedKeepsSourceTag(t *testing.T) { ReferencedTableColumns: []uint64{parentTagOnSource}, }) - // |baseline| is target so only the child table is carried. - result, err := CarryUncommittedTables(ctx, working, target, target, nil) + result, err := CarryTablesAbsentFromBaseline(ctx, working, target, target, nil) require.NoError(t, err) resultFks, err := result.GetForeignKeyCollection(ctx) diff --git a/go/libraries/doltcore/env/actions/checkout.go b/go/libraries/doltcore/env/actions/checkout.go index c30d7ad9ab6..b740a0be02e 100644 --- a/go/libraries/doltcore/env/actions/checkout.go +++ b/go/libraries/doltcore/env/actions/checkout.go @@ -121,66 +121,79 @@ func FindTableInRoots(ctx *sql.Context, roots doltdb.Roots, name string) (doltdb return doltdb.TableName{}, nil, false, nil } -// CheckoutWouldOverwriteUncommittedTables returns ErrCheckoutWouldOverwrite when any table -// in |roots.Working| but absent from |roots.Head| differs from the committed version on -// |targetRoot|. Tables also present in |roots.Staged| are reported as local changes, while -// tables only in |roots.Working| are reported as untracked. -func CheckoutWouldOverwriteUncommittedTables(ctx context.Context, roots doltdb.Roots, targetRoot doltdb.RootValue) error { - workingNames, err := roots.Working.GetAllTableNames(ctx, false) - if err != nil { - return err +// CheckoutWouldOverwriteWorkingSets returns an error if checking out from a branch with +// uncommitted state |src| onto |destRoots| would silently lose work. This happens when an +// added source table collides with a committed table of the same name on destination, or +// when both branches carry differing uncommitted changes. Pass nil |src| if the source has +// none. Read-only system tables and tables in |ignored| are skipped. +func CheckoutWouldOverwriteWorkingSets(ctx context.Context, src *doltdb.RootsStatus, destRoots doltdb.Roots, ignored *doltdb.TableNameSet) error { + if src == nil { + return nil } - headNames, err := roots.Head.GetAllTableNames(ctx, false) + localChange, err := findCollisions(ctx, src.Added(), destRoots.Head) if err != nil { return err } - stagedNames, err := roots.Staged.GetAllTableNames(ctx, false) + untracked, err := findCollisions(ctx, src.Untracked(), destRoots.Head) if err != nil { return err } - headNamesSet := doltdb.NewTableNameSet(headNames) - stagedNamesSet := doltdb.NewTableNameSet(stagedNames) - - var localChange, untracked []string - for _, name := range workingNames { - if headNamesSet.Contains(name) || doltdb.IsReadOnlySystemTable(name) { + // An unstaged working modification to a tracked table whose committed version on the + // target branch differs from source's head would be silently overwritten by the checkout. + for name := range src.Unstaged { + srcHeadHash, inHead := src.Head[name] + if !inHead { continue } - targetHash, _, err := targetRoot.GetTableHash(ctx, name) + destHeadHash, _, err := destRoots.Head.GetTableHash(ctx, name) if err != nil { return err } - if targetHash.IsEmpty() { - continue - } - workingHash, _, err := roots.Working.GetTableHash(ctx, name) - if err != nil { - return err - } - if workingHash == targetHash { - continue - } - if stagedNamesSet.Contains(name) { + if destHeadHash != srcHeadHash { localChange = append(localChange, name.String()) - } else { - untracked = append(untracked, name.String()) } } - if len(localChange) > 0 || len(untracked) > 0 { slices.Sort(localChange) slices.Sort(untracked) return ErrCheckoutWouldOverwrite{LocalChangeTables: localChange, UntrackedTables: untracked} } + dest, err := doltdb.NewRootsStatus(ctx, destRoots, ignored) + if err != nil || dest == nil { + return err + } + if !maps.Equal(src.Staged, dest.Staged) || !maps.Equal(src.Unstaged, dest.Unstaged) { + return ErrWorkingSetsOnBothBranches + } return nil } +// findCollisions returns the names in |candidates| that already exist as committed tables +// on |destHead|. Such names cannot be safely carried: the carry keeps the destination's +// version and the source-side cleanup then discards the source's copy. +func findCollisions(ctx context.Context, candidates []doltdb.TableName, destHead doltdb.RootValue) ([]string, error) { + var out []string + for _, name := range candidates { + targetHash, _, err := destHead.GetTableHash(ctx, name) + if err != nil { + return nil, err + } + if targetHash.IsEmpty() { + continue + } + out = append(out, name.String()) + } + return out, nil +} + // RootsForBranch returns the roots for checking out a branch whose head is |branchRoot|. // |roots.Head| must be the pre-checkout head. Uncommitted tables, those present in working // or staged but absent from the old head, are moved into the new root via -// [CarryUncommittedTables], except the tables in |ignored|, which stay on the source branch. +// [CarryTablesAbsentFromBaseline], except the tables in |ignored|, which stay on the source branch. +// The caller is responsible for running [CheckoutWouldOverwriteWorkingSets] before reaching +// here. When |force| skipped that check, the carry below keeps the destination's version on +// any name collision. func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.RootValue, force bool, ignored *doltdb.TableNameSet) (doltdb.Roots, error) { - conflicts := doltdb.NewTableNameSet(nil) if roots.Head == nil { roots.Working = branchRoot roots.Staged = branchRoot @@ -188,13 +201,16 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return roots, nil } - if !force { - if err := CheckoutWouldOverwriteUncommittedTables(ctx, roots, branchRoot); err != nil { + // Force discards tracked local changes but preserves untracked tables. + if force { + working, err := CarryTablesAbsentFromBaseline(ctx, roots.Working, roots.Staged, branchRoot, ignored) + if err != nil { return doltdb.Roots{}, err } + return doltdb.Roots{Working: working, Staged: branchRoot, Head: branchRoot}, nil } - // When |force| skipped the conflict check, the carry below keeps the destination's version on - // any name collision. + + conflicts := doltdb.NewTableNameSet(nil) // Snapshot the pre-checkout roots before the three-way merge below reassigns roots.Working // and roots.Staged so the carry step still sees the original values. @@ -234,7 +250,7 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } - // Put the merged collections first so CarryUncommittedTables layers untracked keys on top. + // Put the merged collections first so CarryTablesAbsentFromBaseline layers untracked keys on top. roots.Working, err = roots.Working.PutForeignKeyCollection(ctx, workingForeignKeys) if err != nil { return doltdb.Roots{}, err @@ -245,14 +261,13 @@ func RootsForBranch(ctx context.Context, roots doltdb.Roots, branchRoot doltdb.R return doltdb.Roots{}, err } - // Ignored tables stay on the source branch because dolt_ignore is branch-scoped. |ignored| is - // the precomputed set the caller excludes from both carries. - roots.Working, err = CarryUncommittedTables(ctx, preCheckoutRoots.Working, preCheckoutRoots.Head, roots.Working, ignored) + // Ignored tables stay on the source branch because dolt_ignore is branch-scoped. + roots.Working, err = CarryTablesAbsentFromBaseline(ctx, preCheckoutRoots.Working, preCheckoutRoots.Head, roots.Working, ignored) if err != nil { return doltdb.Roots{}, err } - roots.Staged, err = CarryUncommittedTables(ctx, preCheckoutRoots.Staged, preCheckoutRoots.Head, roots.Staged, ignored) + roots.Staged, err = CarryTablesAbsentFromBaseline(ctx, preCheckoutRoots.Staged, preCheckoutRoots.Head, roots.Staged, ignored) if err != nil { return doltdb.Roots{}, err } @@ -383,7 +398,10 @@ func threeWayMergeTableHashes(ctx context.Context, oldRoot, newRoot, changedRoot return nil, err } - if oldHash == changedHash { + if oldHash == changedHash || changedHash == newHash { + // Either the source did not modify this table, or it modified the table to the + // content the target already has. Either way the target's version is the result + // and no work is lost. resultMap[tblName] = newHash } else if oldHash == newHash { resultMap[tblName] = changedHash @@ -411,7 +429,7 @@ func threeWayMergeTableHashes(ctx context.Context, oldRoot, newRoot, changedRoot return nil, err } - // Skip uncommitted tables here so CarryUncommittedTables can pick them up after + // Skip uncommitted tables here so CarryTablesAbsentFromBaseline can pick them up after // the merged tracked state lands on the destination. Carry needs that final state // to detect column tag collisions and to rewrite foreign key references. if oldHash == emptyHash { @@ -646,92 +664,6 @@ func writeTableHashes(ctx context.Context, head doltdb.RootValue, tblHashes map[ return head, nil } -// CheckoutWouldOverwriteWorkingSetChanges reports whether a checkout would overwrite -// uncommitted changes on the destination. System and dolt_ignore-matched tables are excluded. -// Identical uncommitted changes on both sides are not reported as an overwrite. -func CheckoutWouldOverwriteWorkingSetChanges(ctx context.Context, sourceRoots, destRoots doltdb.Roots, ignored *doltdb.TableNameSet) (bool, error) { - // Fast path, like comparing tree hashes in git: if either side has no uncommitted changes, - // nothing can be overwritten, so skip the per-table hashing. This whole-root check may count - // ignored or system tables as dirty, which only costs a fall-through to the precise comparison - // below. - srcDirty, _, _, err := RootHasUncommittedChanges(sourceRoots) - if err != nil { - return false, err - } - if !srcDirty { - return false, nil - } - destDirty, _, _, err := RootHasUncommittedChanges(destRoots) - if err != nil { - return false, err - } - if !destDirty { - return false, nil - } - - // Both sides have uncommitted changes. Compare only the tracked tables to decide whether the - // carry would actually overwrite the destination's uncommitted work. - // - // TODO(elianddb): this scans every tracked table on both branches, but only tables that - // differ from their branch's last commit can be overwritten. The table list is stored as a - // sorted tree that can compare two versions while skipping the parts that did not change, - // like git comparing trees, so we could look at just the changed tables instead of all of - // them. Doing so needs a small accessor to reach that tree, which the storage layer keeps - // private today. - srcW, srcS, srcH, err := trackedRootHashes(ctx, sourceRoots, ignored) - if err != nil { - return false, err - } - if maps.Equal(srcW, srcH) && maps.Equal(srcS, srcH) { - return false, nil - } - destW, destS, destH, err := trackedRootHashes(ctx, destRoots, ignored) - if err != nil { - return false, err - } - if maps.Equal(destW, destH) && maps.Equal(destS, destH) { - return false, nil - } - return !maps.Equal(srcW, destW) || !maps.Equal(srcS, destS), nil -} - -// trackedRootHashes returns name to hash maps for working, staged, and head of |roots|, keeping -// only tracked tables. Read-only system tables and the tables in |ignored| are excluded, while -// writable system tables such as dolt_ignore are kept. -func trackedRootHashes(ctx context.Context, roots doltdb.Roots, ignored *doltdb.TableNameSet) (working, staged, head map[doltdb.TableName]hash.Hash, err error) { - hashes := func(root doltdb.RootValue) (map[doltdb.TableName]hash.Hash, error) { - names, err := root.GetAllTableNames(ctx, false) - if err != nil { - return nil, err - } - out := make(map[doltdb.TableName]hash.Hash, len(names)) - for _, n := range names { - if doltdb.IsReadOnlySystemTable(n) || ignored.Contains(n) { - continue - } - h, _, err := root.GetTableHash(ctx, n) - if err != nil { - return nil, err - } - out[n] = h - } - return out, nil - } - working, err = hashes(roots.Working) - if err != nil { - return nil, nil, nil, err - } - staged, err = hashes(roots.Staged) - if err != nil { - return nil, nil, nil, err - } - head, err = hashes(roots.Head) - if err != nil { - return nil, nil, nil, err - } - return working, staged, head, nil -} - // ClearFeatureVersion creates a new version of the provided roots where all three roots have the same // feature version. By hashing these new roots, we can easily determine whether the roots differ only by // their feature version. @@ -757,31 +689,3 @@ func ClearFeatureVersion(ctx context.Context, roots doltdb.Roots) (doltdb.Roots, Staged: modifiedStaged, }, nil } - -// RootHasUncommittedChanges returns whether the roots given have uncommitted changes, and the hashes of -// the working and staged roots are identical. This function will ignore any difference in feature -// versions between the root values. -func RootHasUncommittedChanges(roots doltdb.Roots) (hasChanges bool, workingHash hash.Hash, stagedHash hash.Hash, err error) { - roots, err = ClearFeatureVersion(context.Background(), roots) - if err != nil { - return false, hash.Hash{}, hash.Hash{}, err - } - - headHash, err := roots.Head.HashOf() - if err != nil { - return false, hash.Hash{}, hash.Hash{}, err - } - - workingHash, err = roots.Working.HashOf() - if err != nil { - return false, hash.Hash{}, hash.Hash{}, err - } - - stagedHash, err = roots.Staged.HashOf() - if err != nil { - return false, hash.Hash{}, hash.Hash{}, err - } - - hasChanges = workingHash != stagedHash || stagedHash != headHash - return hasChanges, workingHash, stagedHash, nil -} diff --git a/go/libraries/doltcore/env/actions/reset.go b/go/libraries/doltcore/env/actions/reset.go index 7b3fb558dd9..92ac546d4dc 100644 --- a/go/libraries/doltcore/env/actions/reset.go +++ b/go/libraries/doltcore/env/actions/reset.go @@ -31,7 +31,7 @@ import ( // resetHardTables resolves a new HEAD commit from a refSpec and updates working set roots by // resetting tracked tables to that HEAD. Uncommitted tables are carried forward onto the new HEAD -// via CarryUncommittedTables so a hard reset does not destroy tables that were never committed. +// via CarryTablesAbsentFromBaseline so a hard reset does not destroy tables that were never committed. // Returns the new HEAD Commit and Roots. func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr string, roots doltdb.Roots) (*doltdb.Commit, doltdb.Roots, error) { ddb := dbData.Ddb @@ -64,7 +64,7 @@ func resetHardTables[C doltdb.Context](ctx C, dbData env.DbData[C], cSpecStr str } } - newWorking, err := CarryUncommittedTables(ctx, roots.Working, roots.Staged, roots.Head, nil) + newWorking, err := CarryTablesAbsentFromBaseline(ctx, roots.Working, roots.Staged, roots.Head, nil) if err != nil { return nil, doltdb.Roots{}, err } diff --git a/go/libraries/doltcore/merge/merge.go b/go/libraries/doltcore/merge/merge.go index c8f066f47b2..82182d1ff9a 100644 --- a/go/libraries/doltcore/merge/merge.go +++ b/go/libraries/doltcore/merge/merge.go @@ -429,8 +429,8 @@ func MergeWouldStompChanges(ctx context.Context, roots doltdb.Roots, mergeCommit return nil, nil, err } - headWorkingDiffs := diffTableHashes(headTableHashes, workingTableHashes) - mergedHeadDiffs := diffTableHashes(headTableHashes, mergeTableHashes) + headWorkingDiffs := doltdb.DiffTableHashes(headTableHashes, workingTableHashes) + mergedHeadDiffs := doltdb.DiffTableHashes(headTableHashes, mergeTableHashes) stompedTables := make([]doltdb.TableName, 0, len(headWorkingDiffs)) for tName := range headWorkingDiffs { @@ -443,26 +443,3 @@ func MergeWouldStompChanges(ctx context.Context, roots doltdb.Roots, mergeCommit return stompedTables, headWorkingDiffs, nil } -func diffTableHashes(headTableHashes, otherTableHashes map[doltdb.TableName]hash.Hash) map[doltdb.TableName]hash.Hash { - diffs := make(map[doltdb.TableName]hash.Hash) - for tName, hh := range headTableHashes { - if h, ok := otherTableHashes[tName]; ok { - if h != hh { - // modification - diffs[tName] = h - } - } else { - // deletion - diffs[tName] = hash.Hash{} - } - } - - for tName, h := range otherTableHashes { - if _, ok := headTableHashes[tName]; !ok { - // addition - diffs[tName] = h - } - } - - return diffs -} diff --git a/go/libraries/doltcore/schema/schema_test.go b/go/libraries/doltcore/schema/schema_test.go index afbd131b8c4..85cb418def8 100644 --- a/go/libraries/doltcore/schema/schema_test.go +++ b/go/libraries/doltcore/schema/schema_test.go @@ -437,7 +437,7 @@ func TestWithRemappedColumnTagsPreservesMetadata(t *testing.T) { remapped, err := WithRemappedColumnTags(sch, map[uint64]uint64{100: 200, 101: 201}) require.NoError(t, err) - // The remap actually happened: the new tags resolve and the old ones are gone. + // New tags resolve and the old ones are gone. got, ok := remapped.GetAllCols().GetByName("val") require.True(t, ok) require.Equal(t, uint64(201), got.Tag, "column tag must be remapped") diff --git a/go/libraries/doltcore/schema/tag.go b/go/libraries/doltcore/schema/tag.go index f21c556f114..da29cf2673a 100644 --- a/go/libraries/doltcore/schema/tag.go +++ b/go/libraries/doltcore/schema/tag.go @@ -94,11 +94,9 @@ func RemapTags(tags []uint64, remap map[uint64]uint64) []uint64 { return out } -// RemapTagsByColumnName returns |tags| rewritten so each tag points at the same-named column -// on |destSch| instead of |srcSch|. When either schema is nil or any tag cannot be resolved on -// both sides, the input |tags| slice is returned as is (treat it as read-only). The all-or-nothing -// fallback leaves the foreign key pointing at the source tags, which is preferable to a partial -// remap that would mix source and destination tags into one key. +// RemapTagsByColumnName returns |tags| with each tag pointing at the same-named column on +// |destSch| instead of |srcSch|. Returns |tags| unchanged (treat as read-only) when either +// schema is nil or any tag cannot be resolved on both sides, avoiding a partial remap. func RemapTagsByColumnName(tags []uint64, srcSch, destSch Schema) []uint64 { if srcSch == nil || destSch == nil { return tags diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go index d80f41fb384..a2cf9538418 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_checkout_helpers.go @@ -73,10 +73,6 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return fmt.Errorf("unable to resolve roots for %s", dbName) } - // Compute the dolt_ignore-matched table set once for the whole checkout, then pass it to the - // overwrite check and the carry instead of recomputing it at each site. dolt_ignore is - // branch-scoped, so the source branch's patterns classify the tables. The carry leaves these - // tables on the source branch. ignoredNames, err := doltdb.UnionTableNames(ctx, initialRoots.Working, initialRoots.Staged, branchHead) if err != nil { return err @@ -87,28 +83,23 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr } ignored := doltdb.NewTableNameSet(ignoredList) - if !force { - newBranchRoots, err := db.ResolveBranchRoots(ctx, branchRef) + var srcChanges *doltdb.RootsStatus + if workingSetExists { + srcChanges, err = doltdb.NewRootsStatus(ctx, initialRoots, ignored) if err != nil { return err } - if !isNewBranch { - wouldOverwrite, err := actions.CheckoutWouldOverwriteWorkingSetChanges(ctx, initialRoots, newBranchRoots, ignored) - if err != nil { - return err - } - if wouldOverwrite { - return actions.ErrWorkingSetsOnBothBranches - } - } } - hasChanges := false - if workingSetExists { - hasChanges, _, _, err = actions.RootHasUncommittedChanges(initialRoots) + if !force && !isNewBranch { + newBranchRoots, err := db.ResolveBranchRoots(ctx, branchRef) if err != nil { return err } + // The destination branch must not have its own uncommitted changes. + if err := actions.CheckoutWouldOverwriteWorkingSets(ctx, srcChanges, newBranchRoots, ignored); err != nil { + return err + } } dbData, ok := dSess.GetDbData(ctx, dbName) @@ -120,10 +111,7 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr return err } - // Only if the current working set has uncommitted changes do we carry them forward to the branch being checked out. - // In that case the destination branch must not have its own uncommitted changes, as checked by - // CheckoutWouldOverwriteWorkingSetChanges, which only runs when not forced and not creating a new branch. - if hasChanges { + if srcChanges != nil { err = transferWorkingChanges(ctx, dbName, initialRoots, branchHead, branchRef, force, ignored) if err != nil { return err @@ -138,10 +126,9 @@ func MoveWorkingSetToBranch(ctx *sql.Context, brName string, force bool, isNewBr if err != nil { return err } - } - if workingSetExists && hasChanges { + if workingSetExists && srcChanges != nil { name, email, _, _, err := dsess.ResolveNameEmail(ctx, dsess.DoltCommitterName, dsess.DoltCommitterEmail) if err != nil { return err diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go index 12557093bff..cee9506db54 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_rebase.go @@ -809,7 +809,7 @@ func continueRebase(ctx *sql.Context) rebaseResult { if err != nil { return newRebaseError(err) } - restoredWorking, err := actions.CarryUncommittedTables(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot(), nil) + restoredWorking, err := actions.CarryTablesAbsentFromBaseline(ctx, preRebaseWorkingRoot, preRebaseStagedRoot, postCopyWS.WorkingRoot(), nil) if err != nil { return newRebaseError(err) } diff --git a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go index 892414eb44c..af3cb82488e 100644 --- a/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go +++ b/go/libraries/doltcore/sqle/dprocedures/dolt_update_column_tag.go @@ -48,8 +48,7 @@ func doltUpdateColumnTag(ctx *sql.Context, args ...string) (sql.RowIter, error) tbl, tName, ok, err := doltdb.GetTableInsensitive(ctx, root, doltdb.TableName{Name: tableName}) if err != nil { return nil, err - } - if !ok { + } else if !ok { return nil, fmt.Errorf("table %s does not exist", tableName) } diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go index c1532e05034..efd629ef4bf 100644 --- a/go/libraries/doltcore/sqle/enginetest/dolt_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_queries.go @@ -4010,9 +4010,10 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'empty'"}}, }, { - Query: "select count(*) from information_schema.tables where table_schema = database() and table_name in ('parent', 'child');", - Dialect: "mysql", - // Bare SQL checkout does not carry uncommitted tables, so empty stays empty. + Query: "select count(*) from information_schema.tables where table_schema = database() and table_name in ('parent', 'child');", + // TODO(elianddb): the information_schema assertions in these carry tests are gated + // to mysql because Doltgres does not populate information_schema.statistics yet. + Dialect: "mysql", Expected: []sql.Row{{0}}, }, { @@ -4028,13 +4029,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{10, 1, "c_val"}}, }, { - Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", - // TODO(elianddb): the information_schema assertions in these carry tests are gated - // to mysql because Doltgres does not populate information_schema.statistics yet, so - // index lookups return no rows. Remove the Dialect gates once Doltgres supports it. - Dialect: "mysql", - // The unique index survived the round trip because main's working set was - // never touched. + Query: "select index_name, non_unique, seq_in_index, column_name from information_schema.statistics where table_schema = database() and table_name = 'child' and index_name = 'idx_val';", + Dialect: "mysql", Expected: []sql.Row{{"idx_val", 0, 1, "val"}}, }, { @@ -4055,44 +4051,8 @@ var DoltCheckoutScripts = []queries.ScriptTest{ }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout does not abort when the source has an untracked table that conflicts with the target", - SetUpScript: []string{ - "call dolt_branch('feat');", - "call dolt_checkout('--move', 'feat');", - "create table conflict_tbl (id int primary key, val int);", - "call dolt_commit('-Am', 'add conflict_tbl on feat');", - "call dolt_checkout('--move', 'main');", - "create table conflict_tbl (id int primary key);", - }, - Assertions: []queries.ScriptTestAssertion{ - { - Query: "call dolt_checkout('feat');", - // Bare SQL switches to feat without carrying main's local conflict_tbl, so - // nothing on feat would be overwritten. - Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, - }, - { - Query: "select column_name from information_schema.columns where table_schema = database() and table_name = 'conflict_tbl' order by ordinal_position;", - Dialect: "mysql", - // feat sees its own committed two-column schema. - Expected: []sql.Row{{"id"}, {"val"}}, - }, - { - Query: "call dolt_checkout('main');", - Expected: []sql.Row{{0, "Switched to branch 'main'"}}, - }, - { - Query: "select column_name from information_schema.columns where table_schema = database() and table_name = 'conflict_tbl' order by ordinal_position;", - Dialect: "mysql", - // main's untracked one-column copy is still there because bare SQL did not - // touch its working set. - Expected: []sql.Row{{"id"}}, - }, - }, - }, - { - // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout keeps an untracked foreign key on the source even when the parent is missing on the target", + Name: "dolt_checkout keeps an untracked foreign key on the source even when the parent is missing on the target", + Dialect: "mysql", SetUpScript: []string{ "create table parent (id int primary key);", "call dolt_commit('-Am', 'add parent');", @@ -4109,8 +4069,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'no_parent'"}}, }, { - Query: "select count(*) from information_schema.tables where table_schema = database() and table_name = 'child';", - Dialect: "mysql", + Query: "select count(*) from information_schema.tables where table_schema = database() and table_name = 'child';", // child stayed on main, so no foreign key exists on no_parent. Expected: []sql.Row{{0}}, }, @@ -4119,8 +4078,7 @@ var DoltCheckoutScripts = []queries.ScriptTest{ Expected: []sql.Row{{0, "Switched to branch 'main'"}}, }, { - Query: "select constraint_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", - Dialect: "mysql", + Query: "select constraint_name from information_schema.table_constraints where table_schema = database() and constraint_type = 'FOREIGN KEY' and table_name = 'child';", // child still references parent on main, where parent exists. Expected: []sql.Row{{"fk_orphan"}}, }, @@ -4819,8 +4777,6 @@ var DoltResetTestScripts = []queries.ScriptTest{ "create table users (name varchar(64) primary key, email varchar(64));", "create index idx_email on users (email);", "insert into users values ('alice', 'alice@example.com');", - "create table posts (title varchar(64) primary key);", - "insert into posts values ('hello');", }, Assertions: []queries.ScriptTestAssertion{ { @@ -4840,87 +4796,49 @@ var DoltResetTestScripts = []queries.ScriptTest{ Query: "select name from users where email = 'alice@example.com';", Expected: []sql.Row{{"alice"}}, }, - { - Query: "select title from posts;", - Expected: []sql.Row{{"hello"}}, - }, }, }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_reset('--hard') preserves secondary indexes on two untracked tables that share column tags", + // Collision verified in bats-mirror test in reset.bats as tag 9815. + Name: "dolt_reset('--hard') retags an untracked table whose primary key column collides with the target and preserves its secondary indexes", SetUpScript: []string{ - "call dolt_branch('empty');", - "create table c (raw varchar(64) primary key, code varchar(64), constraint chk_code check (code like 'c_%'));", - "create unique index idx_code on c (code);", - "insert into c values ('c_data', 'c_code');", - "create table e (str varchar(64) primary key, label varchar(64), tag varchar(64));", - "create index idx_label on e (label);", - "create index idx_composite on e (label, tag);", - "insert into e values ('e_data', 'e_label', 'e_tag');", - "create table kl (val varchar(64));", - "create index idx_val on kl (val);", - "insert into kl values ('kl_data');", + "call dolt_checkout('-b', 'feat');", + "create table bar (code varchar(64) primary key);", + "call dolt_commit('-Am', 'add bar on feat');", + "call dolt_checkout('main');", + "create table users (name varchar(64) primary key, email varchar(64), label varchar(64));", + "create index idx_email on users (email);", + "create index idx_composite on users (email, label);", + "insert into users values ('alice', 'alice@example.com', 'admin');", }, Assertions: []queries.ScriptTestAssertion{ { - Query: "call dolt_reset('--hard', 'empty');", + Query: "call dolt_reset('--hard', 'feat');", Expected: []sql.Row{{0}}, }, { - Query: "select raw from c;", - Expected: []sql.Row{{"c_data"}}, - }, - { - Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'c' and index_name = 'idx_code';", - Dialect: "mysql", - // The UNIQUE flag survived the retag. - Expected: []sql.Row{{"idx_code", 0, 1, "code", "YES", "BTREE", ""}}, - }, - { - Query: "select raw from c where code = 'c_code';", - Expected: []sql.Row{{"c_data"}}, - }, - { - Query: "insert into c values ('bad', 'x_bad');", - Dialect: "mysql", - ExpectedErr: sql.ErrCheckConstraintViolated, - }, - { - Query: "select str from e;", - Expected: []sql.Row{{"e_data"}}, + Query: "select name from users;", + Expected: []sql.Row{{"alice"}}, }, { - Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'e' and index_name = 'idx_label';", + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'users' and index_name = 'idx_email';", Dialect: "mysql", - Expected: []sql.Row{{"idx_label", 1, 1, "label", "YES", "BTREE", ""}}, + Expected: []sql.Row{{"idx_email", 1, 1, "email", "YES", "BTREE", ""}}, }, { - Query: "select str from e where label = 'e_label';", - Expected: []sql.Row{{"e_data"}}, + Query: "select name from users where email = 'alice@example.com';", + Expected: []sql.Row{{"alice"}}, }, { - Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'e' and index_name = 'idx_composite' order by seq_in_index;", + Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'users' and index_name = 'idx_composite' order by seq_in_index;", Dialect: "mysql", - // Both column positions of the composite index were remapped. - Expected: []sql.Row{{"idx_composite", 1, 1, "label", "YES", "BTREE", ""}, {"idx_composite", 1, 2, "tag", "YES", "BTREE", ""}}, + // Both column positions of the composite index follow the carried columns after the retag. + Expected: []sql.Row{{"idx_composite", 1, 1, "email", "YES", "BTREE", ""}, {"idx_composite", 1, 2, "label", "YES", "BTREE", ""}}, }, { - Query: "select str from e where label = 'e_label' and tag = 'e_tag';", - Expected: []sql.Row{{"e_data"}}, - }, - { - Query: "select val from kl;", - Expected: []sql.Row{{"kl_data"}}, - }, - { - Query: "select index_name, non_unique, seq_in_index, column_name, nullable, index_type, index_comment from information_schema.statistics where table_schema = database() and table_name = 'kl' and index_name = 'idx_val';", - Dialect: "mysql", - Expected: []sql.Row{{"idx_val", 1, 1, "val", "YES", "BTREE", ""}}, - }, - { - Query: "select val from kl where val = 'kl_data';", - Expected: []sql.Row{{"kl_data"}}, + Query: "select name from users where email = 'alice@example.com' and label = 'admin';", + Expected: []sql.Row{{"alice"}}, }, }, }, @@ -4988,7 +4906,7 @@ var DoltResetTestScripts = []queries.ScriptTest{ }, { Query: "insert into ai (val) values ('row3');", - // The auto-increment counter was not reset by the hard reset. + // The auto-increment counter is not reset by the hard reset. Expected: []sql.Row{{types.OkResult{RowsAffected: 1, InsertID: 3}}}, }, { diff --git a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go index 6548c2ff718..2b4ef4044de 100755 --- a/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go +++ b/go/libraries/doltcore/sqle/enginetest/dolt_transaction_queries.go @@ -2543,8 +2543,7 @@ var BranchIsolationTests = []queries.TransactionTest{ }, { // See https://github.com/dolthub/dolt/issues/11007 - // See https://docs.dolthub.com/sql-reference/version-control/branches for - // transaction-start snapshot semantics. + // See https://docs.dolthub.com/sql-reference/version-control/branches for transaction-start snapshot semantics. Name: "dolt_checkout without --move respects transaction-start snapshot semantics", SetUpScript: []string{ "create table tracked_tbl (x int primary key)", @@ -2554,8 +2553,6 @@ var BranchIsolationTests = []queries.TransactionTest{ }, Assertions: []queries.ScriptTestAssertion{ { - // Client B starts first so its snapshot is pinned before A makes any - // changes, which is the precondition the rest of the test exercises. Query: "/* client b */ start transaction", Expected: []sql.Row{}, }, @@ -2585,6 +2582,10 @@ var BranchIsolationTests = []queries.TransactionTest{ // does not pick up A's untracked_tbl even inside an open transaction. Expected: []sql.Row{{0}}, }, + { + Query: "/* client b */ select count(*) from information_schema.tables where table_schema = 'mydb/main' and table_name = 'untracked_tbl'", + Expected: []sql.Row{{0}}, + }, { Query: "/* client b */ commit", Expected: []sql.Row{}, @@ -2609,7 +2610,7 @@ var BranchIsolationTests = []queries.TransactionTest{ }, { // See https://github.com/dolthub/dolt/issues/11007 - Name: "dolt_checkout('--move') still carries uncommitted state for the calling client only", + Name: "dolt_checkout('--move') moves uncommitted state from source to target and cleans the source's working set for all clients", SetUpScript: []string{ "create table tracked_tbl (x int primary key)", "call dolt_commit('-Am', 'add tracked_tbl')", @@ -2646,6 +2647,47 @@ var BranchIsolationTests = []queries.TransactionTest{ }, }, }, + { + // See https://github.com/dolthub/dolt/issues/11007 + Name: "dolt_checkout('--move') inside an open transaction switches the session but the eventual commit fails because the transaction spans two branches", + SetUpScript: []string{ + "create table tracked_tbl (x int primary key)", + "call dolt_commit('-Am', 'add tracked_tbl')", + "call dolt_branch('feat')", + "set autocommit = 0", + }, + Assertions: []queries.ScriptTestAssertion{ + { + Query: "/* client a */ start transaction", + Expected: []sql.Row{}, + }, + { + Query: "/* client a */ create table u (x int primary key)", + Expected: []sql.Row{{types.NewOkResult(0)}}, + }, + { + Query: "/* client a */ insert into u values (7)", + Expected: []sql.Row{{types.NewOkResult(1)}}, + }, + { + Query: "/* client a */ call dolt_checkout('--move', 'feat')", + Expected: []sql.Row{{0, "Switched to branch 'feat'"}}, + }, + { + Query: "/* client a */ select active_branch()", + Expected: []sql.Row{{"feat"}}, + }, + { + Query: "/* client a */ select x from u", + // The carried table is visible in the same session before commit. + Expected: []sql.Row{{7}}, + }, + { + Query: "/* client a */ commit", + ExpectedErrStr: "Cannot commit changes on more than one branch / database", + }, + }, + }, } var MultiDbTransactionTests = []queries.ScriptTest{ diff --git a/integration-tests/bats/checkout.bats b/integration-tests/bats/checkout.bats index f0d9ab38e17..a6220ce83f3 100755 --- a/integration-tests/bats/checkout.bats +++ b/integration-tests/bats/checkout.bats @@ -1515,10 +1515,10 @@ SQL [[ "$output" =~ "fk_child" ]] || false } -@test "checkout: aborts when untracked table conflicts with committed table on target branch" { +@test "checkout: blocks untracked table that collides with target's committed table regardless of content" { # See https://github.com/dolthub/dolt/issues/11007 dolt checkout -b feat - dolt sql -q "CREATE TABLE conflict_tbl (id INT PRIMARY KEY, val INT)" + dolt sql -q "CREATE TABLE conflict_tbl (id INT PRIMARY KEY, val INT); INSERT INTO conflict_tbl VALUES (1, 5);" dolt commit -Am "add conflict_tbl on feat" dolt checkout main @@ -1535,10 +1535,18 @@ SQL [ "$status" -eq 0 ] [[ "$output" =~ "main" ]] || false - # The abort leaves the local table untouched: still the one-column version. run dolt schema show conflict_tbl [ "$status" -eq 0 ] [[ ! "$output" =~ "val" ]] || false + + # Drop the different-content copy and recreate with byte-identical content to feat's: + # the block must still fire because the source-side cleanup would discard it anyway. + dolt sql -q "DROP TABLE conflict_tbl; CREATE TABLE conflict_tbl (id INT PRIMARY KEY, val INT); INSERT INTO conflict_tbl VALUES (1, 5);" + + run dolt checkout feat + [ "$status" -ne 0 ] + [[ "$output" =~ "untracked tables would be overwritten by checkout" ]] || false + [[ "$output" =~ "conflict_tbl" ]] || false } @test "checkout: ignored tables stay on the source branch" { @@ -1567,7 +1575,9 @@ SQL @test "checkout: aborts when an untracked table matches conflicting dolt_ignore patterns" { # See https://github.com/dolthub/dolt/issues/11007 - # dataset matches conflicting dolt_ignore patterns, so it cannot be classified and checkout aborts. + # TODO(elianddb): break ties when no pattern is more specific. dolt_ignore rows have no + # order, so give them one and let the last rule added win. + # See https://git-scm.com/docs/gitignore#_description dolt sql -q "INSERT INTO dolt_ignore VALUES ('data*', true), ('*set', false)" dolt commit -Am "conflicting ignore rules" dolt branch other @@ -1587,3 +1597,270 @@ SQL [ "$status" -eq 0 ] [[ "$output" =~ "7" ]] || false } + +@test "checkout: allows convergent edits where staged content matches target" { + # See https://github.com/dolthub/dolt/issues/11007 + dolt sql < Date: Wed, 27 May 2026 01:43:38 +0000 Subject: [PATCH 23/23] [ga-format-pr] Run go/utils/repofmt/format_repo.sh and go/Godeps/update.sh --- go/libraries/doltcore/merge/merge.go | 1 - 1 file changed, 1 deletion(-) diff --git a/go/libraries/doltcore/merge/merge.go b/go/libraries/doltcore/merge/merge.go index 82182d1ff9a..90b3f7c9b14 100644 --- a/go/libraries/doltcore/merge/merge.go +++ b/go/libraries/doltcore/merge/merge.go @@ -442,4 +442,3 @@ func MergeWouldStompChanges(ctx context.Context, roots doltdb.Roots, mergeCommit return stompedTables, headWorkingDiffs, nil } -