Artifact 1583a83fd4b9c9117a94118b656e253303e89605ec6c8a2ebf36f6d47dbe1bb9:
- File
cli/repo_structural_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: 9238) [annotate] [blame] [check-ins using] [more...]
package cli import ( "path/filepath" "strings" "sync/atomic" "testing" libfossil "github.com/danmestas/go-libfossil" ) // A structural clone (draft-fossil-horizon-00 §5): --structural takes the // structural cut and the clone says so; the store cone shows it; info names // the horizon and the check-ins below it rather than leaving bare phantoms; // verify counts the manifests below the horizon as elided, never as damage // (§5.4); and checking out, diffing or annotating a check-in below C fails // naming the horizon (§5.5). func TestRepoCloneStructural(t *testing.T) { url, c1, c2, _ := horizonSource(t) path := filepath.Join(t.TempDir(), "s.fossil") if err := (&RepoCloneCmd{URL: url, Path: path, Structural: true}).Run(&Globals{}); err == nil { t.Fatal("--structural without --horizon was accepted") } out := requiredHTTPStdout(t, func() error { return (&RepoCloneCmd{URL: url, Path: path, Horizon: c2, Structural: true}).Run(&Globals{}) }) if !strings.Contains(out, "Horizon: structural\n") { t.Fatalf("clone output = %q", out) } if got, want := storeConeOutput(t, path), "horizon "+c2+" descendants structural\n"; got != want { t.Fatalf("store cone = %q, want %q", got, want) } info := requiredHTTPStdout(t, func() error { return (&RepoInfoCmd{}).Run(&Globals{Repo: path}) }) for _, line := range []string{"horizon: " + c2 + " descendants structural\n", "below horizon: 1 check-in(s)\n"} { if !strings.Contains(info, line) { t.Fatalf("info lacks %q:\n%s", line, info) } } var verifyErr error verified := requiredHTTPStdout(t, func() error { verifyErr = (&RepoVerifyCmd{}).Run(&Globals{Repo: path}) return nil }) if verifyErr != nil || !strings.Contains(verified, "0 transit phantoms") || !strings.Contains(verified, "1 check-in(s) below the horizon") { t.Fatalf("verify: %v\n%s", verifyErr, verified) } for name, run := range map[string]func() error{ "co": func() error { return (&RepoCoCmd{Version: c1, Dir: t.TempDir()}).Run(&Globals{Repo: path}) }, "diff": func() error { return (&RepoDiffCmd{Version: c1, To: c2}).Run(&Globals{Repo: path}) }, "annotate": func() error { return (&RepoAnnotateCmd{File: "a", Version: c1}).Run(&Globals{Repo: path}) }, } { err := run() if err == nil || !strings.Contains(err.Error(), "below the structural horizon at "+c2) { t.Errorf("%s below C: %v, want a diagnostic naming the horizon", name, err) } } } // `repo cone deepen` moves a structural store's horizon to a check-in below // it, from the saved remote, and keeps the structural depth // (draft-fossil-horizon-00 §5.8); the check-in below the old horizon can // then be checked out. func TestRepoConeDeepen(t *testing.T) { url, c1, c2, _ := horizonSource(t) path := filepath.Join(t.TempDir(), "s.fossil") requiredHTTPStdout(t, func() error { return (&RepoCloneCmd{URL: url, Path: path, Horizon: c2, Structural: true}).Run(&Globals{}) }) out := requiredHTTPStdout(t, func() error { return (&RepoConeDeepenCmd{Checkin: c1}).Run(&Globals{Repo: path}) }) if !strings.Contains(out, "horizon "+c1+" descendants structural\n") { t.Fatalf("deepen output = %q", out) } if got, want := storeConeOutput(t, path), "horizon "+c1+" descendants structural\n"; got != want { t.Fatalf("store cone = %q, want %q", got, want) } if !holdsBlob(t, path, "old a\n") { t.Fatal("the deepen fetched no blob the deeper horizon keeps") } requiredHTTPStdout(t, func() error { return (&RepoCoCmd{Version: c1, Dir: t.TempDir()}).Run(&Globals{Repo: path}) }) } // structuralMergeSource serves a history where a merge's base lies below a // structural horizon at c2: f forks from c1 below it and g follows f, x // merges c2 into g and y merges g into a, c2's child. fossil's merge base of // x and y, their most recent common ancestor, is g. func structuralMergeSource(t *testing.T) (url, c2, f, x, y string) { t.Helper() source, err := libfossil.Create(filepath.Join(t.TempDir(), "source.fossil"), libfossil.CreateOpts{User: "test"}) if err != nil { t.Fatal(err) } t.Cleanup(func() { source.Close() }) if err := source.SetHashPolicy("sha1"); err != nil { t.Fatal(err) } commit := func(a string, parents ...int64) (int64, string) { rid, uuid, err := source.Commit(libfossil.CommitOpts{User: "test", ParentID: parents[0], MergeParents: parents[1:], Files: []libfossil.FileToCommit{{Name: "a", Content: []byte(a)}, {Name: "b", Content: []byte("shared b\n")}}}) if err != nil { t.Fatal(err) } return rid, uuid } r1, _ := commit("one\n", 0) rf, _ := commit("one\nfork\n", r1) r2, c2 := commit("zero\none\n", r1) ra, _ := commit("zero\none\nA\n", r2) rg, f := commit("one\nfork\ng\n", rf) _, x = commit("zero\none\nfork\ng\n", rg, r2) _, y = commit("zero\none\nA\nfork\ng\n", ra, rg) server := requiredHTTPServer(t, source, new(atomic.Int64)) t.Cleanup(server.Close) return server.URL, c2, f, x, y } // `repo merge` across a structural horizon asks the saved remote for the // base, deepens to reach it and merges over the real base // (draft-fossil-horizon-00 §5.5); it never merges against a base it does // not hold. func TestRepoMergeDeepensAcrossHorizon(t *testing.T) { url, c2, f, x, y := structuralMergeSource(t) path := filepath.Join(t.TempDir(), "s.fossil") requiredHTTPStdout(t, func() error { return (&RepoCloneCmd{URL: url, Path: path, Horizon: c2, Structural: true}).Run(&Globals{}) }) dir := t.TempDir() r, err := libfossil.Open(path) if err != nil { t.Fatal(err) } co, err := r.CreateCheckout(dir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatal(err) } xrid, err := r.ResolveVersion(x) if err != nil { t.Fatal(err) } if err := co.Extract(xrid, libfossil.ExtractOpts{}); err != nil { t.Fatal(err) } co.Close() r.Close() out := requiredHTTPStdout(t, func() error { return (&RepoMergeCmd{Version: y, Dir: dir}).Run(&Globals{Repo: path}) }) if !strings.Contains(out, "MERGE a\n") { t.Fatalf("merge output = %q", out) } if got := storeConeOutput(t, path); !strings.Contains(got, "structural") || strings.Contains(got, c2) { t.Fatalf("store cone after the merge = %q, want a deeper structural horizon", got) } if !holdsBlob(t, path, "one\nfork\ng\n") { t.Fatalf("the base %s's content is not held after the merge", f) } } // `repo verify --seed` verifies the structural store's seed by deepening // (draft-fossil-horizon-00 §5.9): it fetches the history below the horizon // from the saved remote, compares, and leaves a content-horizon store. func TestRepoVerifySeed(t *testing.T) { url, _, c2, _ := horizonSource(t) path := filepath.Join(t.TempDir(), "s.fossil") requiredHTTPStdout(t, func() error { return (&RepoCloneCmd{URL: url, Path: path, Horizon: c2, Structural: true}).Run(&Globals{}) }) out := requiredHTTPStdout(t, func() error { return (&RepoVerifyCmd{Seed: true}).Run(&Globals{Repo: path}) }) if !strings.Contains(out, "seed verified:") || !strings.Contains(out, "repository integrity verified") { t.Fatalf("verify --seed output = %q", out) } if got, want := storeConeOutput(t, path), "horizon "+c2+" descendants\n"; got != want { t.Fatalf("store cone after verify --seed = %q, want %q", got, want) } } // A symbolic name on a check-in below the structural horizon resolves to // that check-in, because the tag's control artifact is held, and operating // on it fails naming the horizon and the deepen (draft-fossil-horizon-00 // §5.5). func TestRepoNameBelowHorizon(t *testing.T) { source, err := libfossil.Create(filepath.Join(t.TempDir(), "source.fossil"), libfossil.CreateOpts{User: "test"}) if err != nil { t.Fatal(err) } defer source.Close() if err := source.SetHashPolicy("sha1"); err != nil { t.Fatal(err) } r1, c1, err := source.Commit(libfossil.CommitOpts{User: "test", Files: []libfossil.FileToCommit{{Name: "a", Content: []byte("old a\n")}}}) if err != nil { t.Fatal(err) } _, c2, err := source.Commit(libfossil.CommitOpts{User: "test", ParentID: r1, Files: []libfossil.FileToCommit{{Name: "a", Content: []byte("new a\n")}}}) if err != nil { t.Fatal(err) } if _, err := source.Tag(libfossil.TagOpts{Name: "old", TargetID: r1, User: "test"}); err != nil { t.Fatal(err) } server := requiredHTTPServer(t, source, new(atomic.Int64)) defer server.Close() path := filepath.Join(t.TempDir(), "s.fossil") requiredHTTPStdout(t, func() error { return (&RepoCloneCmd{URL: server.URL, Path: path, Horizon: c2, Structural: true}).Run(&Globals{}) }) r, err := libfossil.Open(path) if err != nil { t.Fatal(err) } for _, name := range []string{"old", "tag:old"} { rid, err := r.ResolveVersion(name) if err != nil { r.Close() t.Fatalf("%s below the horizon does not resolve: %v", name, err) } uuid, err := r.UUIDFromRID(rid) if err != nil || uuid != c1 { r.Close() t.Fatalf("%s resolves to %s (%v), want %s", name, uuid, err, c1) } } r.Close() err = (&RepoCoCmd{Version: "old", Dir: t.TempDir()}).Run(&Globals{Repo: path}) if err == nil || !strings.Contains(err.Error(), "below the structural horizon at "+c2) || !strings.Contains(err.Error(), "repo cone deepen "+c1) { t.Fatalf("co old: %v, want a diagnostic naming the horizon and the deepen", err) } }