Artifact 4e6ef790e75e35777e37d661e99724f9e76369fd3f16aaa60610d9c2f3fb892b:
- File
internal/merge/pivot_test.go
— part of check-in
[a23c7b12a9]
at
2026-10-22 20:12:19
on branch feat/horizon-cones
— fix(merge): choose merge bases as fossil's pivot_find does
go-libfossil picked a merge base with a bidirectional breadth-first walk, the common ancestor nearest by edge count. fossil's merge uses pivot.c's pivot_find: a queue ordered by pending DESC, mtime DESC that expands the newest pending check-in first and returns the first one with a child queued from the other side, the most recent common ancestor by event time. The two differ on criss-cross merges and skewed histories, so a merge here could diff against another base than fossil's and write other bytes.
merge.FindPivot ports pivot_find in memory: (rid, side) rows with REPLACE re-pending, ties broken as a reverse scan of aqueue_idx1 yields them (rid, then side, descending), a missing event mtime counted as 0.0, ignoreMerges following primary links only, and the walk bound of the horizon merge-base query kept as ErrBound. FindCommonAncestor and FindCommonAncestorWithin now ask it.
Call sites: - Checkout.Merge takes fossil's pid: the merged-in version primary, the checkout's version and every vmerge id=0 merge the secondaries (merge.c 1076-1083), and gains Baseline, fossil's --baseline. The name pivot nid (pivot_find(1), merge.c 1089-1096) feeds only add_renames and the rename fill-in (merge.c 1192-1257); Merge does not follow renames, so it is not ported. Update and stash use the checkout version as their pivot and pick no base. - Repo.Merge (branch merge) makes the source branch tip the primary. - The origin's horizon-mergebase answer makes the merged-in check-in primary and declines to answer from a walk that reached history it does not hold. - Deepen's local deepen point keeps a boundary target among C's ancestors as its own point, since pivot_find seeds from check-ins only.
Horizon client rule (draft-fossil-horizon-00 ยง5.5, agreed for the draft): a common ancestor found in part of the graph need not be the one the whole graph gives, so a merge asks the origin whenever the walk reaches a check-in the store does not hold, not only when the base it finds is not held. MergeBaseCrossing takes that from Pivot.Unheld, and MergeContext merges over the origin's base as a baseline, deepening only when the store does not hold it.
Changed expectations: the horizon merge fixtures (hzStruct's y into feat2, structuralMergeSource, TestBranchMergeRefusesBaseBelowHorizon) fork f below the horizon, merge c2 into f's line and f into c2's: fossil picks c2, not f. On that shape fossil 2.28 answers fossil merge-base y x -> pivot=52d5923b8d (c2) with f=8ac24055 also a common ancestor. The fixtures now add a check-in on f's line newer than the horizon, which fossil does pick, to keep a base below the horizon, and the crossing tests check both the held-base walk that crosses and the base below. (user: [email protected], size: 5950) [annotate] [blame] [check-ins using] [more...]
package merge import ( "context" "errors" "path/filepath" "testing" "time" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/simio" ) // pivotDAG commits check-ins into a new repository, each at its own minute // of 2026-01-01, so a test controls the event mtimes pivot_find orders by. type pivotDAG struct { t *testing.T r *repo.Repo rids map[string]libfossil.FslID } func newPivotDAG(t *testing.T) *pivotDAG { t.Helper() r, err := repo.Create(filepath.Join(t.TempDir(), "pivot.fossil"), "u", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } t.Cleanup(func() { r.Close() }) return &pivotDAG{t: t, r: r, rids: map[string]libfossil.FslID{}} } // commit adds check-in name at minute, its first parent the primary one. func (d *pivotDAG) commit(name string, minute int, parents ...string) { d.t.Helper() opts := manifest.CheckinOpts{ Files: []manifest.File{{Name: "f.txt", Content: []byte(name + "\n")}}, Comment: name, User: "u", Time: time.Date(2026, 1, 1, 0, minute, 0, 0, time.UTC), } for i, p := range parents { rid, ok := d.rids[p] if !ok { d.t.Fatalf("parent %s of %s is not committed", p, name) } if i == 0 { opts.Parent = rid } else { opts.MergeParents = append(opts.MergeParents, rid) } } rid, _, err := manifest.Checkin(d.r, opts) if err != nil { d.t.Fatalf("checkin %s: %v", name, err) } d.rids[name] = rid } func (d *pivotDAG) name(rid libfossil.FslID) string { for n, r := range d.rids { if r == rid { return n } } return "?" } // skewedCrissCross is two merges that cross: p merges b4 into a1 and s // merges a1 into b3. a1 is one edge from both, b3 one from s and two from // p, but b3 is newer, so fossil's pivot_find, which expands the newest // pending check-in first, settles on b3 where a breadth-first walk stops at // a1. func skewedCrissCross(t *testing.T) *pivotDAG { d := newPivotDAG(t) d.commit("r", 1) d.commit("a1", 2, "r") d.commit("b3", 5, "r") d.commit("b4", 6, "b3") d.commit("p", 7, "a1", "b4") d.commit("s", 8, "b3", "a1") return d } func TestFindCommonAncestorIsFossilPivot(t *testing.T) { d := skewedCrissCross(t) for _, pair := range [][2]string{{"p", "s"}, {"s", "p"}} { got, err := FindCommonAncestor(d.r, d.rids[pair[0]], d.rids[pair[1]]) if err != nil { t.Fatalf("FindCommonAncestor(%s, %s): %v", pair[0], pair[1], err) } if d.name(got) != "b3" { t.Errorf("FindCommonAncestor(%s, %s) = %s, want b3, the newest common ancestor", pair[0], pair[1], d.name(got)) } } } func TestFindPivotIgnoreMergesFollowsPrimaryParents(t *testing.T) { d := skewedCrissCross(t) // Along primary links p reaches a1 and r, s reaches b3 and r. got, err := FindPivot(context.Background(), d.r, PivotQuery{ Primary: d.rids["p"], Secondaries: []libfossil.FslID{d.rids["s"]}, IgnoreMerges: true, }) if err != nil { t.Fatal(err) } if d.name(got.RID) != "r" { t.Fatalf("pivot over primary links = %s, want r", d.name(got.RID)) } } func TestFindPivotSecondariesShareASide(t *testing.T) { d := newPivotDAG(t) d.commit("r", 1) d.commit("x", 2, "r") d.commit("m", 3, "x") d.commit("v", 4, "r") // m and v meet at r; with x merged into v already, m and {v, x} meet at // x, as fossil's merge finds with the pending merge's vmerge row. got, err := FindPivot(context.Background(), d.r, PivotQuery{ Primary: d.rids["m"], Secondaries: []libfossil.FslID{d.rids["v"], d.rids["x"]}, }) if err != nil { t.Fatal(err) } if d.name(got.RID) != "x" { t.Fatalf("pivot with a merged-in secondary = %s, want x", d.name(got.RID)) } } func TestFindPivotSameCheckinIsItsParent(t *testing.T) { d := newPivotDAG(t) d.commit("r", 1) d.commit("c", 2, "r") // pivot_find tests a check-in for a child in the queue from the other // side, which c never has: fossil merge-base c c answers r. got, err := FindPivot(context.Background(), d.r, PivotQuery{ Primary: d.rids["c"], Secondaries: []libfossil.FslID{d.rids["c"]}, }) if err != nil { t.Fatal(err) } if d.name(got.RID) != "r" { t.Fatalf("pivot of c with itself = %s, want r", d.name(got.RID)) } } func TestFindPivotNoCommonAncestor(t *testing.T) { d := newPivotDAG(t) d.commit("a", 1) d.commit("b", 2) _, err := FindPivot(context.Background(), d.r, PivotQuery{ Primary: d.rids["a"], Secondaries: []libfossil.FslID{d.rids["b"]}, }) if !errors.Is(err, ErrNoCommonAncestor) { t.Fatalf("disjoint check-ins: %v, want ErrNoCommonAncestor", err) } } func TestFindPivotBound(t *testing.T) { d := skewedCrissCross(t) q := PivotQuery{Primary: d.rids["p"], Secondaries: []libfossil.FslID{d.rids["s"]}, Limit: 2} if _, err := FindPivot(context.Background(), d.r, q); !errors.Is(err, ErrBound) { t.Fatalf("a walk past its bound: %v, want ErrBound", err) } q.Limit = 64 if got, err := FindPivot(context.Background(), d.r, q); err != nil || d.name(got.RID) != "b3" { t.Fatalf("a walk within its bound = %s, %v; want b3", d.name(got.RID), err) } } func TestFindPivotReportsUnheldParents(t *testing.T) { d := skewedCrissCross(t) got, err := FindPivot(context.Background(), d.r, PivotQuery{ Primary: d.rids["p"], Secondaries: []libfossil.FslID{d.rids["s"]}, }) if err != nil || got.Unheld { t.Fatalf("a walk over held check-ins: %+v, %v; want Unheld false", got, err) } // Turn r into a phantom: it has no event, so its mtime counts as 0. if _, err := d.r.DB().Exec("DELETE FROM event WHERE objid=?", d.rids["r"]); err != nil { t.Fatal(err) } got, err = FindPivot(context.Background(), d.r, PivotQuery{ Primary: d.rids["a1"], Secondaries: []libfossil.FslID{d.rids["b3"]}, }) if err != nil || d.name(got.RID) != "r" || !got.Unheld { t.Fatalf("a walk reaching r without its event: %s %+v, %v; want r, Unheld", d.name(got.RID), got, err) } }