Artifact 23c1f8e33357155eac4d7246e3e495d437389f3442168a4bbf826a4a8f915e92:
- File
internal/manifest/manifest_test.go
— part of check-in
[1c02c944ec]
at
2026-08-07 17:33:21
on branch trunk
— fix(db): seed hash-policy so new repos name artifacts like fossil init (#225) (#226)
SeedConfig never wrote hash-policy, so naming fell back to deriving from the repo's own artifacts — and an empty repo has none, settling on SHA1 permanently. Seed sha3, which is what fossil init writes.
Clone drops the row the same way it drops project-code: a clone adopts its source's policy, and an absent row derives naming from the artifacts that arrive.
Moves the policy enum to db so the seed and hash.NamingFor read one definition, and updates fixtures that hardcoded SHA1 names to ask the repo how it names artifacts. (user: [email protected], size: 48066) [annotate] [blame] [check-ins using]
package manifest import ( "bytes" "fmt" "path/filepath" "testing" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/delta" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/tag" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/simio" "github.com/danmestas/go-libfossil/testutil" ) func setupTestRepo(t *testing.T) *repo.Repo { t.Helper() path := filepath.Join(t.TempDir(), "test.fossil") r, err := repo.Create(path, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } t.Cleanup(func() { r.Close() }) return r } // repoHash names content the way r names its artifacts. Fixtures that build // manifests by hand have to agree with what blob.Store will write for the // same bytes, so they ask the repo instead of assuming SHA1. func repoHash(r *repo.Repo, content []byte) string { return hash.NamingFor(r.DB()).New.Hash(content) } func TestCheckinBasic(t *testing.T) { r := setupTestRepo(t) rid, uuid, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "hello.txt", Content: []byte("hello world")}}, Comment: "initial commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } if rid <= 0 { t.Fatalf("rid = %d", rid) } if len(uuid) != 40 && len(uuid) != 64 { t.Fatalf("uuid len = %d", len(uuid)) } var comment string r.DB().QueryRow("SELECT comment FROM event WHERE objid=?", rid).Scan(&comment) if comment != "initial commit" { t.Fatalf("event comment = %q", comment) } var leafCount int r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", rid).Scan(&leafCount) if leafCount != 1 { t.Fatalf("leaf count = %d", leafCount) } } func TestCheckinRollsBackWhenParentManifestIsPhantom(t *testing.T) { r := setupTestRepo(t) phantomRID, err := blob.StorePhantom(r.DB(), "0123456789012345678901234567890123456789") if err != nil { t.Fatalf("StorePhantom: %v", err) } // The phantom reserves a parent UUID while leaving its manifest unavailable; // an unchanged blob count after Checkin fails proves its file and manifest // writes rolled back atomically. var blobCountBefore int if err := r.DB().QueryRow("SELECT count(*) FROM blob").Scan(&blobCountBefore); err != nil { t.Fatalf("count blobs before Checkin: %v", err) } _, _, err = Checkin(r, CheckinOpts{ Files: []File{{Name: "hello.txt", Content: []byte("hello world")}}, Comment: "child commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), Parent: phantomRID, }) if err == nil { t.Fatal("Checkin with phantom parent succeeded") } var blobCountAfter int if err := r.DB().QueryRow("SELECT count(*) FROM blob").Scan(&blobCountAfter); err != nil { t.Fatalf("count blobs after Checkin: %v", err) } if blobCountAfter != blobCountBefore { t.Fatalf("blob count after Checkin = %d, want %d", blobCountAfter, blobCountBefore) } } func TestCheckinRollsBackWhenParentManifestIsNotCheckin(t *testing.T) { r := setupTestRepo(t) parentTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) parentBytes, err := (&deck.Deck{ Type: deck.Wiki, L: "ParentPage", U: deck.User("testuser"), W: []byte("ordinary wiki content"), D: parentTime, }).Marshal() if err != nil { t.Fatalf("Marshal wiki parent: %v", err) } parentRID, _, err := blob.Store(r.DB(), parentBytes) if err != nil { t.Fatalf("Store wiki parent: %v", err) } // Count after storing the available non-checkin parent; a failed Checkin // must preflight mlink and leave its attempted file and manifest writes undone. var blobCountBefore int if err := r.DB().QueryRow("SELECT count(*) FROM blob").Scan(&blobCountBefore); err != nil { t.Fatalf("count blobs before Checkin: %v", err) } _, _, err = Checkin(r, CheckinOpts{ Files: []File{{Name: "hello.txt", Content: []byte("hello world")}}, Comment: "child commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), Parent: parentRID, }) if err == nil { t.Fatal("Checkin with non-checkin parent succeeded") } var blobCountAfter int if err := r.DB().QueryRow("SELECT count(*) FROM blob").Scan(&blobCountAfter); err != nil { t.Fatalf("count blobs after Checkin: %v", err) } if blobCountAfter != blobCountBefore { t.Fatalf("blob count after Checkin = %d, want %d", blobCountAfter, blobCountBefore) } } func TestCheckinDeduplicatesRepeatedMergeParentAndKeepsNewFileNormal(t *testing.T) { r := setupTestRepo(t) primaryRid, primaryUUID, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "primary.txt", Content: []byte("primary")}}, Comment: "primary parent", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("primary Checkin: %v", err) } mergeParentRid, mergeParentUUID, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "merge-parent.txt", Content: []byte("merge parent")}}, Comment: "merge parent", User: "testuser", Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("merge parent Checkin: %v", err) } mergeRid, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "new.txt", Content: []byte("new")}}, Parent: primaryRid, MergeParents: []libfossil.FslID{mergeParentRid, mergeParentRid}, Comment: "merge with duplicate parent", User: "testuser", Time: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("merge Checkin: %v", err) } manifestBytes, err := blob.Load(r.DB(), mergeRid) if err != nil { t.Fatalf("load merge manifest: %v", err) } mergeDeck, err := deck.Parse(manifestBytes) if err != nil { t.Fatalf("parse merge manifest: %v", err) } wantParents := []string{primaryUUID, mergeParentUUID} if len(mergeDeck.P) != len(wantParents) { t.Fatalf("P-card count = %d, want %d (%v)", len(mergeDeck.P), len(wantParents), wantParents) } for i, want := range wantParents { if mergeDeck.P[i] != want { t.Fatalf("P-card parent %d = %q, want %q", i, mergeDeck.P[i], want) } } var pid int64 if err := r.DB().QueryRow( `SELECT m.pid FROM mlink m JOIN filename f USING(fnid) WHERE m.mid=? AND f.name=? AND NOT m.isaux`, mergeRid, "new.txt", ).Scan(&pid); err != nil { t.Fatalf("non-aux mlink for new file: %v", err) } if pid != 0 { t.Fatalf("new file mlink pid = %d, want 0", pid) } } func TestCheckinRollsBackWhenParentTreeReferencesMissingBlob(t *testing.T) { r := setupTestRepo(t) missingContent := []byte("missing parent content") missingRID, missingUUID, err := blob.Store(r.DB(), missingContent) if err != nil { t.Fatalf("Store missing parent file: %v", err) } parentDeck := &deck.Deck{ Type: deck.Checkin, C: "parent with unavailable file", D: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), U: deck.User("testuser"), F: []deck.FileCard{{Name: "missing.txt", UUID: missingUUID}}, } rHash, err := parentDeck.ComputeR(func(uuid string) ([]byte, error) { if uuid != missingUUID { return nil, fmt.Errorf("unexpected parent file UUID: %s", uuid) } return missingContent, nil }) if err != nil { t.Fatalf("ComputeR parent manifest: %v", err) } parentDeck.R = rHash parentBytes, err := parentDeck.Marshal() if err != nil { t.Fatalf("Marshal parent manifest: %v", err) } parentRID, _, err := blob.Store(r.DB(), parentBytes) if err != nil { t.Fatalf("Store parent manifest: %v", err) } if _, err := r.DB().Exec("DELETE FROM blob WHERE rid=?", missingRID); err != nil { t.Fatalf("delete parent file blob: %v", err) } var missingBlobCount int if err := r.DB().QueryRow("SELECT count(*) FROM blob WHERE uuid=?", missingUUID).Scan(&missingBlobCount); err != nil { t.Fatalf("count missing parent blobs: %v", err) } if missingBlobCount != 0 { t.Fatalf("missing parent blob count = %d, want 0", missingBlobCount) } var blobCountBefore int if err := r.DB().QueryRow("SELECT count(*) FROM blob").Scan(&blobCountBefore); err != nil { t.Fatalf("count blobs before Checkin: %v", err) } _, _, err = Checkin(r, CheckinOpts{ Files: []File{{Name: "child.txt", Content: []byte("child")}}, Parent: parentRID, Comment: "child of missing file parent", User: "testuser", Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err == nil { t.Fatal("Checkin with parent tree referencing missing blob succeeded") } var blobCountAfter int if err := r.DB().QueryRow("SELECT count(*) FROM blob").Scan(&blobCountAfter); err != nil { t.Fatalf("count blobs after Checkin: %v", err) } if blobCountAfter != blobCountBefore { t.Fatalf("blob count after Checkin = %d, want %d", blobCountAfter, blobCountBefore) } } // TestCheckinZeroLengthFile is a regression test for issue #68: committing // a zero-length file used to panic in blob.Store, and Checkin's own // postcondition defer masked that panic with an unrelated "manifestRid // must be positive" message during unwind. A zero-length file is legal // input and must commit like any other file. func TestCheckinZeroLengthFile(t *testing.T) { r := setupTestRepo(t) rid, uuid, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "empty.txt", Content: []byte{}}}, Comment: "empty file", User: "testuser", Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin(empty file): %v", err) } if rid <= 0 { t.Fatalf("rid = %d, want > 0", rid) } if len(uuid) != 40 && len(uuid) != 64 { t.Fatalf("uuid len = %d", len(uuid)) } files, err := ListFiles(r, rid) if err != nil { t.Fatalf("ListFiles: %v", err) } if len(files) != 1 || files[0].Name != "empty.txt" { t.Fatalf("ListFiles = %+v, want one entry named empty.txt", files) } // The empty artifact is named by the repo's hash-policy like any other, // so this asserts the empty hash of whichever algorithm the repo uses, // not SHA1's da39a3ee... in particular. if want := repoHash(r, []byte{}); files[0].UUID != want { t.Fatalf("empty.txt UUID = %q, want %q", files[0].UUID, want) } } func TestCheckinFossilRebuild(t *testing.T) { testutil.RequireFossilBin(t) r := setupTestRepo(t) _, _, err := Checkin(r, CheckinOpts{ Files: []File{ {Name: "hello.txt", Content: []byte("hello world")}, {Name: "src/main.go", Content: []byte("package main\n")}, }, Comment: "initial commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } r.Close() if err := testutil.FossilRebuild(r.Path()); err != nil { t.Fatalf("fossil rebuild: %v", err) } } func TestCheckinMultiple(t *testing.T) { r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "file.txt", Content: []byte("v1")}}, Comment: "first", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) rid2, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "file.txt", Content: []byte("v2")}}, Comment: "second", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) var leafCount int r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", rid1).Scan(&leafCount) if leafCount != 0 { t.Fatal("rid1 still a leaf") } r.DB().QueryRow("SELECT count(*) FROM leaf WHERE rid=?", rid2).Scan(&leafCount) if leafCount != 1 { t.Fatal("rid2 not a leaf") } var plinkCount int r.DB().QueryRow("SELECT count(*) FROM plink WHERE pid=? AND cid=?", rid1, rid2).Scan(&plinkCount) if plinkCount != 1 { t.Fatal("plink missing") } } func TestGetManifest(t *testing.T) { r := setupTestRepo(t) rid, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "hello.txt", Content: []byte("hello")}}, Comment: "test commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), }) d, err := GetManifest(r, rid) if err != nil { t.Fatalf("GetManifest: %v", err) } if d.C != "test commit" { t.Fatalf("C = %q", d.C) } if d.Type != deck.Checkin { t.Fatalf("Type = %d", d.Type) } if len(d.F) != 1 || d.F[0].Name != "hello.txt" { t.Fatalf("F = %+v", d.F) } } func TestListFilesBaseline(t *testing.T) { r := setupTestRepo(t) rid, _, _ := Checkin(r, CheckinOpts{ Files: []File{ {Name: "a.txt", Content: []byte("aaa")}, {Name: "b.txt", Content: []byte("bbb")}, }, Comment: "initial", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) files, err := ListFiles(r, rid) if err != nil { t.Fatalf("ListFiles: %v", err) } if len(files) != 2 { t.Fatalf("count = %d", len(files)) } } func TestLogMultiple(t *testing.T) { r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1")}}, Comment: "first", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) rid2, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v2")}}, Comment: "second", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) rid3, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v3")}}, Comment: "third", User: "testuser", Parent: rid2, Time: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC), }) entries, err := Log(r, LogOpts{Start: rid3}) if err != nil { t.Fatalf("Log: %v", err) } if len(entries) != 3 { t.Fatalf("count = %d", len(entries)) } if entries[0].Comment != "third" || entries[2].Comment != "first" { t.Fatalf("order: %q %q %q", entries[0].Comment, entries[1].Comment, entries[2].Comment) } } func TestLogWithLimit(t *testing.T) { r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1")}}, Comment: "first", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) rid2, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v2")}}, Comment: "second", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) entries, _ := Log(r, LogOpts{Start: rid2, Limit: 1}) if len(entries) != 1 { t.Fatalf("count = %d", len(entries)) } } // TestLogNullUser covers fossil's optional U-card: a check-in manifest with // no recorded user stores event.user as SQL NULL (the required cards for a // check-in are only "DZ" — see manifest.c:130). Log must tolerate this and // report an empty string, not fail the whole walk. func TestLogNullUser(t *testing.T) { r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1")}}, Comment: "first", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) rid2, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v2")}}, Comment: "second", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if _, err := r.DB().Exec("UPDATE event SET user=NULL WHERE objid=?", rid2); err != nil { t.Fatalf("UPDATE event SET user=NULL: %v", err) } entries, err := Log(r, LogOpts{Start: rid2}) if err != nil { t.Fatalf("Log: %v", err) } if len(entries) != 2 { t.Fatalf("count = %d, want 2", len(entries)) } if entries[0].User != "" { t.Fatalf("entries[0].User = %q, want empty string for NULL event.user", entries[0].User) } if entries[0].Comment != "second" { t.Fatalf("entries[0].Comment = %q, want %q", entries[0].Comment, "second") } if entries[1].User != "testuser" { t.Fatalf("entries[1].User = %q, want %q (non-NULL users unchanged)", entries[1].User, "testuser") } } // TestLogNullComment covers fossil's optional C-card, the other realistic // NULL candidate on the same event row (required cards remain only "DZ"). func TestLogNullComment(t *testing.T) { r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1")}}, Comment: "first", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if _, err := r.DB().Exec("UPDATE event SET comment=NULL WHERE objid=?", rid1); err != nil { t.Fatalf("UPDATE event SET comment=NULL: %v", err) } entries, err := Log(r, LogOpts{Start: rid1}) if err != nil { t.Fatalf("Log: %v", err) } if len(entries) != 1 { t.Fatalf("count = %d, want 1", len(entries)) } if entries[0].Comment != "" { t.Fatalf("entries[0].Comment = %q, want empty string for NULL event.comment", entries[0].Comment) } if entries[0].User != "testuser" { t.Fatalf("entries[0].User = %q, want %q", entries[0].User, "testuser") } } func TestCheckinDelta(t *testing.T) { r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{ {Name: "a.txt", Content: []byte("aaa")}, {Name: "b.txt", Content: []byte("bbb")}, }, Comment: "baseline", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) rid2, _, err := Checkin(r, CheckinOpts{ Files: []File{ {Name: "a.txt", Content: []byte("aaa-modified")}, {Name: "b.txt", Content: []byte("bbb")}, }, Comment: "delta", User: "testuser", Parent: rid1, Delta: true, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin delta: %v", err) } d, _ := GetManifest(r, rid2) if d.B == "" { t.Fatal("delta should have B-card") } if len(d.F) != 1 || d.F[0].Name != "a.txt" { t.Fatalf("delta F = %+v, want only a.txt", d.F) } files, _ := ListFiles(r, rid2) if len(files) != 2 { t.Fatalf("ListFiles = %d, want 2", len(files)) } } func TestCheckinDeltaFossilRebuild(t *testing.T) { testutil.RequireFossilBin(t) r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{ {Name: "a.txt", Content: []byte("aaa")}, {Name: "b.txt", Content: []byte("bbb")}, }, Comment: "baseline", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) _, _, err := Checkin(r, CheckinOpts{ Files: []File{ {Name: "a.txt", Content: []byte("aaa-v2")}, {Name: "b.txt", Content: []byte("bbb")}, {Name: "c.txt", Content: []byte("new file")}, }, Comment: "delta with add", User: "testuser", Parent: rid1, Delta: true, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("delta: %v", err) } r.Close() if err := testutil.FossilRebuild(r.Path()); err != nil { t.Fatalf("rebuild: %v", err) } } func TestCheckinWithCustomTags(t *testing.T) { r := setupTestRepo(t) // Initial checkin (gets default trunk tags) rid1, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "file.txt", Content: []byte("v1")}}, Comment: "initial", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("first checkin: %v", err) } // Second checkin with custom tags (branch creation) rid2, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "file.txt", Content: []byte("v2")}}, Comment: "branch commit", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), Tags: []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "feature-x"}, {Type: deck.TagSingleton, Name: "sym-feature-x", UUID: "*"}, }, }) if err != nil { t.Fatalf("second checkin: %v", err) } d, err := GetManifest(r, rid2) if err != nil { t.Fatalf("GetManifest: %v", err) } if len(d.T) != 2 { t.Fatalf("expected 2 tags, got %d: %+v", len(d.T), d.T) } // Verify the propagating branch tag found := false for _, tag := range d.T { if tag.Type == deck.TagPropagating && tag.Name == "branch" && tag.Value == "feature-x" { found = true } } if !found { t.Fatalf("missing propagating branch tag in %+v", d.T) } // Verify the singleton sym tag found = false for _, tag := range d.T { if tag.Type == deck.TagSingleton && tag.Name == "sym-feature-x" { found = true } } if !found { t.Fatalf("missing singleton sym-feature-x tag in %+v", d.T) } } func BenchmarkCheckin(b *testing.B) { path := filepath.Join(b.TempDir(), "bench.fossil") r, _ := repo.Create(path, "bench", simio.CryptoRand{}, "") defer r.Close() files := make([]File, 10) for i := range files { files[i] = File{Name: fmt.Sprintf("src/file%03d.go", i), Content: bytes.Repeat([]byte("x"), 1000)} } b.ResetTimer() var lastRid libfossil.FslID for i := 0; i < b.N; i++ { rid, _, _ := Checkin(r, CheckinOpts{ Files: files, Comment: "bench", User: "bench", Parent: lastRid, Time: time.Now().UTC(), }) lastRid = rid } } func BenchmarkListFiles(b *testing.B) { path := filepath.Join(b.TempDir(), "bench.fossil") r, _ := repo.Create(path, "bench", simio.CryptoRand{}, "") defer r.Close() files := make([]File, 50) for i := range files { files[i] = File{Name: fmt.Sprintf("src/file%03d.go", i), Content: []byte(fmt.Sprintf("c-%d", i))} } rid, _, _ := Checkin(r, CheckinOpts{ Files: files, Comment: "bench", User: "bench", Time: time.Now().UTC(), }) b.ResetTimer() for i := 0; i < b.N; i++ { ListFiles(r, rid) } } func TestCrosslinkInlineTCards(t *testing.T) { r := setupTestRepo(t) rid, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "hello.txt", Content: []byte("hello")}}, Comment: "initial commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } // Clear event and tagxref to simulate post-clone state (blobs exist, but not crosslinked). r.DB().Exec("DELETE FROM event") r.DB().Exec("DELETE FROM plink") r.DB().Exec("DELETE FROM leaf") r.DB().Exec("DELETE FROM mlink") r.DB().Exec("DELETE FROM tagxref") // Run Crosslink. n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } t.Logf("crosslinked %d artifacts", n) // Verify inline T-cards were processed. // Initial checkin adds: *branch trunk, +sym-trunk var branchCount int r.DB().QueryRow( "SELECT count(*) FROM tagxref JOIN tag USING(tagid) WHERE tagname='branch' AND rid=?", rid, ).Scan(&branchCount) if branchCount != 1 { t.Errorf("branch tagxref count=%d, want 1", branchCount) } var symCount int r.DB().QueryRow( "SELECT count(*) FROM tagxref JOIN tag USING(tagid) WHERE tagname='sym-trunk' AND rid=?", rid, ).Scan(&symCount) if symCount != 1 { t.Errorf("sym-trunk tagxref count=%d, want 1", symCount) } } func TestCrosslinkControlArtifact(t *testing.T) { r := setupTestRepo(t) rid, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "hello.txt", Content: []byte("hello")}}, Comment: "initial commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } // Create a control artifact via AddTag. _, err = tag.AddTag(r, tag.TagOpts{ TargetRID: rid, TagName: "testlabel", TagType: tag.TagSingleton, Value: "myvalue", User: "testuser", Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("AddTag: %v", err) } // Clear tagxref to simulate post-clone. r.DB().Exec("DELETE FROM tagxref") n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } t.Logf("crosslinked %d artifacts", n) // Verify control artifact's tag was re-applied. var count int r.DB().QueryRow( "SELECT count(*) FROM tagxref JOIN tag USING(tagid) WHERE tagname='testlabel' AND rid=?", rid, ).Scan(&count) if count != 1 { t.Errorf("testlabel tagxref count=%d, want 1", count) } } func TestCrosslinkControlEventRow(t *testing.T) { r := setupTestRepo(t) // Create a checkin to target rid, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "hello.txt", Content: []byte("hello")}}, Comment: "initial commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } // Create a control artifact via AddTag controlRID, err := tag.AddTag(r, tag.TagOpts{ TargetRID: rid, TagName: "testlabel", TagType: tag.TagSingleton, Value: "myvalue", User: "testuser", Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("AddTag: %v", err) } // Clear tagxref and event to simulate post-clone r.DB().Exec("DELETE FROM tagxref") r.DB().Exec("DELETE FROM event WHERE objid=?", controlRID) // Run Crosslink n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } // Verify event row (type='g') exists for the control artifact var eventType, comment string err = r.DB().QueryRow( "SELECT type, comment FROM event WHERE objid=?", controlRID, ).Scan(&eventType, &comment) if err != nil { t.Fatalf("event query: %v", err) } if eventType != "g" { t.Errorf("event type=%q, want 'g'", eventType) } if comment == "" { t.Errorf("event comment is empty, expected descriptive text") } t.Logf("event comment: %q", comment) } func TestDiscoveryQueryIdempotent(t *testing.T) { r := setupTestRepo(t) // Create a checkin. rid, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "hello.txt", Content: []byte("hello")}}, Comment: "initial commit", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } // Clear event and tagxref to simulate post-clone state. r.DB().Exec("DELETE FROM event") r.DB().Exec("DELETE FROM plink") r.DB().Exec("DELETE FROM leaf") r.DB().Exec("DELETE FROM mlink") r.DB().Exec("DELETE FROM tagxref") // First run should link artifacts. n1, err := Crosslink(r) if err != nil { t.Fatalf("first Crosslink: %v", err) } if n1 < 1 { t.Fatalf("first run linked %d, want >= 1", n1) } t.Logf("first run linked %d artifacts", n1) // Verify checkin was crosslinked. var eventCount int r.DB().QueryRow("SELECT count(*) FROM event WHERE objid=?", rid).Scan(&eventCount) if eventCount != 1 { t.Errorf("event count=%d, want 1", eventCount) } // Second run should link nothing (idempotent). n2, err := Crosslink(r) if err != nil { t.Fatalf("second Crosslink: %v", err) } if n2 != 0 { t.Errorf("second run linked %d, want 0 (idempotent)", n2) } t.Logf("second run linked %d artifacts (idempotent)", n2) } func TestCrosslinkCherrypick(t *testing.T) { r := setupTestRepo(t) // Create initial checkin rid1, uuid1, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "file.txt", Content: []byte("v1")}}, Comment: "first", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("first Checkin: %v", err) } // Create second checkin (parent of cherrypick target) rid2, uuid2, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "file.txt", Content: []byte("v2")}}, Comment: "second", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("second Checkin: %v", err) } // Manually create a manifest with Q-card (cherrypick) // Since Checkin doesn't support Q-cards, we construct the deck manually d := &deck.Deck{ Type: deck.Checkin, C: "cherrypick commit", D: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC), U: deck.User("testuser"), P: []string{uuid1}, // parent is rid1 Q: []deck.CherryPick{ {Target: uuid2, IsBackout: false}, // cherrypick rid2 }, F: []deck.FileCard{ {Name: "file.txt", UUID: uuid2}, // use file from rid2 }, } // Compute R-card (manifest hash) d.R = "0000000000000000000000000000000000000000" // simplified for test // Marshal and store the manifest blob manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid3, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("Store manifest: %v", err) } // Clear crosslink tables to simulate post-clone r.DB().Exec("DELETE FROM event WHERE objid=?", rid3) r.DB().Exec("DELETE FROM plink WHERE cid=?", rid3) r.DB().Exec("DELETE FROM mlink WHERE mid=?", rid3) r.DB().Exec("DELETE FROM cherrypick WHERE childid=?", rid3) // Run Crosslink n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } // Verify cherrypick table var count int r.DB().QueryRow( "SELECT count(*) FROM cherrypick WHERE parentid=? AND childid=? AND isExclude=0", rid2, rid3, ).Scan(&count) if count != 1 { t.Errorf("cherrypick count=%d, want 1", count) } } func TestCrosslinkWiki(t *testing.T) { r := setupTestRepo(t) // Manually create a wiki manifest wikiTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) wikiContent := []byte("This is a test wiki page") d := &deck.Deck{ Type: deck.Wiki, L: "TestPage", U: deck.User("testuser"), W: wikiContent, D: wikiTime, } // Marshal and store the wiki manifest blob manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("Store manifest: %v", err) } // Clear crosslink tables to simulate post-clone r.DB().Exec("DELETE FROM event WHERE objid=?", rid) r.DB().Exec("DELETE FROM tagxref WHERE rid=?", rid) // Run Crosslink n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } // Verify event row (type='w') var eventType, comment string err = r.DB().QueryRow( "SELECT type, comment FROM event WHERE objid=?", rid, ).Scan(&eventType, &comment) if err != nil { t.Fatalf("event query: %v", err) } if eventType != "w" { t.Errorf("event type=%q, want 'w'", eventType) } if comment != "+TestPage" { t.Errorf("event comment=%q, want '+TestPage'", comment) } // Verify wiki-TestPage tag in tagxref var tagCount int r.DB().QueryRow(` SELECT count(*) FROM tagxref JOIN tag USING(tagid) WHERE tagname='wiki-TestPage' AND rid=? `, rid).Scan(&tagCount) if tagCount != 1 { t.Errorf("wiki-TestPage tag count=%d, want 1", tagCount) } } func TestCrosslinkTicket(t *testing.T) { r := setupTestRepo(t) // Manually create a ticket manifest ticketTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) ticketUUID := "0123456789abcdef0123456789abcdef01234567" d := &deck.Deck{ Type: deck.Ticket, K: ticketUUID, U: deck.User("testuser"), D: ticketTime, J: []deck.TicketField{ {Name: "title", Value: "Test ticket"}, {Name: "status", Value: "Open"}, }, } // Marshal and store the ticket manifest blob manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("Store manifest: %v", err) } // Clear crosslink tables to simulate post-clone r.DB().Exec("DELETE FROM tagxref WHERE rid=?", rid) // Run Crosslink n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } // Verify tkt-* tag in tagxref var tagCount int r.DB().QueryRow(` SELECT count(*) FROM tagxref JOIN tag USING(tagid) WHERE tagname=? AND rid=? `, fmt.Sprintf("tkt-%s", ticketUUID), rid).Scan(&tagCount) if tagCount != 1 { t.Errorf("tkt-%s tag count=%d, want 1", ticketUUID, tagCount) } } func TestCrosslinkEvent(t *testing.T) { r := setupTestRepo(t) // Manually create an event (technote) manifest eventTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) eventUUID := "fedcba9876543210fedcba9876543210fedcba98" eventBody := []byte("This is a technote body") d := &deck.Deck{ Type: deck.Event, E: &deck.EventCard{ Date: eventTime, UUID: eventUUID, }, U: deck.User("testuser"), D: eventTime, W: eventBody, C: "Test technote", } // Marshal and store the event manifest blob manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("Store manifest: %v", err) } // Clear crosslink tables to simulate post-clone r.DB().Exec("DELETE FROM event WHERE objid=?", rid) r.DB().Exec("DELETE FROM tagxref WHERE rid=?", rid) // Run Crosslink n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } // Verify event row (type='e') var eventType, comment string err = r.DB().QueryRow( "SELECT type, comment FROM event WHERE objid=?", rid, ).Scan(&eventType, &comment) if err != nil { t.Fatalf("event query: %v", err) } if eventType != "e" { t.Errorf("event type=%q, want 'e'", eventType) } if comment != "Test technote" { t.Errorf("event comment=%q, want 'Test technote'", comment) } // Verify event-* tag in tagxref var tagCount int r.DB().QueryRow(` SELECT count(*) FROM tagxref JOIN tag USING(tagid) WHERE tagname=? AND rid=? `, fmt.Sprintf("event-%s", eventUUID), rid).Scan(&tagCount) if tagCount != 1 { t.Errorf("event-%s tag count=%d, want 1", eventUUID, tagCount) } } func TestCrosslinkAttachment(t *testing.T) { r := setupTestRepo(t) // Manually create an attachment manifest with wiki target attachTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) d := &deck.Deck{ Type: deck.Attachment, A: &deck.AttachmentCard{ Filename: "test.txt", Target: "TestPage", Source: "abc123", }, U: deck.User("testuser"), D: attachTime, C: "Attach test file", } // Marshal and store the attachment manifest blob manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("Store manifest: %v", err) } // Clear crosslink tables to simulate post-clone r.DB().Exec("DELETE FROM attachment WHERE attachid=?", rid) r.DB().Exec("DELETE FROM event WHERE objid=?", rid) // Run Crosslink n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } // Verify attachment table row var target, filename, src string var isLatestRaw any err = r.DB().QueryRow( "SELECT target, filename, src, isLatest FROM attachment WHERE attachid=?", rid, ).Scan(&target, &filename, &src, &isLatestRaw) if err != nil { t.Fatalf("attachment query: %v", err) } if target != "TestPage" { t.Errorf("target=%q, want 'TestPage'", target) } if filename != "test.txt" { t.Errorf("filename=%q, want 'test.txt'", filename) } if src != "abc123" { t.Errorf("src=%q, want 'abc123'", src) } isLatest, _ := db.ScanInt(isLatestRaw) if isLatest != 1 { t.Errorf("isLatest=%d, want 1", isLatest) } // Verify event row (type='w' for wiki attachment) var eventType, comment string err = r.DB().QueryRow( "SELECT type, comment FROM event WHERE objid=?", rid, ).Scan(&eventType, &comment) if err != nil { t.Fatalf("event query: %v", err) } if eventType != "w" { t.Errorf("event type=%q, want 'w'", eventType) } if comment != "Add attachment test.txt to wiki page TestPage" { t.Errorf("event comment=%q", comment) } } func TestCrosslinkCluster(t *testing.T) { r := setupTestRepo(t) // Store 2 blobs that will be members of the cluster blob1Data := []byte("test blob 1") rid1, uuid1, err := blob.Store(r.DB(), blob1Data) if err != nil { t.Fatalf("Store blob1: %v", err) } blob2Data := []byte("test blob 2") rid2, uuid2, err := blob.Store(r.DB(), blob2Data) if err != nil { t.Fatalf("Store blob2: %v", err) } // Verify both blobs are initially in unclustered table r.DB().Exec("INSERT OR IGNORE INTO unclustered(rid) VALUES(?)", rid1) r.DB().Exec("INSERT OR IGNORE INTO unclustered(rid) VALUES(?)", rid2) var count1, count2 int r.DB().QueryRow("SELECT count(*) FROM unclustered WHERE rid=?", rid1).Scan(&count1) r.DB().QueryRow("SELECT count(*) FROM unclustered WHERE rid=?", rid2).Scan(&count2) if count1 != 1 || count2 != 1 { t.Fatalf("blobs not in unclustered: rid1=%d rid2=%d", count1, count2) } // Create a cluster manifest with these two blobs as members clusterTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) d := &deck.Deck{ Type: deck.Cluster, M: []string{uuid1, uuid2}, D: clusterTime, } // Marshal and store the cluster manifest blob manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("Store manifest: %v", err) } // Clear crosslink tables to simulate post-clone r.DB().Exec("DELETE FROM tagxref WHERE rid=?", rid) // Run Crosslink n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } // Verify cluster tag var tagCount int r.DB().QueryRow(` SELECT count(*) FROM tagxref JOIN tag USING(tagid) WHERE tagname='cluster' AND rid=? `, rid).Scan(&tagCount) if tagCount != 1 { t.Errorf("cluster tag count=%d, want 1", tagCount) } // Verify both blobs were removed from unclustered table r.DB().QueryRow("SELECT count(*) FROM unclustered WHERE rid=?", rid1).Scan(&count1) r.DB().QueryRow("SELECT count(*) FROM unclustered WHERE rid=?", rid2).Scan(&count2) if count1 != 0 { t.Errorf("rid1 still in unclustered, count=%d", count1) } if count2 != 0 { t.Errorf("rid2 still in unclustered, count=%d", count2) } } func TestCrosslinkForum(t *testing.T) { r := setupTestRepo(t) // Create a forum post (thread starter) forumTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) forumContent := []byte("This is a test forum post") d := &deck.Deck{ Type: deck.ForumPost, H: "Discussion about sync", U: deck.User("testuser"), W: forumContent, D: forumTime, } // Marshal and store the forum manifest blob manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("Store manifest: %v", err) } // Clear crosslink tables to simulate post-clone r.DB().Exec("DELETE FROM event WHERE objid=?", rid) r.DB().Exec("DELETE FROM forumpost WHERE fpid=?", rid) // Run Crosslink n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } // Verify forumpost row with froot=self (thread starter) var froot int64 var fprev, firt interface{} var fmtime float64 err = r.DB().QueryRow( "SELECT froot, fprev, firt, fmtime FROM forumpost WHERE fpid=?", rid, ).Scan(&froot, &fprev, &firt, &fmtime) if err != nil { t.Fatalf("forumpost query: %v", err) } if froot != int64(rid) { t.Errorf("froot=%d, want %d (self)", froot, rid) } if fprev != nil { t.Errorf("fprev=%v, want nil (no previous edit)", fprev) } if firt != nil { t.Errorf("firt=%v, want nil (thread starter)", firt) } // Verify event row (type='f') var eventType, comment string err = r.DB().QueryRow( "SELECT type, comment FROM event WHERE objid=?", rid, ).Scan(&eventType, &comment) if err != nil { t.Fatalf("event query: %v", err) } if eventType != "f" { t.Errorf("event type=%q, want 'f'", eventType) } if comment != "Post: Discussion about sync" { t.Errorf("event comment=%q, want 'Post: Discussion about sync'", comment) } } // TestCrosslinkForumPostTableDropped reproduces #103: canonical `fossil // rebuild` drops on-demand tables it did not populate, including forumpost, // so a repository handed to us straight out of a rebuild may not have the // table at all. Crosslink must create it on demand the way canonical does, // rather than erroring with "no such table: forumpost". func TestCrosslinkForumPostTableDropped(t *testing.T) { r := setupTestRepo(t) // Simulate a repository straight out of a canonical rebuild that never // saw a forum post: the table does not exist at all. if _, err := r.DB().Exec("DROP TABLE forumpost"); err != nil { t.Fatalf("drop forumpost: %v", err) } forumTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) d := &deck.Deck{ Type: deck.ForumPost, H: "Discussion about sync", U: deck.User("testuser"), W: []byte("This is a test forum post"), D: forumTime, } manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } rid, _, err := blob.Store(r.DB(), manifestBytes) if err != nil { t.Fatalf("Store manifest: %v", err) } n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 1 { t.Fatalf("expected at least 1 artifact crosslinked, got %d", n) } var froot int64 if err := r.DB().QueryRow("SELECT froot FROM forumpost WHERE fpid=?", rid).Scan(&froot); err != nil { t.Fatalf("forumpost query: %v", err) } if froot != int64(rid) { t.Errorf("froot=%d, want %d (self)", froot, rid) } } func TestCrosslinkTwoPass(t *testing.T) { r := setupTestRepo(t) // Store a wiki manifest blob (Type=Wiki, L="TwoPassPage", U="test", W=content, D=time). wikiTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) wikiContent := []byte("Two-pass wiki content") wikiDeck := &deck.Deck{ Type: deck.Wiki, L: "TwoPassPage", U: deck.User("testuser"), W: wikiContent, D: wikiTime, } wikiBytes, err := wikiDeck.Marshal() if err != nil { t.Fatalf("Marshal wiki: %v", err) } wikiRid, _, err := blob.Store(r.DB(), wikiBytes) if err != nil { t.Fatalf("Store wiki: %v", err) } // Create a checkin via Checkin(). checkinRid, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "file.txt", Content: []byte("checkin content")}}, Comment: "test checkin", User: "testuser", Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } // Clear all event and tagxref rows to simulate post-clone state. r.DB().Exec("DELETE FROM event") r.DB().Exec("DELETE FROM tagxref") r.DB().Exec("DELETE FROM plink") r.DB().Exec("DELETE FROM leaf") r.DB().Exec("DELETE FROM mlink") // Call Crosslink once. n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n < 2 { t.Fatalf("expected n >= 2 artifacts crosslinked, got %d", n) } t.Logf("crosslinked %d artifacts", n) // Verify: wiki event type='w' exists. var wikiEventType string err = r.DB().QueryRow("SELECT type FROM event WHERE objid=?", wikiRid).Scan(&wikiEventType) if err != nil { t.Errorf("wiki event query: %v", err) } else if wikiEventType != "w" { t.Errorf("wiki event type=%q, want 'w'", wikiEventType) } // Verify: checkin event type='ci' exists. var checkinEventType string err = r.DB().QueryRow("SELECT type FROM event WHERE objid=?", checkinRid).Scan(&checkinEventType) if err != nil { t.Errorf("checkin event query: %v", err) } else if checkinEventType != "ci" { t.Errorf("checkin event type=%q, want 'ci'", checkinEventType) } } // forceDeltify rewrites rid as a delta against srcRid, the same way // content.Deltify would, without content.Deltify's size/ratio gate -- // exercising that gate isn't the point of the tests that use this, only // forcing candidates into a genuine delta relationship so Crosslink's // delta-chain-order sweep has something to reorder around. Mirrors // deltifyWrite (internal/content/deltify.go) exactly: compress the delta // bytes over the blob row, link it in delta, and verify the round trip // through content.Expand before trusting it. func forceDeltify(t *testing.T, r *repo.Repo, rid, srcRid libfossil.FslID) { t.Helper() data, err := content.Expand(r.DB(), rid) if err != nil { t.Fatalf("forceDeltify: expand rid=%d: %v", rid, err) } src, err := content.Expand(r.DB(), srcRid) if err != nil { t.Fatalf("forceDeltify: expand srcid=%d: %v", srcRid, err) } deltaBytes := delta.Create(src, data) compressed, err := blob.Compress(deltaBytes) if err != nil { t.Fatalf("forceDeltify: compress: %v", err) } if err := r.WithTx(func(tx *db.Tx) error { if _, err := tx.Exec("UPDATE blob SET content=? WHERE rid=?", compressed, rid); err != nil { return err } _, err := tx.Exec("REPLACE INTO delta(rid, srcid) VALUES(?, ?)", rid, srcRid) return err }); err != nil { t.Fatalf("forceDeltify: %v", err) } if _, err := content.Expand(r.DB(), rid); err != nil { t.Fatalf("forceDeltify: round-trip verify rid=%d: %v", rid, err) } } // TestCrosslinkReversedDeltaOrderStillPropagatesAndLeafs is the #102 // regression: manifest.Checkin deltifies each checkin's parent manifest // against the new one it just wrote (content.Deltify(parent, this)), so in // a repository old enough for that to take, the chain root is the newest // commit and the deepest ancestor is the oldest -- forced here directly // with forceDeltify rather than relying on real commits being large and // similar enough for content.Deltify's own size/ratio gate to take the // delta, which is a orthogonal concern this test doesn't need to exercise. // Once the derived tables are cleared to simulate a fresh clone, Crosslink's // delta-chain-order sweep visits v3 first and v1 last -- the exact reverse // of commit order, and the opposite of what the old per-checkin // PropagateAll(parent) and incremental leaf bookkeeping assumed. tagxref and // leaf must still come out correct: v1's propagating branch tag on all // three, and only v3 marked a leaf. func TestCrosslinkReversedDeltaOrderStillPropagatesAndLeafs(t *testing.T) { r := setupTestRepo(t) v1, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1 content")}}, Comment: "v1", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin v1: %v", err) } v2, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v2 content")}}, Comment: "v2", User: "testuser", Parent: v1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin v2: %v", err) } v3, _, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v3 content")}}, Comment: "v3", User: "testuser", Parent: v2, Time: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin v3: %v", err) } forceDeltify(t, r, v1, v2) forceDeltify(t, r, v2, v3) // Reset to the state a fresh clone is in right before crosslinking. for _, tbl := range crosslinkDerivedTables { if _, err := r.DB().Exec("DELETE FROM " + tbl); err != nil { t.Fatalf("clear %s: %v", tbl, err) } } n, err := Crosslink(r) if err != nil { t.Fatalf("Crosslink: %v", err) } if n != 3 { t.Fatalf("linked=%d, want 3", n) } for _, rid := range []libfossil.FslID{v1, v2, v3} { var value string err := r.DB().QueryRow(` SELECT value FROM tagxref JOIN tag USING(tagid) WHERE tagname='branch' AND rid=? `, rid).Scan(&value) if err != nil { t.Errorf("rid=%d: branch tag missing: %v", rid, err) } else if value != "trunk" { t.Errorf("rid=%d: branch tag=%q, want %q", rid, value, "trunk") } } var leafRids []libfossil.FslID rows, err := r.DB().Query("SELECT rid FROM leaf ORDER BY rid") if err != nil { t.Fatalf("leaf query: %v", err) } for rows.Next() { var rid libfossil.FslID if err := rows.Scan(&rid); err != nil { t.Fatalf("leaf scan: %v", err) } leafRids = append(leafRids, rid) } rows.Close() if len(leafRids) != 1 || leafRids[0] != v3 { t.Errorf("leaf=%v, want [%d] (v3 only)", leafRids, v3) } }