Artifact ef16be9515d5e19f655581218f104616c46519c8d063f589967dbb5db64c561b:
- File
internal/content/horizon_crossing.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: 3565) [annotate] [blame] [check-ins using] [more...]
package content import ( "errors" "fmt" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/cone" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // ErrBelowHorizon is matched by the diagnostic of an operation that needs // history below the store's structural horizon (draft-fossil-horizon-00 // §5.5): the store does not hold it, and a deepen is what brings it. var ErrBelowHorizon = errors.New("history below the structural horizon") // MergeBaseError is a merge whose base lies below the structural horizon at // Horizon (§5.5), or may: Base is the base the store's walk found when the // store does not hold it, "" when the walk crossed the horizon, so that only // the origin can name the base. Side is the check-in being merged in. type MergeBaseError struct { Horizon, Side, Base string } func (e *MergeBaseError) Error() string { if e.Base != "" { return fmt.Sprintf("the merge base of %s is %s, below the structural horizon at %s: it is not held here; "+ "deepen the horizon to it (repo cone deepen %s), or merge with a remote to ask the origin and deepen", e.Side, e.Base, e.Horizon, e.Base) } return fmt.Sprintf("the merge base of %s may lie below the structural horizon at %s: its ancestry crosses the "+ "horizon, so only the origin can name it; merge with a remote to ask the origin for the base and deepen to it", e.Side, e.Horizon) } func (e *MergeBaseError) Is(target error) bool { return target == ErrBelowHorizon } // StructuralHorizon is the store cone's horizon when it is structural. func StructuralHorizon(q db.Querier) (cone.Horizon, bool, error) { if q == nil { panic("content.StructuralHorizon: q must not be nil") } c, err := StoreCone(q) if err != nil { return cone.Horizon{}, false, err } h := horizonOf(c) return h, h.Hash() != "" && h.Depth() == cone.DepthStructural, nil } // MergeBaseCrossing decides whether the merge of side crosses the store's // structural horizon (§5.5), given the base the store's own walk found, 0 // for none, and whether that walk reached a check-in the store does not // hold (merge.Pivot.Unheld). A base the store does not hold is a crossing. // So is any walk that reached the horizon: the store holds only part of the // graph, and a common ancestor found in part of it need not be the one the // whole graph gives, so the origin must be asked. It returns a // *MergeBaseError for a crossing and nil otherwise, so a merge never runs // against a base it does not hold or one the origin would not choose. func MergeBaseCrossing(q db.Querier, side, base libfossil.FslID, unheld bool) error { if q == nil { panic("content.MergeBaseCrossing: q must not be nil") } if side <= 0 || base < 0 { panic("content.MergeBaseCrossing: invalid rid") } h, structural, err := StructuralHorizon(q) if err != nil || !structural { return err } crossing := &MergeBaseError{Horizon: h.Hash()} if err := q.QueryRow("SELECT uuid FROM blob WHERE rid=?", side).Scan(&crossing.Side); err != nil { return fmt.Errorf("content.MergeBaseCrossing: %w", err) } if base > 0 { var held bool var uuid string if err := q.QueryRow(`SELECT size>=0 AND EXISTS(SELECT 1 FROM event WHERE objid=rid AND type='ci') AND NOT EXISTS(SELECT 1 FROM phantom WHERE phantom.rid=blob.rid), uuid FROM blob WHERE rid=?`, base).Scan(&held, &uuid); err != nil { return fmt.Errorf("content.MergeBaseCrossing: %w", err) } if !held { crossing.Base = uuid return crossing } } if unheld { return crossing } return nil }