Artifact ed75b9c5ab3c144e7eef9cd2946947c0d11d1fad50a09c9b1d27fee404feb5f8:
- File
repo_timeline_test.go
— part of check-in
[9a2697c556]
at
2026-07-19 16:00:36
on branch trunk
— fix: split repository enumeration from ancestry walking (#45) (#54)
Repo.Timeline was an ancestry walk wearing an enumeration's name: it required a start rid, emitted that check-in, followed plink where isprim=1 to the primary parent, and repeated until the chain ended. It never touched the event table and never visited a second parent or a sibling branch head, so it silently omitted any check-in that wasn't a first-parent ancestor of the given start rid — on libfossil's own repository that was 577 of 3,761 check-ins, with no error and no warning. The limit option never bound because the (wrong) walk exhausted first.
Split into two operations under honest names:
- Repo.Timeline(TimelineOpts) enumerates the event table newest first, every kind by default or a single kind via Type. This matches canonical `fossil timeline`'s -t/--type default (a filter only applies when given and not 'a'). - Repo.Ancestry(LogOpts) keeps the original first-parent walk, unchanged in behavior, under a name that says what it does.
Breaking change, no deprecated shim (pre-1.0, clean break): callers of the old Timeline(LogOpts) need to move to Ancestry(LogOpts) if they want the walk, or to Timeline(TimelineOpts) if they want the enumeration fossil's own timeline command performs. The only in-tree callers were the `repo timeline` CLI command and the two examples; all three are migrated to Timeline(TimelineOpts). See CHANGELOG.md.
LogEntry gains a Kind field identifying which of event.type's six kinds (ci, e, f, g, t, w) an entry is; Parents is only populated for check-ins, since plink only relates check-in artifacts.
Timeline's pagination cursor orders by (mtime DESC, rid DESC) instead of canonical fossil's bare mtime DESC with no tie-break — a deliberate improvement, not an oversight, since fossil's web cursor combines that missing tie-break with a one-second timestamp slop and can repeat or skip rows at a page boundary. Getting the composite cursor to actually round-trip losslessly required fixing a real bug along the way: JulianToTime truncated instead of rounded when converting a julian day back to milliseconds, so TimeToJulian(JulianToTime(m)) was not always equal to m even for exact-millisecond inputs — enough to misplace a row at a page boundary under exactly the same-mtime conditions this issue's acceptance criteria test for. Fixed to round to nearest. Because the same class of drift also appears in the ncruces/WASM sqlite driver's own internal REAL-to-time.Time conversion (independent of this library's code), the cursor comparison also carries a small explicit tolerance rather than relying on bit-exact equality across drivers.
Both the ancestry walk's loop and the enumeration's row loop now carry an explicit, testable bound so a corrupt or cyclic plink table can't hang the process. (user: [email protected], size: 5763) [annotate] [blame] [check-ins using]
package libfossil import ( "fmt" "path/filepath" "testing" "time" _ "github.com/danmestas/libfossil/internal/testdriver" ) // TestTimelineEnumeratesEveryEvent is the acceptance criterion for the bug // this issue fixes: Timeline with no type filter must return every row of // event, including check-ins that are not first-parent ancestors of any // single start rid — the thing the old Timeline (now Ancestry) could // never see. func TestTimelineEnumeratesEveryEvent(t *testing.T) { dir := t.TempDir() r, err := Create(filepath.Join(dir, "test.fossil"), CreateOpts{User: "test"}) if err != nil { t.Fatal(err) } defer r.Close() rid1, _, err := r.Commit(CommitOpts{ Files: []FileToCommit{{Name: "a.txt", Content: []byte("v1")}}, Comment: "trunk root", User: "test", }) if err != nil { t.Fatal(err) } if _, _, err := r.Commit(CommitOpts{ ParentID: rid1, Files: []FileToCommit{{Name: "a.txt", Content: []byte("v2")}}, Comment: "trunk tip", User: "test", }); err != nil { t.Fatal(err) } branchRID, branchUUID, err := r.Commit(CommitOpts{ ParentID: rid1, Files: []FileToCommit{{Name: "a.txt", Content: []byte("v3")}}, Comment: "sibling branch head", User: "test", Tags: []TagSpec{{Name: "branch", Value: "feature-x"}}, }) if err != nil { t.Fatal(err) } if branchRID <= 0 { t.Fatal("branch commit returned rid <= 0") } var wantCount int if err := r.DB().QueryRow("SELECT count(*) FROM event").Scan(&wantCount); err != nil { t.Fatal(err) } entries, err := r.Timeline(TimelineOpts{}) if err != nil { t.Fatal(err) } if len(entries) != wantCount { t.Fatalf("Timeline returned %d entries, want %d (SELECT count(*) FROM event)", len(entries), wantCount) } found := false for _, e := range entries { if e.UUID == branchUUID { found = true } } if !found { t.Fatalf("sibling branch head %s missing from Timeline output", branchUUID) } } // TestTimelineTypeFilter covers Type=EventKindCheckin returning exactly // the check-in rows. func TestTimelineTypeFilter(t *testing.T) { dir := t.TempDir() r, err := Create(filepath.Join(dir, "test.fossil"), CreateOpts{User: "test"}) if err != nil { t.Fatal(err) } defer r.Close() if _, _, err := r.Commit(CommitOpts{ Files: []FileToCommit{{Name: "a.txt", Content: []byte("v1")}}, Comment: "checkin", User: "test", }); err != nil { t.Fatal(err) } var wantCi int if err := r.DB().QueryRow("SELECT count(*) FROM event WHERE type='ci'").Scan(&wantCi); err != nil { t.Fatal(err) } entries, err := r.Timeline(TimelineOpts{Type: EventKindCheckin}) if err != nil { t.Fatal(err) } if len(entries) != wantCi { t.Fatalf("Timeline(Type=ci) returned %d entries, want %d", len(entries), wantCi) } for _, e := range entries { if e.Kind != EventKindCheckin { t.Fatalf("entry kind = %q, want %q", e.Kind, EventKindCheckin) } } } // TestTimelineOrderingAndLimit covers (mtime DESC, rid DESC) ordering and // that Limit both bounds the result and is reachable — the defining // symptom of the original bug was a limit that never bound. func TestTimelineOrderingAndLimit(t *testing.T) { dir := t.TempDir() r, err := Create(filepath.Join(dir, "test.fossil"), CreateOpts{User: "test"}) if err != nil { t.Fatal(err) } defer r.Close() var parent int64 var rids []int64 for i := 0; i < 5; i++ { rid, _, err := r.Commit(CommitOpts{ ParentID: parent, Files: []FileToCommit{{Name: "a.txt", Content: []byte(fmt.Sprintf("v%d", i))}}, Comment: fmt.Sprintf("commit %d", i), User: "test", Time: time.Date(2024, 1, 15, 10, i, 0, 0, time.UTC), }) if err != nil { t.Fatalf("commit %d: %v", i, err) } parent = rid rids = append(rids, rid) } entries, err := r.Timeline(TimelineOpts{Limit: 2}) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Fatalf("len = %d, want 2", len(entries)) } // Newest first: the last two commits, newest rid first. if entries[0].RID != rids[4] || entries[1].RID != rids[3] { t.Fatalf("order = [%d, %d], want [%d, %d]", entries[0].RID, entries[1].RID, rids[4], rids[3]) } } // TestTimelinePaginationExactlyOnce covers the acceptance criterion that // paginating the full set yields each event exactly once, including when // several events share an identical mtime. func TestTimelinePaginationExactlyOnce(t *testing.T) { dir := t.TempDir() r, err := Create(filepath.Join(dir, "test.fossil"), CreateOpts{User: "test"}) if err != nil { t.Fatal(err) } defer r.Close() sameTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) var parent int64 var allUUIDs []string for i := 0; i < 6; i++ { rid, uuid, err := r.Commit(CommitOpts{ ParentID: parent, Files: []FileToCommit{{Name: "a.txt", Content: []byte(fmt.Sprintf("v%d", i))}}, Comment: fmt.Sprintf("commit %d", i), User: "test", Time: sameTime, // every commit shares the exact same mtime }) if err != nil { t.Fatalf("commit %d: %v", i, err) } parent = rid allUUIDs = append(allUUIDs, uuid) } const pageSize = 2 seen := make(map[string]int) var total int var before time.Time var after FslID for page := 0; page < 10; page++ { // explicit bound on the pagination loop entries, err := r.Timeline(TimelineOpts{Limit: pageSize, Before: before, After: after}) if err != nil { t.Fatalf("page %d: %v", page, err) } if len(entries) == 0 { break } for _, e := range entries { seen[e.UUID]++ total++ } last := entries[len(entries)-1] before = last.Time after = FslID(last.RID) } if total != len(allUUIDs) { t.Fatalf("paginated total = %d, want %d", total, len(allUUIDs)) } for _, uuid := range allUUIDs { if seen[uuid] != 1 { t.Fatalf("event %s seen %d times, want exactly 1", uuid, seen[uuid]) } } }